抛出问题
我们先来看一下下面这段代码
<template>
<div>
<div class="message">{{ info.message }}</div>
<div><input v-model="info.message" type="text"></div>
<button @click="change">click</button>
</div>
</template>
<script>
export default {
data () {
return {
info: {}
}
},
methods: {
change () {
this.info.message = 'hello world'
}
}
}
</script>
上述代码很简单,就不做过多的解释了。如果这段代码都看不懂,那下面也没必要再看下去了
问题重现步骤
我现在对上述代码做两种操作:
- 一进页面先在输入框中输入
hello vue
- 一进页面先点击click按钮进行赋值操作,再在输入框中输入
hello vue
上述两种情况分别会出现什么现象呢?
第一种操作,当我们在输入框中输入hello vue
的时候,class为message
的div中会联动出现hello vue
,也就是说info
中的message
属性是响应式的
第二种操作,当我们先进行赋值操作,之后无论在输入框中输入什么内容,class为message
的div中都不会联动出现任何值,也就是说info
中的message
属性非响应式的
问题引发的猜想
查阅vue官方文档我们得知vue
在初始化的时候会对data中所有已经定义
的对象及其子属性进行遍历,给他们添加getter
和setter
,使得他们变成响应式的(关于响应式这块之后会单开文章进行解析),但是vue
不能检测对象属性的添加或删除。但是,可以使用 Vue.set(object, propertyName, value)
方法向嵌套对象添加响应式属性
基于上述描述,我们先看第一种操作。直接在输入框中输入hello vue
,class为message
的div中会联动出现hello vue
。但是我们看data
中只定义了info
对象,其中并没有定义message
属性,message
属于新增属性。根据vue
官方文档中说的,vue
不能检测对象属性的添加或删除,所以我猜测vue底层在解析v-model
指令的时候,每当触发表单元素的监听事件(例如input事件),就会有Vue.set()
操作,从而触发setter
带着这个猜测,我们来看第二种操作。一进页面先点击click按钮,对info.message
进行赋值,message
属于新增属性,根据官方文档中说的,此时message
并不是响应式的,没问题。但是我们接着在input
输入框中输入值,class为message
的div中没有联动出现任何值,根据我们对于第一种情况的猜测,当输入框监听到input
事件的时候,会对info
中的message
进行Vue.set()
操作,所以理论上就算一开始click中是对新增属性message
直接赋值的,导致该属性并非响应式的,在经过输入框input
事件中的Vue.set()
操作之后,应该会变成响应式的,而现在呈现出来的情况并不是这样的啊,这是为什么呢?
聪明的你们应该已经猜到在Vue.set()
底层源码中,应该是会判断message
属性是否一开始就在info
中,如果存在就只是进行单纯的赋值,不存在的话在进行响应式操作,绑定getter
和setter
但是光猜测肯定是不够的,我们要用事实说话,做到有理有据。接下来我们就去看下vue
源码中v-model
这块,看看是不是如我们猜想的一样
探索真相-源码分析
v-model
指令使用分为两种情况:一种是在表单元素上使用,另外一种是在组件上使用。我们今天分析的是第一种情况,也就是在表单元素上使用
v-model
实现机制
我们先简单说下v-model
的机制:v-model
会把它关联的响应式数据(如info.message
),动态地绑定到表单元素的value属性上,然后监听表单元素的input
事件:当v-model
绑定的响应数据发生变化时,表单元素的value值也会同步变化;当表单元素接受用户的输入时,input
事件会触发,input
的回调逻辑会把表单元素value最新值同步赋值给v-model
绑定的响应式数据。
v-model
实现原理
我用来分析的源码是在vue官网安装模块里面下载的开发版本(2.6.10),便于调试
编译
我们今天讲的内容其实就是把模版编译成render
函数的一个流程,这里不会对每步流程都展开讲解,我可以给出一个步骤实现的流程,大家有兴趣的话可以根据这个流程来阅读代码,提高效率
$mount()->compileToFunctions()->compile()->baseCompile()
真正的编译过程都是在这个baseCompile()
里面执行,执行步骤可以分为三个过程
- 解析模版字符串生成AST
const ast = parse(template.trim(), options)
- 优化语法树
optimize(ast, options)
- 生成代码
const code = generate(ast, options)
然后我们看下generate
里面的代码,这也是我们今天讲的重点
function generate (
ast,
options
) {
var state = new CodegenState(options);
var code = ast ? genElement(ast, state) : '_c("div")';
return {
render: ("with(this){return " + code + "}"),
staticRenderFns: state.staticRenderFns
}
}
generate()
首先通过 genElement()->genData$2()->genDirectives()
生成code
,再把code
用 with(this){return ${code}}}
包裹起来,最终的到render函数。
接下来我们从genDirectives()
开始讲解
genDirectives
在模板的编译阶段,v-model
跟其他指令一样,会被解析到 el.directives
中,之后会通过genDirectives
方法处理这些指令,我们这里从genDirectives()
重点开始讲,至于怎么到这步,如果大家感兴趣的话,可以从generate()
开始看
function genDirectives (el, state) {
var dirs = el.directives;
if (!dirs) { return }
var res = 'directives:[';
var hasRuntime = false;
var i, l, dir, needRuntime;
for (i = 0, l = dirs.length; i < l; i++) {
dir = dirs[i];
needRuntime = true;
var gen = state.directives[dir.name];
if (gen) {
// compile-time directive that manipulates AST.
// returns true if it also needs a runtime counterpart.
needRuntime = !!gen(el, dir, state.warn);
}
if (needRuntime) {
hasRuntime = true;
res += "{name:\"" + (dir.name) + "\",rawName:\"" + (dir.rawName) + "\"" + (dir.value ? (",value:(" + (dir.value) + "),expression:" + (JSON.stringify(dir.value))) : '') + (dir.arg ? (",arg:" + (dir.isDynamicArg ? dir.arg : ("\"" + (dir.arg) + "\""))) : '') + (dir.modifiers ? (",modifiers:" + (JSON.stringify(dir.modifiers))) : '') + "},";
}
}
if (hasRuntime) {
return res.slice(0, -1) + ']'
}
}
我对上面这个代码打个断点,结合我们上面的代码例子,这样子看的更清楚,如下图:
我们可以看到传进来的el
是Ast
语法树,el.directives
是el
上的指令,在我们这里就是el-model
的相关参数,然后赋值给变量dirs
往下看代码,for循环中有段代码:
var gen = state.directives[dir.name];
if (gen) {
// compile-time directive that manipulates AST.
// returns true if it also needs a runtime counterpart.
needRuntime = !!gen(el, dir, state.warn);
}
这里面的state.dirctives
是什么呢?打个断点看一下,如下图:
我们可以看到state.directives
里面包含了很多指令方法,model
就在其中,
var gen = state.directives[dir.name];
其实就是等价于
var gen = state.directives[model];
所以代码中的变量gen
得到的是model()
needRuntime = !!gen(el, dir, state.warn);
其实就是执行了model()
model
那我们再来看看model这个方法里面做了些什么事情,先上model的代码:
function model (el,dir,_warn) {
warn$1 = _warn;
var value = dir.value;
var modifiers = dir.modifiers;
var tag = el.tag;
var type = el.attrsMap.type;
{
// inputs with type="file" are read only and setting the input's
// value will throw an error.
if (tag === 'input' && type === 'file') {
warn$1(
"<" + (el.tag) + " v-model=\"" + value + "\" type=\"file\">:\n" +
"File inputs are read only. Use a v-on:change listener instead.",
el.rawAttrsMap['v-model']
);
}
}
if (el.component) {
genComponentModel(el, value, modifiers);
// component v-model doesn't need extra runtime
return false
} else if (tag === 'select') {
genSelect(el, value, modifiers);
} else if (tag === 'input' && type === 'checkbox') {
genCheckboxModel(el, value, modifiers);
} else if (tag === 'input' && type === 'radio') {
genRadioModel(el, value, modifiers);
} else if (tag === 'input' || tag === 'textarea') {
genDefaultModel(el, value, modifiers);
} else if (!config.isReservedTag(tag)) {
genComponentModel(el, value, modifiers);
// component v-model doesn't need extra runtime
return false
} else {
warn$1(
"<" + (el.tag) + " v-model=\"" + value + "\">: " +
"v-model is not supported on this element type. " +
'If you are working with contenteditable, it\'s recommended to ' +
'wrap a library dedicated for that purpose inside a custom component.',
el.rawAttrsMap['v-model']
);
}
// ensure runtime directive metadata
return true
}
model
方法根据传入的参数对tag的类型进行判断,调用不同的处理逻辑,本demo中tag的类型为input
,所以会执行genDefaultModel
方法
genDefaultModel
function genDefaultModel (el,value,modifiers) {
var type = el.attrsMap.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()
中的代码进行分块解析,首先看下面这段代码:
是否同时具有指令v-model
和v-bind
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]
);
}
这块代码其实就是解释表单元素是否同时有指令v-model
和v-bind
var ref = modifiers || {};
var lazy = ref.lazy;
var number = ref.number;
var trim = ref.trim;
修饰符
这段代码就是获取修饰符lazy, number及trim
- .lazy 取代input监听change事件
- .number 输入字符串转为数字
- .trim 输入首尾空格过滤
var needCompositionGuard = !lazy && type !== 'range';
这里的needCompositionGuard
后面再说有什么用,现在只用知道默认是true
就行了
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 + ")";
}
上面这段代码中,event = ‘input’
,定义变量valueExpression
,修饰符trim
和number
在我们这个demo中默认都没有,所以跳过往下看
genAssignmentCode
var code = genAssignmentCode(value, valueExpression);
if (needCompositionGuard) {
code = "if($event.target.composing)return;" + code;
}
这里涉及到一个函数genAssignmentCode,上源码:
function genAssignmentCode (
value,
assignment
) {
var res = parseModel(value);
if (res.key === null) {
return (value + "=" + assignment)
} else {
return ("$set(" + (res.exp) + ", " + (res.key) + ", " + assignment + ")")
}
}
这段代码是生成v-model
绑定的value
的值,看到这段代码,我们就知道离真相不远了,因为我们看到了$set()
。现在我们通过断点具体分析下,如下图:
通过断点我们可以很清楚的看到我们先执行parseModel('info.message')
获取到一个对象res
,由于我们的demo中绑定的值是路径形式的对象,即info.message
,所以此时res通过parseModel
解析出来就是{exp: "info", key: "message"}
。那下面的判断就进入else,即:
return ("$set(" + (res.exp) + ", " + (res.key) + ", " + assignment + ")")
回到上面的getDefaultModel()中
var code = genAssignmentCode(value, valueExpression);
if (needCompositionGuard) {
code = "if($event.target.composing)return;" + code;
}
此时code
获取到genAssignmentCode()
返回的字符串值"$set(info, "message", $event.target.value)"
$event.target.composing
上面我说的到变量needCompositionGuard = true
,经过拼接,最终code = “if($event.target.composing)return;$set(info, "message", $event.target.value)”
这里的$event.target.composing
有什么用呢?其实就是用于判断此次input事件是否是IME构成触发的,如果是IME构成,直接return。IME 是输入法编辑器(Input Method Editor) 的英文缩写,IME构成指我们在输入文字时,处于未确认状态的文字。如图:
带下划线的ceshi就属于IME构成,它会同样会触发input事件,但不会触发v-model更新数据。
继续往下看
addProp(el, 'value', ("(" + value + ")"));
addHandler(el, event, code, null, true);
if (trim || number) {
addHandler(el, 'blur', '$forceUpdate()');
}
addProp
先说下addProp(el, 'value', ("(" + value + ")"))
,
function addProp (el, name, value, range, dynamic) {
(el.props || (el.props = [])).push(rangeSetItem({ name: name, value: value, dynamic: dynamic }, range));
el.plain = false;
}
照常打个断点看下:,如下图
可以看到此方法的功能为给el
添加props
,首先判断el
上有没有props
,如果没有的话创建props
并赋值为一个空数组,随后拼接对象并推到props
中,代码在此demo中相当于push{name: "value", value: "(info.message)"}
如果一直往下追,可以看到这个方法其实是在input
输入框上绑定了value,对照我们的demo来看,就是将<input v-model="info.message" type="text">
变成<input v-bind:value="info.message" type="text">
addHandler
同样的,addHandler()
相当于在input
上绑定了input
事件,最终我们demo的模版就会被编译成
<input v-bind:value="info.message" v-on:input="info.message=$event.target.value">
render
后续再根据一些指令拼接,我们最终的到的render如下:
with(this) {
return _c('div', {
attrs: {
"id": "app-2"
}
}, [_c('div', [_v(_s(info.message))]), _v(" "), _c('div', [_c('input', {
directives: [{
name: "model",
rawName: "v-model",
value: (info.message),
expression: "info.message"
}],
attrs: {
"type": "text"
},
domProps: {
"value": (info.message)
},
on: {
"input": function ($event) {
if ($event.target.composing) return;
$set(info, "message", $event.target.value)
}
}
})]), _v(" "), _c('button', {
on: {
"click": change
}
}, [_v("click")])])
}
最后通过createFunction()
把render
代码串通过new Function
的方式转换成可执行的函数,赋值给 vm.options.render
,这样当组件通过vm._render
的时候,就会执行这个render
函数
至此,针对表单元素上的v-model
指令从开始编译到最终生成render()
并执行的过程就讲解完了,我们验证了在编译阶段,v-model
会在监听到input
事件时对我们绑定的value
进行Vue.$set()
操作
还记得我们上面说的对demo第二种操作情况么?先进行click操作赋值,那v-model
中的Vue.$set()
操作似乎没有作用了。我们当时猜测的是Vue.$set()
底层源码中有应该是会判断message
属性是否一开始就在info
中,如果存在就只是进行单纯的赋值,不存在的话在进行响应式操作,绑定getter
和setter
现在我们就去Vue.$set()
中看一下
set
先上代码:
/**
* Set a property on an object. Adds the new property and
* triggers change notification if the property doesn't
* already exist.
*/
function set (target, key, val) {
if (isUndef(target) || isPrimitive(target)
) {
warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target))));
}
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key);
target.splice(key, 1, val);
return val
}
if (key in target && !(key in Object.prototype)) {
target[key] = val;
return val
}
var ob = (target).__ob__;
if (target._isVue || (ob && ob.vmCount)) {
warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
);
return val
}
if (!ob) {
target[key] = val;
return val
}
defineReactive$$1(ob.value, key, val);
ob.dep.notify();
return val
}
看到这句代码了么?这就是证据,验证我们猜想的证据
if (key in target && !(key in Object.prototype)) {
target[key] = val;
return val
}
验证猜想
当我们首先点击click的时候,执行this.info.message = 'hello world'
,此时info
对象中新增了一个message
属性。当我们在input
框中输入值并触发Vue.$set()
时,key in target
为true
,并且message
又不是Object
原型上的属性,所以!(key in Object.prototype)
也为true
,此时message
属性并不是响应式属性,没有绑定setter
,所以仅仅进行了单纯的赋值操作。
而当我们一进页面首次
在input
中执行输入操作时,根据上面我们的分析input
框监听到了input
事件,先执行了Vue.$set()
操作,因为时首次,所以info
中还没有message
属性,所以上面的key in target
为false
,跳过了赋值操作,到了下面的
defineReactive$$1(ob.value, key, val);
ob.dep.notify();
这个defineReactive
的作用就是为message
绑定了getter()
和setter()
,之后再对message
的赋值操作都会直接进入自身绑定的setter
中进行响应式操作
一个意外的发现
我突然奇想把vue的版本换到了2.3.0,发现v-model
不能对demo中的message
属性实现响应化,跑去看了下vue更新日志,发现在2.5.0版本中,有这么一句话
now creates non-existent properties as reactive (non-recursive) e1da0d5, closes #5932 (See reasoning behind this change)
上面这句话的意思是从2.5.0版本开始支持将不存在的属性响应化,非递归的。
因为message
属性一开始在info中并没有定义,在2.3.0中,还不支持将不存在的属性响应化的操作,所以对demo无效
总结
到这里,我们这篇文章就结束了 里面有一些细节如果大家有兴趣的话可以自己再去深究一下。有时候很小的一个问题,背后牵扯到的知识点也是很多的,尽量把每个不懂背后的逻辑搞清楚,才能尽快的成为你想成为的人
参考资料
https://segmentfault.com/a/1190000015848976#articleHeader0
https://blog.csdn.net/fabulous1111/article/details/85265503