抛出问题
我们先来看一下下面这段代码
<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
