请不要把 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>
<input
v-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++) {
...
// 代码 1
var gen = state.directives[dir.name];
if (gen) {
// 代码 2
needRuntime = !!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-model
if (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 & events
if (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 的响应式原理认为是双向绑定。