自定义 v-model 完全指南:从 Vue 2 到 Vue 3
先给结论:v-model 就是语法糖,它替你拼好了「绑定值 + 监听事件」这层模板代码。在原生表单上它变成 :value + @input;在自定义组件上,它变成「父传子一个 prop,子改值时 $emit 一个事件回父」。区别在于 Vue 2 和 Vue 3 约定的 prop 名和事件名不一样,Vue 3 还支持多个 v-model 和修饰符。
下面从原理到实战讲清楚。
一、v-model 的本质:语法糖
任何 v-model 都能手动展开成「单向绑定 + 事件回调」:
<input type="text" v-model="name" />
等价于:
<input type="text" :value="name" @input="name = $event.target.value" />
所以「双向绑定」拆开看只有两步:
:value="name"—— 父数据流向输入框(单向)@input把输入框的新值写回name(另一个单向)
自定义组件里实现 v-model,本质也是把这两步接好:用一个 prop 接收父传下来的值,在数据变化时 $emit 一个约定好的事件把新值抛回去。
二、Vue 2 中实现自定义 v-model
2.1 默认约定:value + input
Vue 2 规定,组件上的 v-model 默认使用名为 value 的 prop 和名为 input 的事件。
<template>
<input type="text" :value="value" :placeholder="placeholder" @input="handleInput" />
</template>
<script>
export default {
name: "KInput",
props: {
value: [String, Number],
placeholder: String,
},
methods: {
handleInput($event) {
// 把新值通过 input 事件抛回父组件
this.$emit("input", $event.target.value);
},
},
};
</script>
父组件就能直接用了:
<KInput v-model="keyword" placeholder="搜索" />
2.2 用 model 选项避免 prop 冲突
问题在于:<input type="checkbox"> 这类原生控件自己就用 value 属性,如果我们的组件也想用 value 当数据,就撞车了。Vue 2 提供 model 选项来改掉默认约定:
Vue.component("base-checkbox", {
model: {
prop: "checked",
event: "change",
},
props: {
checked: Boolean, // 注意:仍要在 props 里声明
},
template: `
<input
type="checkbox"
:checked="checked"
@change="$emit('change', $event.target.checked)"
>
`,
});
<base-checkbox v-model="lovingVue"></base-checkbox>
这里 lovingVue 会传进 checked prop;当组件触发 change 并带上新值时,lovingVue 被更新。model 选项只是「换个 prop 名和事件名」,机制没变。
2.3 做透明包裹组件:inheritAttrs + $listeners
有时会封装一个 <input>,外层套个 <label>。直接用 .native 监听根元素原生事件不可靠——一旦根元素被重构(比如换成 <label> 包 <input>),.native 会静默失效。
正确做法是 inheritAttrs: false + v-on="$listeners",把父级监听器透传到真正的 <input> 上:
Vue.component("base-input", {
inheritAttrs: false,
props: ["label", "value"],
computed: {
inputListeners() {
const vm = this;
return Object.assign({}, this.$listeners, {
input(event) {
vm.$emit("input", event.target.value);
},
});
},
},
template: `
<label>
{{ label }}
<input
v-bind="$attrs"
:value="value"
v-on="inputListeners"
>
</label>
`,
});
这样 <base-input> 用起来和原生 <input> 完全一样:所有 attribute 和事件都能正常工作,也天然支持 v-model。
2.4 多个「双向绑定」:.sync 修饰符
v-model 一个组件只能用一次。如果要在子组件上双向绑定多个 prop,用 .sync(2.3.0+):
// 子组件
this.$emit("update:title", newTitle);
<!-- 父组件 -->
<text-document :title.sync="doc.title"></text-document>
它本质是 :title="doc.title" @update:title="doc.title = $event" 的缩写。注意 .sync 不能接表达式,只能接属性名;用 v-bind.sync="doc" 还能把整个对象的每个属性都变成独立可双向绑定的 prop。
三、Vue 3 中实现自定义 v-model
Vue 3 把默认约定改了:prop 从 value 改成 modelValue,事件从 input 改成 update:modelValue。原因是要支持多个 v-model 和更好的 TypeScript 推断。
3.1 默认:modelValue + update:modelValue
<CustomInput v-model="name" />
<!-- 等价于::modelValue="name" @update:modelValue="name = $event" -->
提示:Vue 3 强烈建议用
emits显式声明事件,否则会有控制台警告,且事件会透传到 DOM 上变成无效 attribute。
3.2 多个 v-model
Vue 3 可以给同一个组件绑多个 v-model,只要给每个指定名字:
对应实现:
export default {
props: {
firstName: String,
lastName: String,
},
emits: ["update:firstName", "update:lastName"],
};
模板里分别 $emit('update:firstName', ...) 和 $emit('update:lastName', ...) 即可。
3.3 v-model 修饰符
修饰符让你在「写入父值之前」先加工数据。以自定义 capitalize(首字母大写)为例:
Options API 下,修饰符通过 modelModifiers prop 拿到(命名 model 则是 xxxModifiers):
app.component("MyInput", {
props: {
modelValue: String,
modelModifiers: { default: () => ({}) },
},
emits: ["update:modelValue"],
methods: {
onInput(e) {
let value = e.target.value;
if (this.modelModifiers.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1);
}
this.$emit("update:modelValue", value);
},
},
template: `<input :value="modelValue" @input="onInput">`,
});
内置的 .trim、.number、.lazy 也是同样机制,Vue 已帮你处理,自定义组件里要自己读 modelModifiers 实现。
3.4 defineModel 语法糖(Vue 3.3+)
手动写 prop + emit 样板代码太多。Vue 3.3 起 <script setup> 里可以用 defineModel() 一步到位——它返回一个可直接读写、且自动完成双向绑定的 ref:
父组件照常 <MyInput v-model="name" /> 即可。需要带类型或默认值:
const model = defineModel({ type: String, default: "" });
命名 model 也一样简单:
const firstName = defineModel("firstName");
const lastName = defineModel("lastName");
defineModel 是现在写自定义 v-model 的首选方式,样板代码基本清零。
四、实战:一个带清除按钮的输入框
综合上面的知识,写一个 Vue 3 组件:
父组件:
<ClearableInput v-model="search" />
因为 model 是双向 ref,输入框输入和点击 × 清空都会同步回父组件的 search。
五、常见坑
- Vue 2 改 prop 不会触发更新:
this.someProp = x不会反向影响父组件,必须$emit对应事件。 - Vue 3 忘记声明 emits:会有警告,且事件会透传到 DOM 上变成无效 attribute。
- Vue 2 对象/数组 prop 直接改属性:虽然能生效(引用没变),但语义上是反模式;应通过事件回传新对象,或在子组件内 copy 一份再改。
- Vue 2 的
.sync与 Vue 3 的v-model:xxx:两者能力等价,但写法不同,迁移时注意替换。 - 复选框别用默认 value:原生
value语义冲突,用model选项(Vue 2)或命名 model(Vue 3)规避。
总结
v-model永远是「prop + 事件」的语法糖,理解了这点就不慌。- Vue 2:默认
value/input,用model选项改约定,多个双向绑定用.sync。 - Vue 3:默认
modelValue/update:modelValue,支持多个v-model:xxx、修饰符,3.3+ 用defineModel()最省事。 - 新项目直接用
defineModel;维护老项目记得 Vue 2 那套value/input约定。