Vue 2 / Vue 3 中 v-model 的区别
很多同学学 v-model 时,最容易记住一句话:它是“双向绑定”。这句话不算错,但如果你要真正写组件、做迁移、答面试,只记这个结论远远不够。
更准确地说,v-model 是一层语法糖:它帮你把“父组件传值给子组件”与“子组件通知父组件更新”这套样板代码收起来了。Vue 2 和 Vue 3 的核心思想没变,但组件协议、扩展能力、迁移方式发生了明显变化。
1. 先记结论:最大差异在“组件上的 v-model”
先看最重要的结论表:
| 对比项 | Vue 2 | Vue 3 |
|---|---|---|
| 普通表单元素 | 本质是 value/checked + 对应事件 | 本质没变,仍然是表单值 + 对应事件 |
| 组件默认 prop | value | modelValue |
| 组件默认事件 | input | update:modelValue |
| 自定义默认协议 | model 选项 | 不再用 model,改为 v-model:参数 |
一个组件可绑定几个 v-model | 通常 1 个 | 支持多个 |
| 额外双向绑定写法 | 常配合 .sync | .sync 被移除,统一用 v-model:xxx |
| 组件修饰符扩展 | 能力弱,写法不统一 | 更自然,可处理自定义修饰符 |
| 迁移重点 | value/input、model、.sync | 改成 modelValue/update:modelValue、v-model:xxx |
v-model 在 原生表单元素 上变化不大,在 自定义组件 上变化最大。面试和项目迁移里,真正高频考的几乎都是“组件上的 v-model”。
2. 先别急着背差异:先搞清楚 v-model 的本质
不管是 Vue 2 还是 Vue 3,v-model 的底层逻辑都可以概括成两步:
- 父组件把值传给表单元素或子组件
- 表单元素或子组件在值变化时通知父组件更新
比如在原生输入框上,这个语法糖可以先粗略理解为:
<input v-model="msg" />
约等于:
<input :value="msg" @input="msg = $event.target.value" />
所以你可以把 v-model 理解成:
v-model = :绑定值 + @更新事件
这个本质在 Vue 2 和 Vue 3 都没有变。变的是:组件默认使用什么 prop,发什么事件,以及框架允许你把这件事扩展到什么程度。
3. 原生表单元素:Vue 2 和 Vue 3 差异不算大
很多人一提“Vue 2 和 Vue 3 的 v-model 区别”,会误以为所有场景都大改了。其实不是。
在原生表单元素上,两代 Vue 的心智模型基本一致:
- 文本输入框、
textarea:通常围绕value和输入事件 checkbox、radio:通常围绕checked和变更事件select:通常围绕选中值和变更事件.trim、.number、.lazy这些常见修饰符,两代 Vue 都支持
也就是说:
- 你写
<input v-model="keyword" />时,Vue 2 和 Vue 3 的使用体验差别不大 - 真正会让你改代码、改组件设计的,是组件上的
v-model协议
为了讲清主线,下面默认重点讨论“组件上的 v-model”。这也是面试官和迁移文档最关心的部分。
4. 组件上的 v-model:Vue 2 默认是 value + input
Vue 2 里,如果你在组件上这样写:
<BaseInput v-model="title" />
它默认等价于:
<BaseInput :value="title" @input="title = $event" />
这意味着 Vue 2 组件想接住 v-model,通常要做两件事:
- 接收一个名为
value的 prop - 在值变化时触发
input事件
4.1 Vue 2 组件示例
父组件:
<template>
<BaseInput v-model="title" />
</template>
<script>
import BaseInput from './BaseInput.vue'
export default {
components: { BaseInput },
data() {
return {
title: 'Vue 2 标题',
}
},
}
</script>
子组件:
<template>
<input :value="value" @input="$emit('input', $event.target.value)" />
</template>
<script>
export default {
props: {
value: {
type: String,
default: '',
},
},
}
</script>
4.2 Vue 2 想改协议怎么办?
Vue 2 提供了 model 选项,允许你把默认的 value/input 改成别的名字:
<script>
export default {
model: {
prop: 'checked',
event: 'change',
},
props: {
checked: Boolean,
},
}
</script>
这样父组件的:
<MySwitch v-model="enabled" />
就会变成:
<MySwitch :checked="enabled" @change="enabled = $event" />
这能解决一部分问题,但它有两个明显限制:
- 一个组件通常只有一组
v-model - 如果还想对别的 prop 做“双向同步”,常常要额外使用
.sync
5. 组件上的 v-model:Vue 3 默认是 modelValue + update:modelValue
Vue 3 里,同样的写法:
<BaseInput v-model="title" />
默认等价于:
<BaseInput
:modelValue="title"
@update:modelValue="title = $event"
/>
也就是说,Vue 3 的组件协议改成了:
- prop:
modelValue - event:
update:modelValue
5.1 Vue 3 组件示例
父组件:
<template>
<BaseInput v-model="title" />
</template>
<script setup>
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'
const title = ref('Vue 3 标题')
</script>
子组件:
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps({
modelValue: {
type: String,
default: '',
},
})
defineEmits(['update:modelValue'])
</script>
5.2 为什么 Vue 3 要这么改?
表面上只是名字变了,背后其实是在解决 Vue 2 的几个痛点:
value这个 prop 名过于常见,容易和组件自己的业务语义撞车input这个事件名太泛,不够语义化- Vue 2 对“多个双向绑定”的支持不自然
.sync和v-model并存,学习和维护成本偏高
换成 modelValue / update:modelValue 后,规则更统一,也更容易扩展。
6. Vue 3 最关键的升级:支持多个 v-model
这是 Vue 3 相比 Vue 2 最实用的提升之一。
假设你有一个表单组件,同时维护标题和内容。Vue 2 通常只能给它设计一组默认 v-model,其他字段要么手写事件,要么配合 .sync。
而 Vue 3 可以直接这样写:
<ArticleEditor
v-model:title="title"
v-model:content="content"
/>
这会分别展开为:
<ArticleEditor
:title="title"
@update:title="title = $event"
:content="content"
@update:content="content = $event"
/>
子组件只要对应声明即可:
<script setup>
defineProps({
title: String,
content: String,
})
defineEmits(['update:title', 'update:content'])
</script>
这件事很重要,因为它意味着:
- 一个组件不再只有“唯一的那一个双向绑定值”
- 组件 API 可以更贴近真实业务语义
- 以前很多依赖
.sync的设计,可以直接统一到v-model:参数
7. .sync 的角色变了:Vue 2 常用,Vue 3 移除
Vue 2 里,如果你除了默认 v-model 之外,还想让某个 prop 支持“父子同步”,常见写法是:
<Dialog :visible.sync="visible" />
本质上它会变成:
<Dialog :visible="visible" @update:visible="visible = $event" />
到了 Vue 3,.sync 被移除了,因为它和新的 v-model:参数 在语义上已经高度重合。
也就是说,Vue 3 推荐直接写:
<Dialog v-model:visible="visible" />
所以从迁移角度看,可以直接记住:
- Vue 2 的
.sync - Vue 3 统一改成
v-model:prop名
很多人会说“Vue 3 的 v-model 只是把 value 改成了 modelValue”。这只说对了一半。真正更大的升级,是它把 .sync 那一套也统一进来了,并原生支持多个 v-model。
8. 自定义协议的方式也变了
8.1 Vue 2:依赖 model 选项
Vue 2 如果不想用默认的 value/input,通常写:
export default {
model: {
prop: 'checked',
event: 'change',
},
props: {
checked: Boolean,
},
}
8.2 Vue 3:依赖参数化 v-model
Vue 3 不再推荐搞一个全局默认协议改名,而是直接把“绑定哪个字段”写在模板里:
<MySwitch v-model:checked="enabled" />
它对应的组件协议就是:
- prop:
checked - event:
update:checked
也就是:
<MySwitch :checked="enabled" @update:checked="enabled = $event" />
这种写法更直观,因为你一眼就能看出“到底是哪个字段在双向绑定”,不需要再进组件里读 model 配置。
9. 修饰符:Vue 3 在组件场景下更自然
原生输入框上的 .trim、.number、.lazy 两代 Vue 都常见,但 Vue 3 对组件 v-model 修饰符的支持更完整、更统一。
例如:
<UserNameInput v-model.trim="username" />
在 Vue 3 的自定义组件里,组件可以拿到这些修饰符信息,再决定怎么处理。
如果你使用的是 Vue 3.4+ 的 <script setup>,还可以写得更简单:
<script setup>
const [modelValue, modifiers] = defineModel()
function handleInput(e) {
let value = e.target.value
if (modifiers.trim) {
value = value.trim()
}
modelValue.value = value
}
</script>
这里要注意两点:
defineModel()只是更方便的语法糖- 它底层仍然遵循
modelValue/update:modelValue这一套协议
10. 一段代码看懂迁移:Vue 2 改到 Vue 3 要改哪里
下面用一个最常见的输入组件举例。
10.1 Vue 2 写法
<template>
<input :value="value" @input="$emit('input', $event.target.value)" />
</template>
<script>
export default {
props: {
value: String,
},
}
</script>
父组件:
<SearchInput v-model="keyword" />
10.2 Vue 3 写法
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps({
modelValue: String,
})
defineEmits(['update:modelValue'])
</script>
父组件:
<SearchInput v-model="keyword" />
你会发现父组件几乎没变,真正改的是组件内部协议。
10.3 如果原来用了 model 选项
Vue 2:
export default {
model: {
prop: 'checked',
event: 'change',
},
props: {
checked: Boolean,
},
}
Vue 3 更推荐:
<MySwitch v-model:checked="enabled" />
子组件:
<script setup>
defineProps({
checked: Boolean,
})
defineEmits(['update:checked'])
</script>
10.4 如果原来用了 .sync
Vue 2:
<Dialog :visible.sync="visible" />
Vue 3:
<Dialog v-model:visible="visible" />
11. 迁移清单:项目里重点搜这几类代码
如果你在做 Vue 2 -> Vue 3 迁移,和 v-model 相关的代码优先排查这些地方:
- 组件里是否还在接收
valueprop - 组件里是否还在
$emit('input', xxx) - 组件里是否声明了
model选项 - 父组件模板里是否还在使用
.sync - 是否存在一个组件维护多个双向字段,但写法仍然分散混乱
最常见的替换关系可以直接记成:
| Vue 2 写法 | Vue 3 写法 |
|---|---|
value | modelValue |
input | update:modelValue |
model: { prop, event } | v-model:prop + update:prop |
:foo.sync="bar" | v-model:foo="bar" |
12. 常见误区
误区 1:v-model 就是“双向绑定”,所以数据流是双向的
不准确。
更准确的描述是:数据源仍然在父组件,子组件只是通过事件请求父组件更新。 所以本质上仍然符合“prop 下发、event 上抛”的单向数据流思路。
误区 2:Vue 3 的 v-model 只是改了名字
不准确。
除了名字变成 modelValue / update:modelValue,Vue 3 还带来了:
- 多个
v-model - 用
v-model:参数统一替代很多.sync场景 - 组件修饰符能力更自然
误区 3:父组件写法没变,所以迁移没成本
也不准确。
父组件很多时候看起来还是:
<MyInput v-model="msg" />
但子组件实现几乎一定要改,尤其是老组件库、业务基础组件、表单组件封装层。
13. 总结
如果只用一句话总结:
Vue 2 和 Vue 3 的
v-model本质都一样,都是“绑定值 + 更新事件”的语法糖;但 Vue 3 把组件协议升级成了modelValue + update:modelValue,并通过v-model:参数解决了 Vue 2 里单一v-model、model选项、.sync分散并存的问题。
你可以把两代 Vue 的区别浓缩为三句:
- 原生表单元素:差异不大
- 自定义组件:协议变化很大
- Vue 3:更统一、更语义化、更适合复杂组件
如果你还想继续往下串,可以接着看:
14. 面试高频问答
Q1:Vue 2 和 Vue 3 的 v-model 最大区别是什么?
参考回答:
最大区别在组件协议。Vue 2 默认是 value + input,Vue 3 默认是 modelValue + update:modelValue。同时 Vue 3 支持多个 v-model,还能用 v-model:xxx 统一替代很多 Vue 2 里的 .sync 场景。
Q2:v-model 的本质是什么?
参考回答:
v-model 是语法糖,本质就是:
- 把一个值传给表单元素或子组件
- 在值变化时通过事件把新值抛回去
也就是“绑定值 + 更新事件”。
Q3:为什么 Vue 3 要把 value/input 改成 modelValue/update:modelValue?
参考回答:
主要是为了统一和扩展:
value、input太通用,容易和业务语义冲突- Vue 2 一个组件通常只有一组
v-model - Vue 2 的
.sync和v-model分开记忆,不够统一
Vue 3 改成 modelValue/update:modelValue 后,再配合 v-model:xxx,就能自然支持多个双向绑定字段。
Q4:Vue 3 里多个 v-model 是怎么实现的?
参考回答:
v-model:title="title" 会展开成 :title="title" @update:title="title = $event";v-model:content="content" 同理。所以它本质上是把“默认的 modelValue 协议”参数化了。
Q5:Vue 2 的 .sync 和 Vue 3 的 v-model:visible 是什么关系?
参考回答:
它们解决的是非常接近的问题,都是“某个 prop 需要父子同步更新”。Vue 3 里 .sync 被移除,推荐统一写成 v-model:visible="visible",本质上更一致、更直观。
Q6:v-model 会破坏单向数据流吗?
参考回答:
不会。父组件仍然通过 prop 往下传值,子组件仍然通过事件通知父组件修改。v-model 只是把这套模式封装成了更短的写法,并没有让子组件直接篡改父组件状态。