先记住这个答案
Vue 3 中不带参数的组件 v-model 默认展开为 modelValue prop 和 update:modelValue 事件。子组件读取 props.modelValue 渲染当前值,在用户输入时 emit("update:modelValue", nextValue);父组件监听事件并更新自己的状态,再把新值传回。子组件不应直接修改 prop,因为状态所有权仍在父级。这个模式可以看作受控组件契约:是否接受、规范化或撤销新值由父级决定。defineModel 是 Vue 3.4+ 对同一契约的编译期简写。
- 默认 prop 是 modelValue
- 默认事件是 update:modelValue
- 父级仍拥有最终状态
语法糖背后是公开接口
<TextField v-model="name" /> 等价于传入 :model-value="name" 并监听 @update:model-value 后执行 name = $event。理解展开结果后,校验失败、延迟提交和只读模式都能落在明确边界,而不是让子组件偷偷改父状态。
事件必须携带完整候选值。只发送“changed”让父级再读取子实例,会破坏单向契约并增加时序耦合。对象值还要决定是发送新对象还是结构化变更,避免子级直接原地修改共享引用。
手写 modelValue 与更新事件
下例把输入节点的 value 绑定到 prop,原生 input 事件发生时提取字符串并通知父级。父级可以在监听器中去空格、拒绝非法值或写入 store,再通过下一次 prop 更新控制最终显示。
测试应断言两个方向:父级改变 modelValue 后子节点显示新值;触发输入后只发出一次正确载荷,并由父级更新后才改变受控状态。只断言 DOM 事件触发不足以验证组件契约。
import { defineComponent, h } from 'vue'
const TextField = defineComponent({
props: { modelValue: { type: String, required: true } },
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => h('input', {
value: props.modelValue,
onInput: (event: Event) => {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
})
}
})组件只读取 prop 并发送候选字符串。父级模板使用 <TextField v-model="name" /> 即可连接这两个接口,状态更新策略仍由父级决定。
更新频率应匹配产品语义
文本输入通常每次 input 就更新,昂贵搜索可以在父级防抖,金额或复杂对象则可能采用草稿与提交两个事件。不要为了性能改变 v-model 契约却不告诉调用方,否则外部看到的 value 与内部显示会长期分叉。
发送与当前值相同的更新通常没有必要,父级响应式系统也可能跳过等值更新。需要记录用户动作时应另发语义事件,例如 submit 或 blur,不能把状态更新次数当作完整行为日志。
容易答错的地方
- 在子组件内直接修改 modelValue
- prop 是只读输入,直接写会告警且破坏父级所有权。需要本地草稿时创建独立 ref,并明确何时同步外部变化、何时提交。
- 把事件写成 update:model-value
- 契约的脚本名称是 update:modelValue;模板监听可以使用 @update:model-value。声明、emit 与类型定义应使用同一个完整事件名。
面试官还会怎么问?
本地输入草稿怎样避免覆盖外部更新?
先定义冲突规则:未编辑时跟随 prop,编辑中保留草稿或提示版本变化,提交后 emit 完整值。不能用一个无条件 watch 双向复制,否则容易形成覆盖和循环。
v-model 可以传对象吗?
可以,但子级不要原地修改来自父级的对象。发送结构共享可控的新对象,或设计字段级事件,并考虑深层比较与验证成本。
defineModel 改变运行时契约吗?
不会,它在编译期生成对应 prop 与更新事件,并给 setup 返回可读写 ref。版本和默认值同步等边界仍需理解。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。