Vue源码-指令v-model

在Vue中我们可以用v-model指令来使表单的值和状态进行双向绑定,当表单的值改变时绑定的值也会变化。其实,v-model是Vue提供的props和事件的语法糖,现在我们通过源码分析下这其中的原理。

表单元素绑定

我们先来看一下v-model的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import Vue from 'vue';

new Vue({
el: '#app',
template: `
<div>
<input v-model="message" />
<p>{{ message }}</p>
</div>
`,
data: {
message: ''
}
});

编译解析

对于v-model和其他指令一样,在模版的编译解析阶段会走src/compiler/parser/index.js文件的processAttrs方法,这个方法是对ast节点的attrsList属性进行处理。因为这个指令不是v-bindv-on等特殊指令,所以该方法会走下面逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
name = name.replace(dirRE, '')
// parse arg
const argMatch = name.match(argRE)
let arg = argMatch && argMatch[1]
isDynamic = false
if (arg) {
name = name.slice(0, -(arg.length + 1))
if (dynamicArgRE.test(arg)) {
arg = arg.slice(1, -1)
isDynamic = true
}
}
addDirective(el, name, rawName, value, arg, isDynamic, modifiers, list[i])
if (process.env.NODE_ENV !== 'production' && name === 'model') {
checkForAliasModel(el, value)
}

这个方法就是处理普通指令并调用addDirective方法在ast节点的directives属性上增加指令对象,对于我们的例子,执行完的结果:

现在对v-model的编译解析阶段就完成了,接下来是进行编译代码生成阶段。

代码生成

在编译代码生成阶段,会在src/compiler/codegen/index.js文件对于data代码生成入口函数genData中处理指令代码的相关逻辑,这部分逻辑都在genDirectives函数处理:

1
2
3
4
5
6
7
8
9
10
11
// 生成render代码入口
export function genData (el: ASTElement, state: CodegenState): string {
let data = '{'

// directives first.
// directives may mutate the el's other properties before they are generated.
const dirs = genDirectives(el, state)
if (dirs) data += dirs + ','

// ...
}

来看下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
25
26
27
28
29
30
function genDirectives (el: ASTElement, state: CodegenState): string | void {
const dirs = el.directives
if (!dirs) return
let res = 'directives:['
let hasRuntime = false
let i, l, dir, needRuntime
for (i = 0, l = dirs.length; i < l; i++) {
dir = dirs[i]
needRuntime = true
const gen: DirectiveFunction = 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) + ']'
}
}

这个方法循环遍历ast节点的directives属性的每个指令,对于每个指令会调用state.directives[dir.name]返回的函数。这里的state是指Vue编译相关的一些配置,这些配置和平台有关,它的入口在src/platforms/web/compiler/options.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import directives from './directives/index'
//...

export const baseOptions: CompilerOptions = {
expectHTML: true,
modules,
directives,
isPreTag,
isUnaryTag,
mustUseProp,
canBeLeftOpenTag,
isReservedTag,
getTagNamespace,
staticKeys: genStaticKeys(modules)
}

和指令相关配置定义在src/platforms/web/compiler/directives/index.js中:

1
2
3
4
5
6
7
8
9
import model from './model'
import text from './text'
import html from './html'

export default {
model,
text,
html
}

