Vue2.x之v-model详解

传送门

在看本篇之前,你可以看看博主的另两篇文章,有助于对本篇的理解

  1. Vue2.x数据响应篇
  2. Vue2.x-Watcher篇

v-model

传送门的两章详细讲述了Vue在处理数据响应的过程,但关于Vue在runtime时期的compile还没梳理,这章我们先从v-model开始吧。
v-model是Vue提供给用户做双向绑定的一个指令语法 原文传送门
官网对于用法讲得很清楚,可以给input、select、checkbox、radio和自定义组件使用v-model指令

上demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<div id="root">
<input v-model="input.value" type="text" />
</div>
<script src="./vue.js"></script>
<script>
new Vue({
el: '#root',
data() {
return {
value: 'demo',
input: {
value: 'demo',
},
}
},
})
</script>

以上是我们常见的v-model用法,当我们再input内输入内容时 组件实例上的数据也会随之修改,这就实现了数据的双向绑定

原理

在模板编译阶段,v-model会被解析到el.directives中,我们从genDirectives方法看起

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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.directives里的指令,并用state里提供
el.directives的类型如下:

1
2
3
4
5
6
7
8
[
{
name: 'model',
rawName: 'v-model',
value: 'input.value', // v-mode后的的expression
...rest, // 其余属性 有兴趣可以打印出来看看
}
]

state.directives也提供了处理个指令的方法,对应如下:

1
2
3
4
5
6
7
8
{
on() {},
bind() {},
text() {},
html() {},
model() {},
cloak() {},
}

由此可见Vue提供了以上6中内置指令,有兴趣的同学可以到官网了解一下这几种常见指令的用法
回到正题,这里我们需要的是model处理指令的代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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
}

从上面的代码不难看出不同类型的组件使用v-model,解析的方式也不一样,demo我们使用的是input,根据匹配规则我们先看第五个branch,genDefaultModel代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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()');
}
}

先判断el上是否也存在v-bind:value,为了防止和v-model冲突,会抛出错误
lazy、number、trim是v-model指令自带的修饰符,有兴趣的同学可以参考官网的用法

  1. lazy的目的是为了取代 input 监听 change 事件
  2. number的目的是为了将字符串转化为有效数字
  3. trim目的是为了去除首尾的空格
    根据上面三个修饰符对valueExpression赋予了不同的值
    1
    var code = genAssignmentCode(value, valueExpression);
    我们先看genAssignmentCode的代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function genAssignmentCode (
    value,
    assignment
    ) {
    var res = parseModel(value);
    if (res.key === null) {
    return (value + "=" + assignment)
    } else {
    return ("$set(" + (res.exp) + ", " + (res.key) + ", " + assignment + ")")
    }
    }
    parseModal的代码我就不贴了,大致意思就是分析v-model的value,比如demo传入的是”input.value”,会被parseModal转化成
    1
    2
    3
    4
    {
    exp: 'input',
    key: 'value',
    }
    众所周知,v-model后面可以传入模板字符串,比如name,也可以传入对象取值的表达式,比如person.name等,
    如果我们v-model传的是”value”,返回结果是这样的
    1
    2
    3
    4
    {
    exp: 'value',
    key: null,
    }
    如果我们传入的是”person.chinese.name”,返回如下
    1
    2
    3
    4
    {
    exp: 'person.chinese',
    key: 'name',
    }
    接着看代码,根据我们的demo来看
    如果v-model后面跟的是纯字符串”value”,返回”value = $event.target.value”
    如果v-model后面跟的对象模板字符串”input.value”,返回”$set(‘input’, ‘value’, $event.target.value)”
    看到这儿,你应该能知道v-model是通过什么方式同步修改组件实例上的值了吧
    回到getDefaultModel方法:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    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()');
    }
    needCompositionGuard作用是处理了中文输入法的问题,并将’if($event.target.composing)return;’拼接到genAssignmentCode的结果前面
    addProp:
    1
    2
    3
    4
    function addProp (el, name, value, range, dynamic) {
    (el.props || (el.props = [])).push(rangeSetItem({ name: name, value: value, dynamic: dynamic }, range));
    el.plain = false;
    }
    执行到这儿就相当于把v-model转换正v-bind:value=”input.value”了
    下一步addHandler的目的就是在el.events上增加input方法,把我们的code结果添加到input方法上,得到以下节点
    1
    <input v-bind:value="input.value" type="text" @input="if($event.target.composing)return;$set('input', 'value', $event.target.value)" />
    当然实际代码并不会直接生成这个dom节点,首先生成的应该是dom的ast结构,然后通过编译拼接成如下代码结构:
    方便阅读,就不展示字符串了
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    with(this){
    return _c(
    'div',
    {
    attrs:{"id":"root"}
    },
    [
    _c(
    'input',
    {
    directives:[{name:"model",rawName:"v-model",value:(input.value),expression:"input.value"}],
    attrs:{"type":"text"},
    domProps:{"value":(input.value)},
    on:{"input":function($event){if($event.target.composing)return;$set(input, "value", $event.target.value)}
    }})
    ]
    )
    }
    以上的代码会通过new Function()进行封装并赋值给options.render,上一章讲到的render-watcher调用更新的时候实际调用的就是这段代码

总结

v-model的原理现在看来其实很明确了,就是在编译过程中会将v-model装换成v-bind:value(当然,这点因组件而异),并在el上添加input事件,
如果是深层的字符串模板,会生成$set对应的方法,若是单个字符串,会直接将value = $event.target.value拼接到input方法上。