在纯 JavaScript 时代,我们尝尝直接操作 DOM、管理全局变量。随着应用复杂度提升,出现了以下问题:
为了解决手工同步的痛点,AngularJS(1.x)提出了 MVVM 架构,引入双向数据绑定,模板中写一个 ng-model,改变模型(Model)或视图(View)都能自动同步。这大大提升了开发效率,但也带来性能风险——脏检查(digest cycle)在大型应用中很难调优。
Vue 1.0 受到启发,保留双向绑定优势的同时,用基于 Object.defineProperty 的响应式系统来替代脏检查,性能与体验兼得。
在传统的网页开发中,我们经常要手动将表单输入与 JavaScript 数据做同步:
这种手工绑定不仅代码冗长,而且容易出错,尤其是在多表单、复杂交互场景下,维护成本极高。
Vue 2.x 引入了 MVVM(Model–View–ViewModel)的理念,通过 数据响应式 + 模板指令,让模板到数据、数据到模板的同步都只需一句话:
Vue 会在内部搞定:
从而极大地简化了表单开发,提升了开发效率。
举个
输入的是:{{ message }}
new Vue({
el: '#app',
data: {
message: ''
}
});
Vue 2.x 为 v-model 提供了三个常用修饰符:.lazy、.number、.trim,以及组合的 .sync 语法(实为 v-bind.sync 语法糖)。
自动将用户输入的字符串转为数字,相当于:
inputHandler(e) {
this.msg = Number(e.target.value);
}
注意:Number('') 会转成 0,要防范空字符串带来的歧义。
自动去除用户输入的首尾空白,相当于:
inputHandler(e) {
this.msg = e.target.value.trim();
}
其实是 v-bind:prop.sync="value" 的简写。
等价于:
bar = val" />
父组件在使用子组件绑定时:
会自动把子组件的 this.$emit('update:title', newVal) 绑定到父组件,达成双向。
当把 v-model 用在子组件时,Vue 会默认:
比如:
等价于:
text = val" />
在子组件里,需要:
Vue.component('my-input', {
props: ['value'],
template: ``
});
Vue 2.x 支持在组件中用 model 选项定制 v-model 的 prop 和 event 名称:
Vue.component('my-cmp', {
model: {
prop: 'checked',
event: 'change'
},
props: {
checked: Boolean
},
template: ``
});
父组件写
v-model 在 Vue 2 中本质是一个 内置指令(model):
在编译阶段,模板编译器会识别 v-model 指令,生成对应的 AST 节点;
在渲染阶段,为元素注入 model 指令的钩子函数:bind、update、componentUpdated 等。
// 大致伪代码结构
model: {
bind(el, binding, vnode) {
// 初始化:setInitialValue(el, binding.value)
// 根据 el.tagName / type 决定事件类型,如 input、change
el.addEventListener(eventName, handler);
},
update(el, binding) {
// 当数据变化时,更新 el.value = binding.value
},
unbind(el, binding) {
// 清理事件监听器
el.removeEventListener(eventName, handler);
}
}
以 为例,编译器处理过程:
解析模板 → AST:
{
tag: 'input',
attrsList: [{ name: 'v-model', value: 'foo' }],
// ... 其它节点信息
}
生成指令 → 为 AST 添加 model 指令:
node.directives = [{ name: 'model', rawName: 'v-model', value: 'foo', modifiers: {} }];
代码生成 → 在渲染函数中注入 withDirectives:
withDirectives(
createElementVNode('input', { value: foo }),
[[vModelText, foo]]
)
1、初始化:directive.bind 会调用 el.value = foo,并用 el.addEventListener('input', handler)。
2、用户输入:handler 执行 foo = el.value,触发 Vue 的响应式赋值。
3、响应式更新:数据变化经过 Object.defineProperty 的 setter → Dep.notify() → watcher → 重新执行渲染函数 → 再次触发指令的 update,从新同步 el.value。
Vue 2.x 用 Object.defineProperty 实现响应式:
v-model 的流程:
1、指令注册
// src/platforms/web/runtime/directives/model.js
export default {
bind(el, binding, vnode) {
setModel(el, binding, vnode);
},
update(el, binding, vnode) {
if (binding.value !== binding.oldValue) {
setModel(el, binding, vnode);
}
},
};
2、模型设置
function setModel(el, binding, vnode) {
const { value, modifiers } = binding;
const tag = el.tagName.toLowerCase();
if (tag === 'input') {
if (el.type === 'text' || el.type === 'password') {
el.value = value; // 初始值
el.addEventListener(
modifiers.lazy ? 'change' : 'input',
e => {
let newValue = e.target.value;
if (modifiers.trim) newValue = newValue.trim();
if (modifiers.number) newValue = Number(newValue);
vnode.context[binding.expression] = newValue;
}
);
}
// 省略 radio / checkbox 逻辑
} else if (tag === 'select') {
// select 多选/单选处理...
}
}
在 Vue 2.x 时代,v-model 已经是表单开发的王炸指令,但也存在一些局限:
Vue 3 团队希望在保留 v-model 开发便捷性的同时:
父组件用 v-model 传进来,就完成了“父 → 子 → 父” 的单向数据流与事件流,清晰、安全。
而在纯模板层面,只需:
社区常用的 @vueuse/core 提供 useVModel,进一步简化:
1、AST 转换
当解析到模版中的一条 v-model 指令时,Vue 3 的编译器(@vue/compiler-core)会在 AST 节点上做如下两步转换:
2、代码生成
最终,渲染函数里那一行 vnode 创建会变成:
// 渲染函数伪代码
return createVNode(Child, {
title: pageTitle,
'onUpdate:title': $event => (pageTitle = $event)
});
或者如果是原生 ,则会调用:
return withDirectives(
createVNode('input', { value: msg }),
[[vModelText, msg]]
);
这里 withDirectives 会把指令传给运行时去处理。
Vue 3 在运行时提供了一组专用的 vModelXxx 指令函数,分别针对不同类型的表单控件:
指令 | 作用对象 | 对应包与模块 |
---|---|---|
vModelText | 、 |
@vue/runtime-dom |
vModelCheckbox | @vue/runtime-dom |
|
vModelRadio | @vue/runtime-dom |
|
vModelSelect | @vue/runtime-dom |
1、指令内部核心流程
以 vModelText 为例,伪代码运行时实现:
// vModelText 指令主要钩子
const vModelText = {
// 在 mount 时设置初始值并注册事件
mounted(el, binding, vnode) {
el.value = binding.value;
el.addEventListener('input', e => {
let newVal = e.target.value;
// 处理 trim、number 修饰符……
vnode.props['onUpdate:modelValue'](newVal);
});
},
// 在更新时,同步最新数据回 DOM
updated(el, binding) {
if (binding.value !== el.value) {
el.value = binding.value;
}
}
};
2、事件流程图
父组件 data.foo
↓ (渲染阶段)
input.value = foo
↓ (用户输入)
input事件 ⇒ 拦截 newVal
↓
触发 onUpdate:modelValue(newVal)
↓
foo = newVal(响应式 setter)
↓ (再次渲染)
input.value = foo
这个“数据→视图→事件→数据”循环就是双向绑定的核心。
自定义组件只要 暴露 modelValue prop 和 update:modelValue 事件即可与 v-model 配合:
父级直接
Vue 3 将原生的 .trim / .number / .lazy 修饰符也统一在指令内部处理:
// 示例:input 事件处理器内
el.addEventListener(modifiers.lazy ? 'change' : 'input', e => {
let val = e.target.value;
if (modifiers.trim) val = val.trim();
if (modifiers.number) val = Number(val);
vnode.props['onUpdate:modelValue'](val);
});
这样在模板上写 就被自动组合成“数字转换 + 空白裁剪 + 延迟触发”的完整逻辑。
1、从 Vue 2.x 到 Vue 3.x 的双向绑定演进
Vue 2.x 引入了 v-model,将表单输入的值与组件数据做双向绑定,语法糖极大简化了表单开发,但仍有痛点:只能同时支持一个 v-model,需要手写大量的 props 和 emits;多模型绑定依赖 .sync 或手动扩展,TS 场景下类型声明冗余。
Vue 3.0 ~ 3.3 在底层改为 modelValue + update:modelValue、支持多 v-model:arg,解决了事件/prop 名冲突、支持多个模型。但在
当要支持多个 v-model 时,还要多次重复,体验并不够理想。
4、团队一致性与 DX(开发者体验)追求
对于中大型团队和组件库,统一简单、少样板、类型安全的“双向绑定”方案是刚需。Vue 核心团队在 3.4 版推出 defineModel 编译宏,旨在一次性声明、自动生成对应的 props 和 emits,极大提升
等同于:
const props = defineProps<{ value: string }>()
const emit = defineEmits<{
(e: 'update:value', v: string): void
}>()
const value = useVModel(props, 'value', emit)
{{ title }}
自动在 props 中注入 { title, visible, count },在 emits 中注入 [ 'update:title','update:visible','update:count' ],并在
在编译器处理时,等同于在顶层自动插入:
const __props = defineProps({
foo: String,
count: { type: Number, default: 0 }
});
const __emit = defineEmits(['update:foo','update:count']);
const foo = useVModel(__props, 'foo', __emit);
const count = useVModel(__props, 'count', __emit);
然后删除 defineModel 的原始调用,最终生成的渲染函数里只有上述标准调用,没有宏调用残留。
defineModel 并非运行时代码,它是编译宏(Compiler Macro):
注:此语法目前需依赖 RFC 或社区插件支持,核心思想是在宏中传入映射选项。
1、仅限
defineModel 宏只能在