Vue 组件v-model
v-model 如何实现父子组件数据同步?
一、先给结论
组件上的 v-model 不是子组件直接修改父组件状态,而是“prop + update 事件”的语法糖:
<Child v-model="value" />
<!-- Vue 3 中近似展开为 -->
<Child
:modelValue="value"
@update:modelValue="value = $event"
/>
完整链路是:
父组件状态 value
↓ props 下发 modelValue
子组件展示 modelValue
↓ 用户输入或子组件逻辑产生新值
子组件 emit('update:modelValue', newValue)
↓ 父组件监听事件并修改 value
响应式系统触发更新,再次向子组件传值
本质仍然是单向数据流:数据向下,事件向上。v-model 只是把这套约定标准化并简化书写。
二、原生元素上的v-model
原生元素和组件的展开规则不同,不能简单概括为所有场景都是 value + input。
<input v-model="message" />
<!-- 文本输入框可近似理解为 -->
<input
:value="message"
@input="message = $event.target.value"
/>
| 元素 | 绑定属性 | 监听事件 | 值的典型形态 |
|---|
| 文本 input / textarea | value | input | 字符串 |
| checkbox | checked | change | 布尔值、数组或 Set |
| radio | checked | change | 选项值 |
| select | selected | change | 选项值或数组 |
所以面试时应区分:
- 原生元素:Vue 根据元素类型选择 DOM 属性和事件。
- 组件:Vue 根据组件约定生成 prop 和自定义事件。
三、Vue 3 手动实现组件v-model
父组件
<UserInput v-model="username" />
子组件
<script setup>
const props = defineProps({
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
function handleInput(event) {
emit('update:modelValue', event.target.value)
}
</script>
<template>
<input :value="props.modelValue" @input="handleInput" />
</template>
子组件不能直接修改 props.modelValue。它应该发出事件,让父组件修改自己的状态,再把新值传下来。
用计算属性封装读写
<script setup>
import { computed } from 'vue'
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
const model = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
</script>
<template>
<input v-model="model" />
</template>
这里的计算属性没有创建一份无关的副本,而是把读取代理到 prop,把写入转换为 emit。
四、Vue 3.4+ 的defineModel
Vue 3.4 开始,官方推荐使用 defineModel():
<!-- Child.vue -->
<script setup>
const model = defineModel({
type: String,
default: ''
})
</script>
<template>
<input v-model="model" />
</template>
defineModel 是 <script setup> 的编译期宏,不是普通运行时函数。它会生成对应的 prop 和 update:modelValue 事件,概念上接近:
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
默认值的同步陷阱
如果子组件给 defineModel 设置默认值,但父组件没有传入对应 model,子组件和父组件可能暂时拥有不同的值:
<!-- Child.vue -->
<script setup>
const model = defineModel({ default: 1 })
</script>
<!-- Parent.vue -->
<Child />
此时子组件可能是 1,父组件对应状态仍是 undefined。因此要根据 API 设计决定 model 是否必填,不能把子组件默认值误认为父子已经完成同步。
五、多个v-model
Vue 3 可以通过参数区分多个独立 model:
<UserName
v-model:first="firstName"
v-model:last="lastName"
/>
近似展开为:
<UserName
:first="firstName"
@update:first="firstName = $event"
:last="lastName"
@update:last="lastName = $event"
/>
命名规则是:
v-model:foo="value"
↓
prop: foo
event: update:foo
手动声明
<script setup>
defineProps({
first: String,
last: String
})
const emit = defineEmits([
'update:first',
'update:last'
])
</script>
使用defineModel
<script setup>
const first = defineModel('first', { type: String })
const last = defineModel('last', { type: String })
</script>
<template>
<input v-model="first" />
<input v-model="last" />
</template>
六、Vue 2 的组件v-model
Vue 2 默认协议是:
<Child v-model="value" />
<!-- 近似展开为 -->
<Child :value="value" @input="value = $event" />
子组件:
export default {
props: { value: String },
methods: {
updateValue(value) {
this.$emit('input', value)
}
}
}
model 选项
Vue 2 可以自定义默认 prop 和事件:
export default {
model: {
prop: 'checked',
event: 'change'
},
props: { checked: Boolean },
methods: {
toggle(value) {
this.$emit('change', value)
}
}
}
这样 <BaseCheckbox v-model="checked" /> 使用的就是 checked + change。
.sync 修饰符
<Child :title.sync="title" />
<!-- 近似展开为 -->
<Child
:title="title"
@update:title="title = $event"
/>
.sync 是 Vue 2 针对任意 prop 的更新约定,不是完整的 v-model 替代品。Vue 3 使用 v-model:title 这类命名 model 表达相同场景。
七、v-model 修饰符
原生输入的内置修饰符
<input v-model.trim="text" />
<input v-model.number="count" />
<input v-model.lazy="text" />
.trim:去除输入字符串首尾空白
.number:尝试将输入转换为数字
.lazy:通常从 input 时机改为 change 时机
修饰符改变的是读取或更新逻辑,不是给数据永久增加类型。
Vue 3.4+:通过defineModel 处理自定义修饰符
<script setup>
const [model, modifiers] = defineModel({
set(value) {
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
}
})
</script>
<template>
<input v-model="model" />
</template>
父组件:
<CustomInput v-model.capitalize="text" />
命名 model 的修饰符名称对应参数名:
v-model:title.capitalize
↓
titleModifiers
手动读取修饰符
没有使用 defineModel 时,默认 model 可以声明 modelModifiers:
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: {
default: () => ({})
}
})
const emit = defineEmits(['update:modelValue'])
function handleInput(event) {
let value = event.target.value
if (props.modelModifiers.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
emit('update:modelValue', value)
}
</script>
对于 v-model:title.capitalize,对应 prop 是 titleModifiers,而不是 modelModifiers。
八、编译与运行时边界
1. 不是双向引用
父组件传入的是当前值,子组件拿到的是 prop。真正修改父状态的是父组件生成的事件监听器:
<Child v-model="value" />
<!-- 事件触发时相当于执行 -->
value = $event
2. 绑定目标必须可写
<Child v-model="user.name" /> <!-- 通常可以写回 -->
<Child v-model="getName()" /> <!-- ❌ 没有可写回目标 -->
<Child v-model="'fixed'" /> <!-- ❌ 常量不可赋值 -->
3. 对象 model 不是深度协议
如果 model 是对象,子组件拿到的可能是同一个对象引用。直接修改内部属性可能绕过 update 事件:
props.modelValue.name = 'new name' // 不推荐
更清晰的写法是发出新对象:
emit('update:modelValue', {
...props.modelValue,
name: 'new name'
})
具体是否复制对象取决于业务数据设计,但组件应明确谁拥有状态、谁负责更新。
4. 事件和 prop 名称必须匹配
默认协议必须同时使用 modelValue 与 update:modelValue;命名 model 必须同时使用 foo 与 update:foo。只改一侧,绑定就不会按预期工作。
九、设计原则
适合使用 v-model:
- 输入框、选择器、日期选择器等表单控件
- 复杂表单组件的当前值同步
- 一个组件有明确的“当前值 / 更新值”协议
- 一个组件有多个独立的可编辑值
不应滥用:
- 用它承载任意业务动作
- 子组件直接修改 prop
- 同时维护父状态和子状态两个真实数据源
- 用它替代语义明确的
submit、confirm、close 等业务事件
例如弹窗更适合:
<Modal :open="isOpen" @close="handleClose" />
而不是把所有打开、关闭逻辑都隐藏在一个复杂的 v-model 中。
十、核心源码模型
下面是帮助理解的简化模型,不代表 Vue 源码:
function expandComponentVModel(value) {
return {
modelValue: value,
'onUpdate:modelValue': nextValue => {
value = nextValue
}
}
}
组件上的 v-model 可以理解为:
模板语法
↓ 编译展开
prop + update 事件
↓
子组件发出新值
↓
父组件更新状态
↓
响应式系统触发重新渲染
真实 Vue 还会根据原生元素类型、参数、修饰符和版本生成不同的运行时指令或 vnode props;示例只是用于理解核心协议。
十一、Vue 2 与 Vue 3 对比
| 对比项 | Vue 2 | Vue 3 |
|---|
| 默认 prop | value | modelValue |
| 默认事件 | input | update:modelValue |
| 自定义默认协议 | model: { prop, event } | v-model:name |
| 多值绑定 | 多个 .sync | 多个命名 v-model |
| 修饰符读取 | 手动设计 | modelModifiers / nameModifiers / defineModel |
| 推荐声明 | props + $emit | defineModel 或 defineProps + defineEmits |
十二、面试官深挖
1.v-model 的本质是什么?
参考答案: 它是 prop 和更新事件的语法糖。Vue 3 默认是 modelValue + update:modelValue。父组件持有状态,子组件发事件请求更新,底层仍是数据向下、事件向上。
2. 原生元素和组件的展开规则一样吗?
参考答案: 不一样。原生元素根据类型处理 value、checked、selected 和不同事件;组件遵循 modelValue / update:modelValue 或命名 model 协议。
3. 子组件为什么不能直接修改 model prop?
参考答案: prop 属于父组件,直接修改会破坏单向数据流。子组件应 emit 更新事件,由父组件修改状态后重新下发。
4. Vue 2 和 Vue 3 有什么区别?
参考答案: Vue 2 默认是 value + input,可用 model 自定义;Vue 3 默认是 modelValue + update:modelValue,通过参数支持多个 model,3.4+ 推荐用 defineModel。
5..sync 和命名v-model 有什么关系?
参考答案: 两者都基于 prop 和 update 事件,但 .sync 是 Vue 2 针对任意 prop 的语法,Vue 3 用 v-model:title 统一表达多个双向值。
6.defineModel 是什么?
参考答案: 它是 <script setup> 编译期宏,会生成 prop 和 update: 事件,简化了手动声明,但没有改变 v-model 的通信本质。
7. 多个v-model 解决什么问题?
参考答案: 一个组件可能有多个相互独立的可编辑值。命名 model 将它们映射到不同 prop 和事件,避免把所有值塞进一个对象。
8. 修饰符如何传给组件?
参考答案: 手动实现时,默认 model 使用 modelModifiers,命名 model 使用 nameModifiers;3.4+ 可以通过 defineModel 返回的第二个值获取修饰符,并在 setter 中转换值。
9.defineModel 的默认值有什么风险?
参考答案: 父组件未传入 model 时,子组件默认值和父状态可能暂时不同步。要明确 model 是否必填,不能把子组件默认值当成父子已经同步。
10.v-model 可以绑定任意表达式吗?
参考答案: 不能。编译器最终需要生成可写回的赋值目标,变量和可写属性通常可以,函数调用、常量和只读表达式不可以。
十三、常见错误回答
- “
v-model 就是双向绑定”:应补充它实际是 prop + event,仍遵循单向数据流。
- “所有
v-model 都是 value + input”:这是 Vue 2 默认协议或文本输入的简化说法。
- “子组件可以直接改
modelValue”:应通过 update: 事件通知父组件。
- “
.sync 就等于 Vue 3 的 v-model”:思想相近,但语法和适用版本不同。
- “
defineModel 会创建本地副本”:它默认封装的是 prop 读取和 update 事件,不等于无条件复制数据。
十四、可迁移的理解
核心关键词
语法糖、单向数据流、prop、update 事件、编译展开、命名 model
一句话本质
组件 v-model 是父组件状态通过 prop 向下传递、子组件通过 update: 事件向上请求更新的标准化协议。
1 分钟面试回答
Vue 组件上的 v-model 本质是 prop 和事件的语法糖。Vue 3 默认展开为 :modelValue 和 @update:modelValue,父组件持有状态,子组件通过 emit 请求更新,仍然是数据向下、事件向上。Vue 2 默认是 value + input,Vue 3 支持多个命名 v-model,3.4+ 可以用 defineModel 简化声明。子组件不能直接修改 prop,而应该发出更新事件。
3 分钟回答应补充
原生 input、checkbox、select 的展开差异;Vue 2 的 model / .sync;Vue 3 的手动实现、defineModel 和自定义修饰符。
10 分钟回答应补充
模板编译和运行时指令、model 参数命名规则、默认值同步风险、对象引用边界、可写表达式限制,以及为什么业务动作更适合使用语义明确的事件。
参考资料