Skip to content

Vue defineModel:把 v-model 契约讲清楚 ​

译注:本文编译自 dev.to 作者 parsajiravand 的技术文章《Vue defineModel: The v-model Contract, Explained》,原文发布于 2026 年 10 月 5 日。原文链接见文末。本文基于 Vue 3.5.x(已验证 3.5.43,2026 年 9 月发布)撰写。

写过组件双向绑定的人大概都经历过这套流程:声明一个 modelValue prop,声明一个 update:modelValue emit,再用一个 computed 的 get/set 把两者接起来,然后祈祷两处事件名拼写完全一致。一旦拼错——updata:modelValue、update:modelvalue,随便哪种——不会有报错,最多在开发模式下滚过一条警告。父组件的 v-model 就此停止更新,你只能对着一个看起来完全正确的组件做二分排查。

Vue 3.4 用 defineModel 这一个宏替换掉了整套动作。但它并不是给 v-model 硬加的新魔法:它只是 Vue 替你写了同一份 prop 与事件的契约。一旦你能看清这份契约,这里所有的边界情况都会变得可预测,而不再令人意外。

v-model 的本质:一个 prop 加一个事件 ​

核心心智模型是:无论外层写什么语法,组件上的 v-model 永远只编译成两样东西——一个绑定当前值的 prop,以及一个把新值写回去的事件监听器。<MyInput> 上的 v-model="x" 就是 :model-value="x" @update:model-value="x = $event" 的简写。这就是全部契约。

Vue 历史上每一版「组件双向绑定」——Vue 2 的 value/input 约定、早期 Vue 3 的 modelValue/update:modelValue 约定,以及今天的 defineModel——区别只在于对同一对 prop/emit 的自动化程度不同。

defineModel() 是一个编译器宏(只能在 <script setup> 内使用;在外部,它编译成的 useModel(props, 'modelValue') 在你自行声明 prop 和 emit 后做同样的事),一次声明会生成三样东西:

  1. 一个 prop(默认名为 modelValue,也可自定义),等同于写在 defineProps 里;
  2. 一个 emit(update:modelValue),等同于写在 defineEmits 里;
  3. 一个类 computed 的 ref,读取 prop,写入时触发 emit——正是你原本手写的那套 get/set 关系。

唯一不只是「更短」的行为:如果父组件完全没有绑定该 prop,defineModel 返回的 ref 不会像普通 prop 那样停留在 undefined 且只读,而是退化为一个普通的本地 ref,以你给的 default 为初始值,完全可写,没有父组件需要同步。正是这个回退行为,让 defineModel 适合真正可选的双向绑定。

基本用法与选项 ​

vue
<!-- CurrencyInput.vue -->
<script setup>
const model = defineModel({ default: 0 })
</script>

<template>
  <input type="number" v-model.number="model" />
</template>

父组件中:

vue
<CurrencyInput v-model="price" />

子组件里的 model 和父组件里的 price 是两个不同的 ref,由 defineModel 写好的 prop/emit 对保持同步。在子组件内修改 model.value 不会直接跨过组件边界,而是发出事件,由父组件的 v-model 接住并写入 price.value。

defineModel 接受与 prop 相同的选项,因为底层它就是一个 prop:

typescript
// 必填 model —— TypeScript 会自动从类型中移除 undefined
const model = defineModel<string>({ required: true })

// 带默认值的可选 model,显式标注类型
const model = defineModel<string | undefined>({ default: 'Untitled' })

显式标注类型很重要:在 lang="ts" 的 .vue 文件中,未标注类型的 defineModel() 推断会像未标注的 prop 一样宽松。

多个具名 model ​

组件可以通过给每个 defineModel 调用命名来暴露多个双向绑定:

vue
<!-- NameFields.vue -->
<script setup>
const firstName = defineModel('firstName', { default: '' })
const lastName = defineModel('lastName', { default: '' })
</script>
vue
<NameFields v-model:first-name="first" v-model:last-name="last" />

每个具名 defineModel 调用都是独立的 prop/emit 对,firstName 与 lastName 之间除了组件逻辑本身之外没有共享状态。

修饰符与值转换 ​

父组件可以像原生输入上的 v-model.trim 一样,给任何 v-model 附加修饰符。通过解构 defineModel 返回的第二个值来读取它们,并用 get/set 处理:

vue
<!-- TitleField.vue -->
<script setup>
const [model, modifiers] = defineModel({
  set(value) {
    return modifiers.capitalize && typeof value === 'string'
      ? value.charAt(0).toUpperCase() + value.slice(1)
      : value
  },
})
</script>
vue
<TitleField v-model.capitalize="title" />

边界情况与坑 ​

对象和数组默认值要用工厂函数。 这与普通 prop 默认值规则相同,但 defineModel({ default: 0 }) 看起来太像普通赋值,容易忘记。default: [] 会让每个未绑定值的实例拿到同一个数组,在一个实例中修改会泄漏到其他实例,应写成 default: () => []。

没有父绑定意味着本地 ref,而不是 undefined。 这是 defineModel 与普通非必填 prop 真正不同的地方,也值得专门测试:完全不带 v-model 渲染的组件应当仍能工作,完全由自身默认值驱动。Vue 3.4.1 把这一回退扩展到了单向传 prop、不带 update: 监听的父组件,因此在 3.5 上这是文档化行为,而非需要绕开的注意事项。

default 加上父组件绑定 undefined 会失步。 当 <Child v-model="myRef" /> 而 myRef = ref() 时,子组件显示 default,父组件的 myRef 在子组件写入前一直是 undefined。Vue 文档对此有警告:如果父组件绑定了 model,应给它的 ref 一个真实的初始值,而不是依赖子组件的 default。

不要在 defineProps 和 defineModel 中声明同名 prop。 defineModel('title') 已经声明了 title prop。若在 defineProps 中再列一次,编译器不会报错——它会合并两者,且 defineModel 的选项会静默覆盖该键。不属于 v-model 绑定的 prop 仍应放在普通的 defineProps 中。

get 每次读取都会执行,set 仅在组件自身赋值 model.value 时执行。 来自父组件的值不会经过 set;在没有父绑定时,set 的返回值只用于 emit,本地 ref 保留原始值。两个转换都应保持纯粹且轻量——不要有副作用,不要有异步操作。它们是格式化钩子,不是生命周期钩子。

未命名 model 与具名 model 的修饰符属于不同命名空间。

原文链接 ​

https://dev.to/parsajiravand/vue-definemodel-the-v-model-contract-explained-fc1