自定义 v-model 完全指南:从 Vue 2 到 Vue 3


自定义 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" />

所以「双向绑定」拆开看只有两步:

  1. :value="name" —— 父数据流向输入框(单向)
  2. @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

五、常见坑

  1. Vue 2 改 prop 不会触发更新this.someProp = x 不会反向影响父组件,必须 $emit 对应事件。
  2. Vue 3 忘记声明 emits:会有警告,且事件会透传到 DOM 上变成无效 attribute。
  3. Vue 2 对象/数组 prop 直接改属性:虽然能生效(引用没变),但语义上是反模式;应通过事件回传新对象,或在子组件内 copy 一份再改。
  4. Vue 2 的 .sync 与 Vue 3 的 v-model:xxx:两者能力等价,但写法不同,迁移时注意替换。
  5. 复选框别用默认 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 约定。

参考资料


文章作者: 弈心
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 弈心 !
评论
  目录