很明显Vue对这3个特殊的指令编译都有特殊处理。所以上面的gen函数就是指src/platforms/web/compiler/directives/model.js文件中定义的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
export default function model (
el: ASTElement,
dir: ASTDirective,
_warn: Function
): ?boolean {
warn = _warn
const value = dir.value
const modifiers = dir.modifiers
const tag = el.tag
const type = el.attrsMap.type

if (process.env.NODE_ENV !== 'production') {
// inputs with type="file" are read only and setting the input's
// value will throw an error.
if (tag === 'input' && type === 'file') {
warn(
`<${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 if (process.env.NODE_ENV !== 'production') {
warn(
`<${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绑定在不同表单或者组件的处理。在我们例子是绑定在input,所以会调用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
function genDefaultModel (
el: ASTElement,
value: string,
modifiers: ?ASTModifiers
): ?boolean {
const type = el.attrsMap.type

const { lazy, number, trim } = modifiers || {}
const needCompositionGuard = !lazy && type !== 'range'
const event = lazy
? 'change'
: type === 'range'
? RANGE_TOKEN
: 'input'

let valueExpression = '$event.target.value'
if (trim) {
valueExpression = `$event.target.value.trim()`
}
if (number) {
valueExpression = `_n(${valueExpression})`
}

let 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()')
}
}

这个方法先获取v-model指令的修饰符,接下来是根据不同修饰符对事件类型event和表达式的值valueExpression的处理。然后调用genAssignmentCode方法生成我们回调函数的code

1
2
3
4
5
6
7
8
9
10
11
export function genAssignmentCode (
value: string,
assignment: string
): string {
const res = parseModel(value)
if (res.key === null) {
return `${value}=${assignment}`
} else {
return `$set(${res.exp}, ${res.key}, ${assignment})`
}
}

这个方法主要是要处理指令表达式是类似test[test1[key]], test["a"][key]等情况。我们例子直接返回${value}=${assignment}。因为我们没设置lazy,所以最终我们的code为if($event.target.composing)return;message=$event.target.value。对于composing为真直接返回这段逻辑我们稍后分析。接下来就是v-model指令的关键逻辑:

1
2
addProp(el, 'value', `(${value})`)
addHandler(el, event, code, null, true)

它会往ast节点上增加一个props和绑定一个事件event,这就是Vue语法糖实现的核心。执行完这段逻辑看下ast节点结果:

执行完平台的model方法后返回true,再回到genDirectives方法,如果needRuntimetrue,就把指令相关属性就行字符串代码拼接并最终返回。这里我们看下genData函数有一细节,就是函数最开始就处理指令,这是因为处理指令时候可能会在节点上新增其他一些属性,例如我们v-model指令会增加props和事件。

最后,来看下render生成的代码结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
with (this) {
return _c('div', [
_c('input', {
directives: [{ name: 'model', rawName: 'v-model', value: message, expression: 'message' }],
domProps: { value: message },
on: {
input: function($event) {
if ($event.target.composing) return;
message = $event.target.value;
}
}
}),
_v(' '),
_c('p', [_v(_s(message))])
]);
}

指令钩子

在上面分析后,我们的例子其实等价于:

1
2
3
4
5
6
7
8
9
10
11
12
new Vue({
el: '#app',
template: `
<div>
<input :value="message" @input="message=$event.target.value"/>
<p>{{ message }}</p>
</div>
`,
data: {
message: ''
}
});

但是这里面有一个细微的差别我们可能没注意,那就是对于中文输入的处理。使用v-model输入中文过程中我们状态message是不会更着变化的,而等价的写法就会,那这中间的处理Vue是怎么实现的呢?

我们知道Vue的自定义指令存在钩子函数,并且在绑定的元素的插入或者更新阶段触发。其实,Vue也内置了v-model的钩子函数来处理我们上面说的中文输入的场景。现在来看下它的定义。

在我们虚拟节点的patch过程中会触发一系列的钩子函数,对于指令会在create,updatedestory钩子都会有处理,它的入口定义在src/core/vdom/modules/directives.js

1
2
3
4
5
6
7
8
9
10
11
12
13
export default {
create: updateDirectives,
update: updateDirectives,
destroy: function unbindDirectives (vnode: VNodeWithData) {
updateDirectives(vnode, emptyNode)
}
}

function updateDirectives (oldVnode: VNodeWithData, vnode: VNodeWithData) {
if (oldVnode.data.directives || vnode.data.directives) {
_update(oldVnode, vnode)
}
}

很明显,在上面的三个时期都会调用_update函数:

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
53
54
55
56
57
58
59
60
function _update (oldVnode, vnode) {
const isCreate = oldVnode === emptyNode
const isDestroy = vnode === emptyNode
const oldDirs = normalizeDirectives(oldVnode.data.directives, oldVnode.context)
const newDirs = normalizeDirectives(vnode.data.directives, vnode.context)

const dirsWithInsert = []
const dirsWithPostpatch = []

let key, oldDir, dir
for (key in newDirs) {
oldDir = oldDirs[key]
dir = newDirs[key]
if (!oldDir) {
// new directive, bind
callHook(dir, 'bind', vnode, oldVnode)
if (dir.def && dir.def.inserted) {
dirsWithInsert.push(dir)
}
} else {
// existing directive, update
dir.oldValue = oldDir.value
dir.oldArg = oldDir.arg
callHook(dir, 'update', vnode, oldVnode)
if (dir.def && dir.def.componentUpdated) {
dirsWithPostpatch.push(dir)
}
}
}

if (dirsWithInsert.length) {
const callInsert = () => {
for (let i = 0; i < dirsWithInsert.length; i++) {
callHook(dirsWithInsert[i], 'inserted', vnode, oldVnode)
}
}
if (isCreate) {
mergeVNodeHook(vnode, 'insert', callInsert)
} else {
callInsert()
}
}

if (dirsWithPostpatch.length) {
mergeVNodeHook(vnode, 'postpatch', () => {
for (let i = 0; i < dirsWithPostpatch.length; i++) {
callHook(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode)
}
})
}

if (!isCreate) {
for (key in oldDirs) {
if (!newDirs[key]) {
// no longer present, unbind
callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy)
}
}
}
}

这个方法用isCreate表示当前vnode是否是新建的节点,isDestroy表示当前节点是否销毁。normalizeDirectives方法是获取格式化指令对象,把指令的钩子函数进行整合到def。接着循环新节点的指令数组newDirs,对于每个指令对象dir在老的指令对象oldDirs不存在,这会调用指令的bind钩子,如果有定义insert钩子,则push到dirsWithInsert队列中,这样能保证所有的指令执行完bind钩子才去执行insert钩子。

如果老的指令对象oldDir存在,则调用指令的update钩子,并把componentUpdated钩子存到dirsWithPostpatch中,这样能保证所有的指令执行完update钩子才去执行componentUpdated钩子。最后把执行指令insert钩子数组函数合并到虚拟节点的自身的insert钩子,把执行指令componentUpdated钩子数组函数合并到虚拟节点的自身的postpatch钩子,这样就会更新虚拟节点在patch过程的对应阶段执行。

如果不是新建的节点,并且老的指令数组oldDirs如果有newDirs中不存在的,则证明该指令已经废弃,会调用响应的unbind钩子函数。

回到我们上面的问题,看看v-model内置的insert钩子的实现,它定义在src/platforms/web/runtime/directives/model.js中:

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
const directive = {
inserted (el, binding, vnode, oldVnode) {
if (vnode.tag === 'select') {
// #6903
if (oldVnode.elm && !oldVnode.elm._vOptions) {
mergeVNodeHook(vnode, 'postpatch', () => {
directive.componentUpdated(el, binding, vnode)
})
} else {
setSelected(el, binding, vnode.context)
}
el._vOptions = [].map.call(el.options, getValue)
} else if (vnode.tag === 'textarea' || isTextInputType(el.type)) {
el._vModifiers = binding.modifiers
if (!binding.modifiers.lazy) {
el.addEventListener('compositionstart', onCompositionStart)
el.addEventListener('compositionend', onCompositionEnd)
el.addEventListener('change', onCompositionEnd)
if (isIE9) {
el.vmodel = true
}
}
}
}
}

上面代码在处理绑定inputtextarea类型的绑定时,在元素插入DOM后会另外绑定compositionstartcompositionend事件,它们分别会在中文输入过程和输入完成触发。来看下对应的回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function onCompositionStart (e) {
e.target.composing = true
}

function onCompositionEnd (e) {
// prevent triggering an input event for no reason
if (!e.target.composing) return
e.target.composing = false
trigger(e.target, 'input')
}

function trigger (el, type) {
const e = document.createEvent('HTMLEvents')
e.initEvent(type, true, true)
el.dispatchEvent(e)
}

在中文输入过程中,设置e.target.composingtrue,这个时候我们再来看下v-model绑定事件的函数体:

1
2
if ($event.target.composing) return;
message = $event.target.value;

当中文输入过程中触发的input事件,$event.target.composingtrue直接返回,这样状态就会不更着改变了。当中文输入完成执行onCompositionEnd函数会把e.target.composing设置为false,这个时候执行函数体就会修改状态message了。

组件绑定

v-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
const Child = {
template: `<div>
<input :value="value" @input="handleInput">
</div>`,
props: ['value'],
methods: {
handleInput(e) {
this.$emit('input', e.target.value);
}
}
};

new Vue({
el: '#app',
template: `
<div>
<Child v-model="message"></Child>
<p>{{ message }}</p>
</div>
`,
data: {
message: ''
},
components: { Child }
});

在组件上使用v-model也会在编译模版时进行处理,不同的是在gen函数中会走下面的逻辑:

1
2
3
4
5
else if (!config.isReservedTag(tag)) {
genComponentModel(el, value, modifiers)
// component v-model doesn't need extra runtime
return false
}

因为组件不是平台保留的标签,调用genComponentModel方法进行处理并且返回false

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
export function genComponentModel (
el: ASTElement,
value: string,
modifiers: ?ASTModifiers
): ?boolean {
const { number, trim } = modifiers || {}

const baseValueExpression = '$$v'
let valueExpression = baseValueExpression
if (trim) {
valueExpression =
`(typeof ${baseValueExpression} === 'string'` +
`? ${baseValueExpression}.trim()` +
`: ${baseValueExpression})`
}
if (number) {
valueExpression = `_n(${valueExpression})`
}
const assignment = genAssignmentCode(value, valueExpression)

el.model = {
value: `(${value})`,
expression: JSON.stringify(value),
callback: `function (${baseValueExpression}) {${assignment}}`
}
}

这个方法主要在ast节点上添加model属性来表示指令相关数据,我们例子中执行完的结果为:

然后返回genData函数,这里返回的dirs为undefined,因为组件使用v-model单纯是个语法糖,不需要在运行时进行相关处理。另外,这个函数要把节点上的model赋值给data属性:

1
2
3
4
5
6
7
8
9
10
// component v-model
if (el.model) {
data += `model:{value:${
el.model.value
},callback:${
el.model.callback
},expression:${
el.model.expression
}},`
}

最后我们看下生成的render代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
with (this) {
return _c(
'div',
[
_c('Child', {
model: {
value: message,
callback: function($$v) {
message = $$v;
},
expression: 'message'
}
}),
_v(' '),
_c('p', [_v(_s(message))])
],
1
);
}

很明显,在Childdata增加了model属性,并且会在创建组件构造器时进行处理。在src/core/vdom/create-component.js文件的createComponent函数有下面一段逻辑:

1
2
3
4
// v-model的处理
if (isDef(data.model)) {
transformModel(Ctor.options, data)
}

来看下transformModel的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function transformModel (options, data: any) {
const prop = (options.model && options.model.prop) || 'value'
const event = (options.model && options.model.event) || 'input'
;(data.attrs || (data.attrs = {}))[prop] = data.model.value
const on = data.on || (data.on = {})
const existing = on[event]
const 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
}
}

这个方法向组件虚拟节点data属性增加一个key为prop的属性,并且在on增加事件event,这样就实现了v-model的功能。

总结

那么至此,v-model的实现就分析完了,我们了解到它是 Vue 双向绑定的真正实现,但本质上就是一种语法糖,它即可以支持原生表单元素,也可以支持自定义组件。在组件的实现中,我们是可以配置子组件接收prop名称,以及派发的事件名称。