请不要把 Vue 的响应式原理理解为双向绑定哦?很多同学在学习 Vue 时,认为 Vue 的响应式原理是双向绑定。这并不准确,Vue 的响应式是一种单向行为。
这种单向行为只是数据到 DOM 的映射。
而双向绑定不仅有数据到 DOM 的映射,还有 DOM 到数据的映射。
虽然 Vue 的响应式原理是单向行为,但 Vue 为了便于开发者开发,在响应式原理的基础之上实现了一个双向绑定的语法糖。
是的,他就是 v-model,在 Vue 项目的开发中,它再常见不过了。
它可以在一些特定的表单标签如:input、select、textarea 和自定义组件中使用(v-model 也不是可以作用到任意标签)。那么 v-model 的实现原理到底是怎样的呢?接下来,我们从普通表单元素和自定义组件两个方面来分别分析它的实现。
普通表单元素中的 v-model
Vue.version=’2.6.11’;
为了更加直观方便的解析,这里引入一个简单的示例:
new Vue({el: '#app',data: {message: ''},template: `<div><inputv-model="message"placeholder="请输入"ref="test-ref"id="testId"key="test-key"class="text-clasee"style="color: red"data-a="test-a"data-b="test-b"/></div>`});
示例很简单,一个输入框,设置了 v-model 和一些其他的属性(辅助说明的作用)。为了演示在编译阶段表单元素中 v-model 的变化,所以示例用了Runtime + Compiler 版本。
Vue.js 提供了 2 个版本,一个是
Runtime + Compiler版本,一个是Runtime only版本。Runtime + Compiler版本是包含编译代码的,可以把编译过程放在运行时做,Runtime only版本不包含编译代码的,需要借助 webpack 的 vue-loader 事先把模板编译成 render 函数。
parse 阶段的 v-model
首先我们从编译的 parse阶段来看看 v-model 在 AST 是怎么描述的。先看看 parse template的 AST 长什么样。
我们在示例 input 元素中设置了很多属性,不同的属性会被放入到不同的集合或者是属性值中。
比如:key、ref、class、style都会被单独赋值。
而 v-model 被记录在了**directives 数列**中。
在整个 AST 描述对象中,针对属性,还有一个几个大家可以关注一下。
比如:attrsMap 集合,记录了所有属性的 key - value 的映射。
rowAttrsMap 集合,记录了所有属性的相信信息。
attrs 数列,记录了非指令、非单独被赋值的属性(如:key、ref、class、style)的集合。
attrsList 数列,记录了非单独被赋值的属性的集合。
到这里我们知道了在编译解析阶段, v-model 被记录在了**directives 数列**中。
generate 阶段的 v-model
进过了parse阶段的洗礼,我们在来看看在generate阶段 v-model 又会被怎么处理了?
我们先看看最后的生成产物render code string长什么样子。
with (this) {return _c('div', [_c('input', {directives: [{name: "model",rawName: "v-model",value: (message),expression: "message"}],key: "test-key",ref: "test-ref",staticClass: "text-clasee",staticStyle: { "color": "red" },attrs: { "placeholder": "请输入", "id": "testId", "data-a": "test-a", "data-b": "test-b" },domProps: { "value": (message) },on: { "input": function ($event) {if ($event.target.composing) return;message = $event.target.value}}})])}
在generate阶段会传入已经优化处理好的 AST。然后在函数中根据不同的节点属性执行不同的生成函数。
对于 v-model 这类指令属性来说,就会走到 genData函数来进行code string的生成。
在 Vue 中一个 VNode代表一个虚拟节点,而该节点的虚拟属性信息用 VNodeData 描述。而 VNodeData 的生成就是用genData函数来实现的。
genData 函数就是根据 AST 元素节点的属性构造出一个 data 对象字符串,这个在后面创建 VNode 的时候的时候会作为参数传入。
Vue 中对处理节点属性其实有三个 genData 函数。分别是genData、genData$1和genData$2。三个函数分别处理不同类型的节点属性。
在 Vue 中可以使用绑定 Class 与 绑定 Style 来生成动态 class 列表和内联样式。在源码中:
- genData 的作用就是处理静态的 class 和绑定的 class。
- genData$1 用来处理静态的 style 和绑定的 style。
- genData$2 用来处理其他属性。
对于属性的生成函数genData$2,首先就会调用genDrirectives() 方法对元素的指令集合进行处理,也就是对parse阶段生成directives数列进行处理。
而现在directives数列是这个样子。
循环directives数列,进行指令的处理。指令的处理有两行比较重要的代码(代码1、代码2)。
function genDirectives(el, state) {...for (i = 0, l = dirs.length; i < l; i++) {...// 代码 1var gen = state.directives[dir.name];if (gen) {// 代码 2needRuntime = !!gen(el, dir, state.warn);}...}...}
代码1 var gen = state.directives[dir.name];获取指令对应的方法,不同的指令有不同的directive对应函数,这些对应的方法是提前定义好的。
那么对于 v-model 而言,对应的 directive函数就是 model 函数。代码2 needRuntime = !!gen(el, dir, state.warn);执行指令对应的函数,也即是 model函数。它会根据 AST 元素节点的不同情况去执行不同的逻辑,对于我们示例中的代码而言,它会命中 genDefaultModel(el, value, modifiers)的逻辑,
**genDefaultModel**函数就是表单元素实现 v-model 双向绑定的重点了?
function genDefaultModel(el,value,modifiers) {var type = el.attrsMap.type;// warn if v-bind:value conflicts with v-model// except for inputs with v-bind:type{var value$1 = el.attrsMap['v-bind:value'] || el.attrsMap[':value'];var typeBinding = el.attrsMap['v-bind:type'] || el.attrsMap[':type'];if (value$1 && !typeBinding) {var binding = el.attrsMap['v-bind:value'] ? 'v-bind:value' : ':value';warn$1(binding + "=\"" + value$1 + "\" conflicts with v-model on the same element " +'because the latter already expands to a value binding internally',el.rawAttrsMap[binding]);}}var ref = modifiers || {};var lazy = ref.lazy;var number = ref.number;var trim = ref.trim;var needCompositionGuard = !lazy && type !== 'range';var event = lazy? 'change': type === 'range'? RANGE_TOKEN: 'input';var valueExpression = '$event.target.value';if (trim) {valueExpression = "$event.target.value.trim()";}if (number) {valueExpression = "_n(" + valueExpression + ")";}var code = genAssignmentCode(value, valueExpression);if (needCompositionGuard) {code = "if($event.target.composing)return;" + code;}addProp(el, 'value', ("(" + value + ")"));addHandler(el, event, code, null, true);if (trim || number) {addHandler(el, 'blur', '$forceUpdate()');}}
genDefaultModel 函数先处理了 modifiers,我们的示例中没有修饰符,所以我们跳过修饰符处理。- 接着
event 为 input,valueExpression赋值为$event.target.value(事件值)。 - 它然后去执行
genAssignmentCode去生成代码。message=$event.target.value

- code 生成完后,又执行了 2 句非常关键的代码,重点来了,重点来了,重点来了(重要的事情说三遍)。
addProp(el, 'value', ("(" + value + ")"));addHandler(el, event, code, null, true);
**addProp**修改 AST 元素,给 el 添加一个 prop,相当于在**input 上动态绑定了 value**。**addHandler**修改 AST 元素,给 el 添加了事件处理,相当于在**input 上绑定了 input 事件**。
这相当于将 v-model 进行了转换。
动态绑定<input:value="message"@input="message=$event.target.value"/>
input的value,并将value指向message,然后当触发输入事件的时候,将输入的目标值设置到message上。实现数据的双向绑定。
我们在回头来看看生成的render code。发现我们即使没有设置指令事件,但是还是生成了input事件。with (this) {return _c('div', [_c('input', {directives: [{name: "model",rawName: "v-model",value: (message),expression: "message"}],key: "test-key",ref: "test-ref",staticClass: "text-clasee",staticStyle: { "color": "red" },attrs: { "placeholder": "请输入", "id": "testId", "data-a": "test-a", "data-b": "test-b" },domProps: { "value": (message) },on: { "input": function ($event) {if ($event.target.composing) return;message = $event.target.value}}})])}
小结
表单元素上的 v-model 原理的精髓,在于通过修改 AST 元素,添加 prop,相当于我们在 input 上动态绑定了 value,又添加事件处理,相当于在 input 上绑定了 input 事件。动态绑定input的value,并将value指向message,然后当触发输入事件的时候,将输入的目标值设置到message上。实现数据的双向绑定。间接说明了 v-model 就是一个语法糖。组件元素中的 v-model
为了更加直观方便的解析,这里我们也引入一个简单的示例:
同样,我们使用let inputComponent = {template: `<div><input:value="value"placeholder="请输入"ref="test-ref"id="testId"key="test-key"class="text-clasee"style="color: red"data-a="test-a"data-b="test-b"/>{{ value }}</div>`,props: ['value'],}new Vue({el: '#app',data: {message: ''},components: {inputComponent},template: `<inputComponent v-model="message"></inputComponent>`});
Runtime + Compiler版本。parse 阶段的 v-model
在parse阶段 v-model 生成的 AST 描述是和表单 v-model 一样。v-model 被记录在了**directives 数列**中。
但是有一点差别的是,由于这里我们用到了组件模式。所以会生成两个 AST。而 v-model 是作用在 inputComponent 组件标签(相当于自定义标签)上的 AST 中。
generate 阶段的 v-model
然后进入generate阶段。在generate阶段会传入已经优化处理好的 AST。然后在函数中根据不同的节点属性执行不同的生成函数。对于组件就会就会走 genComponent 函数调用,对于 genComponent 函数内部其实就是对 genData和genChildren的封装处理。
function genComponent(componentName,el,state) {var children = el.inlineTemplate ? null : genChildren(el, state, true);return ("_c(" + componentName + "," + (genData$2(el, state)) + (children ? ("," + children) : '') + ")")}
一个组件本身其实也就是很多元素的集合,只是这些元素套在了一个名为某某某的组件内部。所以用genComponent用 genData和genChildren封装也就想得通了。genData负责处理组件的属性,也就包括 v-model。genChildren负责处理组件内部的元素,用于生成子级虚拟节点信息字符串。
用genData处理函数属性,也就走到了表单 v-model 处理的逻辑。先就会调用genDrirectives() 方法对元素的指令集合进行处理,也就是对parse阶段生成directives数列进行处理。
然后执行 model 函数。让后触发genComponentModel()函数。
config.isReservedTag(tag) 用于判断是否是保留标签。
genComponentModel()函数是组件处理 v-model 的重点,但是这个函数的逻辑很简单。重点就是 model 描述对象的生成。
function genComponentModel(el,value,modifiers) {...el.model = {value: ("(" + value + ")"),expression: JSON.stringify(value),callback: ("function (" + baseValueExpression + ") {" + assignment + "}")};}
针对我们的示例,生成了如下的描述对象。
回到genDrirectives() 方法,会对 model 描述对象处理,将描述对象生成字符串。挂载到data上。
// component v-modelif (el.model) {data += "model:{value:" + (el.model.value) + ",callback:" + (el.model.callback) + ",expression:" + (el.model.expression) + "},";}

这一步的处理不仅仅是为了为组件标签生成代码字符串render code。
with(this) {return _c('inputComponent',{model: {value:(message),callback: function ($$v) { message=$$v },expression:"message"}})}
还一个目的是在创建子组件的阶段,将组件的 **v-model**转换到**props**和**event**。
整个组件的创建是一个递归过程,当处理完父组件的创建(父组件包含了组件标签),也就进入了子组件的创建,这里我们看看如果将组件的 v-model转换到props和event。
关键就是一个函数,transformModel()函数。
function createComponent(Ctor,data,context,children,tag) {...// transform component v-model data into props & eventsif (isDef(data.model)) {transformModel(Ctor.options, data);}...}function transformModel(options, data) {var prop = (options.model && options.model.prop) || 'value';var event = (options.model && options.model.event) || 'input'; (data.attrs || (data.attrs = {}))[prop] = data.model.value;var on = data.on || (data.on = {});var existing = on[event];var callback = data.model.callback;if (isDef(existing)) {if (Array.isArray(existing)? existing.indexOf(callback) === -1: existing !== callback) {on[event] = [callback].concat(existing);}} else {on[event] = callback;}}
transformModel()函数目的就是将组件设置的 v-model 转换。
- model.value -> 子组件 props.value
- model.callback -> 子组件 on event

这样一来,就将父组件的属性绑定到子组件的 **value**上,同时监听自定义**input**事件。当子组件进行派发**input**事件时,回调修改父组件的值。
并且transformModel()函数还可以将子组件的 value prop 以及派发的 input 事件名进行配置处理(默认是value和input)。
var prop = (options.model && options.model.prop) || 'value';var event = (options.model && options.model.event) || 'input'
比如:
let inputComponent = {template: `<div><input:value="newMessage"...@input="updateValue"/>{{ value }}</div>`,props: ['newMessage'],model: {prop: 'newMessage',event: 'change'},methods: {updateValue(e) {this.$emit('change', e.target.value)}}}new Vue({el: '#app',data: {message: ''},components: {inputComponent},template: `<inputComponent v-model="message"></inputComponent>`});
子组件修改了接收的 prop 名以及派发的事件名,然而这一切父组件作为调用方是不用关心的,这样做的好处是我们可以把 value 这个 prop 作为其它的用途。
小结
父组件 v-model 默认被绑定在子组件的 value 上,并且同时监听自定义input事件。当子组件触发自定义input事件时,父组件中的v-model绑定值也随之更新,同时子组件的value也被改变,实现数据的双向绑定。这是 Vue 的父子组件通讯模式,父组件通过 prop 把数据传递到子组件,子组件修改数据后通过 $emit 事件的方式通知父组件,所以说组件上的 v-model 也是一种语法糖。
总结
OK,到这里 v-model 的原理已经全部分析完了,我们再来回忆一下:
- 表单元素上的 v-model 原理的精髓,在于通过修改 AST 元素,添加 prop,添加事件处理,实现数据的双向绑定。
- 组件元素上的 v-model 原理的精髓,在于父组件默认将
v-model被绑定在子组件的value上,并添加子组件的派发事件处理,实现数据的双向绑定。
但是不管是表单元素还是组件元素,Vue 的双向绑定本质上是 v-model语法糖。所以请不要将 Vue 的响应式原理认为是双向绑定。
