前言
通过这篇文章可以了解如下内容
- 指令的绑定原理
- 表单 v-model 原理
- 组件 v-model 原理
看这篇文章之前,要理清楚 patch 过程和事件机制,可以看下之前写的^_^,文中涉及的所有流程在之前的文章中都很详细的分析过
进入正题前先看下Demo,下面是两个自定义指令,分别绑定在普通标签和组件标签上
<div id="app">
<div v-check="123"></div>
<child v-test="456"></child>
</div>
编译后的代码如下
with (this) {
return _c(
'div',
{ attrs: { id: 'app' } },
[
_c('div', {
directives: [ // 普通标签自定义指令
{
name: 'check',
rawName: 'v-check',
value: 123,
expression: '123',
},
],
}),
_v(' '),
_c('child', {
directives: [ // 组件标签自定义指令
{
name: 'test',
rawName: 'v-test',
value: 456,
expression: '456',
},
],
}),
],
1
)
}
其实不难发现,如果标签绑定了指令,在编译生成的代码中,会添加一个数组属性directives,里面存储的是绑定的指令
执行原理
接下来看下指令的原理,在之前的章节曾介绍过,patch开始前会收集当前平台支持的钩子函数,在patch过程的不同时机执行钩子函数
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
export function createPatchFunction (backend) {
let i, j
const cbs = {}
const { modules, nodeOps } = backend
// 将 modules 中导出的值都放到 cbs 中
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
for (j = 0; j < modules.length; ++j) {
if (isDef(modules[j][hooks[i]])) {
cbs[hooks[i]].push(modules[j][hooks[i]])
}
}
}
// ...
}
将modules中导出的值都放到cbs中,cbs数据结构如下
cbs = {
create: [],
activate: [],
...
}
其中就包含指令的钩子函数,它定义在src/core/vdom/modules/directives.js中,先看下导出
export default {
create: updateDirectives,
update: updateDirectives,
destroy: function unbindDirectives (vnode: VNodeWithData) {
updateDirectives(vnode, emptyNode)
}
}
也就是说cbs的create、update、destroy数组中都包含指令的钩子函数
先来回顾下create、update的执行时机
create:
- 子VNode 创建DOM元素并插入到目标位置后,当前VNode插入目标位置前调用;传入当前VNode
- 子组件 的DOM树创建并插入到目标位置后调用,传入组件占位符VNode
- 更新过程中,当子组件的根元素和老节点的根元素不同时,当子组件更新完成,会更新组件VNode的
elm属性,并调用此钩子函数,将组件VNode传入
update:
patchVnode方法中,会调用update钩子全量更新当前VNode上所有update钩子函数
从create钩子开始看起,当div的子节点创建并插入到目标位置后,会调用指令的created钩子函数,也就是updateDirectives方法,并传入div的VNode
function updateDirectives (oldVnode: VNodeWithData, vnode: VNodeWithData) {
if (oldVnode.data.directives || vnode.data.directives) {
_update(oldVnode, vnode)
}
}
对于created钩子函数来说oldVnode永远为空,由于div的VNode的data.directives有值,执行_update方法
function _update (oldVnode, vnode) {
// 如果 oldVnode 是一个空节点,则说明是首次创建
// 更新阶段也会出现 oldVnode 是空节点的情况,具体参考上面 create 钩子执行时机的第三条
const isCreate = oldVnode === emptyNode
const isDestroy = vnode === emptyNode
// 格式化指令对象,并查找指令的属性值
const oldDirs = normalizeDirectives(oldVnode.data.directives, oldVnode.context)
const newDirs = normalizeDirectives(vnode.data.directives, vnode.context)
// ...
}
根据传入的参数判断当前是创建阶段还是销毁阶段,接着调用normalizeDirectives将新老节点的指令数组转换成指令对象的形式
const emptyModifiers = Object.create(null)
function normalizeDirectives (
dirs: ?Array<VNodeDirective>,
vm: Component
): { [key: string]: VNodeDirective } {
const res = Object.create(null)
if (!dirs) {
return res
}
let i, dir
for (i = 0; i < dirs.length; i++) {
dir = dirs[i]
if (!dir.modifiers) {
dir.modifiers = emptyModifiers
}
res[getRawDirName(dir)] = dir
// 获取 指令的定义
dir.def = resolveAsset(vm.$options, 'directives', dir.name, true)
}
return res
}
将所有指令变为属性名为指令名,属性值为指令内容的对象
res = {
v-check: {
name: 'check', // 不包括 `v-` 前缀
rawName: 'v-check',
value: 123,
expression: '123',
def: {}, // 指令定义
arg: '', 传给指令的参数,例如 `v-check:foo` 中,参数为 "foo"
modifiers: {} // 一个包含修饰符的对象。例如:v-model.sync 中,修饰符对象为 { sync: true }
}
}
回到_update,获取到指令对象后,继续执行
const dirsWithInsert = []
const dirsWithPostpatch = []
let key, oldDir, dir
for (key in newDirs) {
oldDir = oldDirs[key]
dir = newDirs[key]
if (!oldDir) {
// 执行 bind
callHook(dir, 'bind', vnode, oldVnode)
// 如果 定义了 inserted 钩子函数,则将 dir 添加到 dirsWithInsert 中
if (dir.def && dir.def.inserted) {
dirsWithInsert.push(dir)
}
} else {
dir.oldValue = oldDir.value
dir.oldArg = oldDir.arg
// 所在组件的 VNode 更新时调用
callHook(dir, 'update', vnode, oldVnode)
if (dir.def && dir.def.componentUpdated) {
dirsWithPostpatch.push(dir)
}
}
}
// ...
接下来遍历新节点中所有指令,对每个指令执行下面逻辑
- 如果是第一次创建或者老节点中没有指令,则调用
callHook执行指令的bind钩子函数。如果指令定义中有inserted钩子函数,则将指令对象添加到dirsWithInsert中 - 否则,说明是更新过程;给新指令对象添加
oldValue(旧值)和oldArg(旧参数)属性,并执行指令的update钩子函数;上面说过,cbs的update钩子函数会在patchVnode方法中执行,所以指令的update钩子函数发生在其子 VNode 更新之前。如果指令有componentUpdated钩子函数,则将指令对象添加到dirsWithPostpatch中
需要注意的是指令的bind钩子函数执行时机是在子VNode 的 DOM 树创建并挂载到当前VNode的DOM树上之后,但是当前VNode的DOM树还没有挂载,也就是说这个时候可以获取到当前元素的子元素,但是获取不到父元素
接下来看下callHook函数
function callHook (dir, hook, vnode, oldVnode, isDestroy) {
// 根据获取定义中的对应钩子函数
const fn = dir.def && dir.def[hook]
if (fn) {
try {
// 执行钩子函数
/**
* vnode.elm:指令所绑定的元素
* dir:一个对象,参考官网 binding 介绍
* 官网地址:
* https://cn.vuejs.org/v2/guide/custom-directive.html#%E9%92%A9%E5%AD%90%E5%87%BD%E6%95%B0
*/
fn(vnode.elm, dir, vnode, oldVnode, isDestroy)
} catch (e) {}
}
_update继续执行
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()
}
}
dirsWithInsert内存储的是有inserted钩子函数的指令,如果长度不为空,创建一个回调函数callInsert;如果此时是创建阶段(更准确的说法是oldVnode是空VNode),调用mergeVNodeHook,将回调函数callInsert添加到vnode.data.hook.insert中。反之直接调用回调函数callInsert。回调函数callInsert内就是执行当前VNode的所有指令对象的inserted钩子函数。
看下mergeVNodeHook方法
export function mergeVNodeHook (def: Object, hookKey: string, hook: Function) {
if (def instanceof VNode) {
// 组件 VNode 创建的时候就已经绑定了 hook,渲染VNode 是没有的
def = def.data.hook || (def.data.hook = {})
}
let invoker
// 获取已有的 hooks
const oldHook = def[hookKey]
function wrappedHook () {
hook.apply(this, arguments)
remove(invoker.fns, wrappedHook)
}
if (isUndef(oldHook)) {
// 此时是渲染 VNode,并且当前 VNode 中没有绑定 hook
invoker = createFnInvoker([wrappedHook])
} else {
if (isDef(oldHook.fns) && isTrue(oldHook.merged)) {
// 已经绑定 hook,并且是通过 mergeVNodeHook 绑定的 hook
invoker = oldHook
invoker.fns.push(wrappedHook)
} else {
// 此时是组件 VNode,将组件 hook 和指令 key 绑定到一起
invoker = createFnInvoker([oldHook, wrappedHook])
}
}
// 如果通过 mergeVNodeHook 绑定的 hooks,会有一个 merged 属性
invoker.merged = true
def[hookKey] = invoker
}
首先获取或初始化vnode.data.hook对象,因为组件VNode创建时就已经绑定了 hook,渲染VNode是没有的。获取已经存在的insert钩子函数并创建一个回调函数wrappedHook,接下来执行逻辑如下
- 如果VNode上没有
insert钩子函数,说明这个是一个渲染VNode,调用createFnInvoker创建一个invoker函数,并将[wrappedHook]挂载到invoker.fns上 - 反之,说明是组件VNode,也有可能是有
insert钩子函数的渲染/组件VNode;接下来根据已有的钩子函数判断是前面的哪一种;- 如果是组件VNode,则调用
createFnInvoker创建invoker函数,并将[oldHook, wrappedHook]添加到invoker.fns中 - 如果
渲染/组件VNode上有通过mergeVNodeHook绑定的insert钩子函数,将新建的wrappedHook添加到invoker.fns中
- 如果是组件VNode,则调用
最后,设置invoker.merged,也就是说如果VNode通过mergeVNodeHook方法绑定过钩子函数的话,它的invoker.merged为true。将函数invoker添加到vnode.data.hook.insert中
不光自定义指令会通过
mergeVNodeHook给VNode绑定钩子函数,transition组件也会。
接下来先说下后续patch流程,patch过程中,每次创建并将DOM插入到目标位置后,会收集当前VNode的insert钩子函数。当所有DOM挂载完成之后,会统一执行收集到的insert钩子函数,对于指令来说,就是执行wrappedHook函数;执行完成后会删除当前的钩子函数,保证只执行一次;一个原因是当子组件根元素和老节点的不同时,重新给组件VNode绑定指令的insert钩子函数,如果不删除会重复添加;下面会具体说为啥要再次绑定。另一个原因是针对于普通VNode的更新,其实和第一个原因一样,防止重复添加。
在更新过程中也会调用VNode的insert钩子函数,就是在当前组件的所有子节点都更新完成之后,如果根元素和老的根元素不同时,会更新该组件的组件占位符VNode的elm属性,此时会再次调用cbs.create中的钩子函数,并 将组件占位符VNode传入,如果组件占位符VNode有指令并且指令中有inserted钩子函数会再次绑定。并重新执行组件占位符VNode中所有insert钩子函数(注意注释)。这是因为如果指令的inserted钩子函数中有DOM相关操作,更新后这个DOM不是最新的,所以需要再次执行
// issue #6513
const insert = ancestor.data.hook.insert
if (insert.merged) {
// 从 1 开始,因为第一个insert hook 是 mounted
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]()
}
}
回到_update,继续执行
if (dirsWithPostpatch.length) {
mergeVNodeHook(vnode, 'postpatch', () => {
for (let i = 0; i < dirsWithPostpatch.length; i++) {
// 指令所在 VNode 及其子 VNode 全部更新后调用
callHook(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode)
}
})
}
if (!isCreate) {
for (key in oldDirs) {
if (!newDirs[key]) {
callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy)
}
}
}
和inserted一样,如果指令有componentUpdated钩子函数,则将此钩子函数添加到vnode.data.hook.postpatch中。最后如果当前是销毁阶段则调用unbind钩子函数
小结
上面把整个流程拆分了一下,最后再做一下总结。再看下demo
<div id="app">
<div v-check="123"></div>
<child v-test="456"></child>
</div>
创建阶段
先从创建阶段开始说起,创建VNode和组件VNode,并给组件VNode绑定钩子函数。在patch过程中,第一个div的子节点创建DOM并插入到目标位置后,调用cbs.create中所有的钩子函数,并将这个VNode传入;触发指令的create函数,调用v-check的bind钩子函数,并收集inserted钩子函数,将收集到的所有inserted钩子函数添加到vnode.data.hook.insert中。回到patch过程,收集当前VNode的insert钩子函数。
当child组件的渲染VNode的DOM创建完成并插入到目标位置后,会更新child组件的组件VNode的elm属性,再次调用cbs.create中所有的钩子函数,和上面一样执行指令的bind钩子函数,并将inserted钩子函数添加到vnode.data.hook.insert中;然后就是收集组件VNode的insert钩子函数。
当DOM树创建完成并插入到页面后,执行所有收集到的insert钩子函数,其中就包含v-check指令的inserted钩子函数、child的mounted生命周期函数、v-test指令的inserted钩子函数。在指令的inserted钩子函数执行完成之后会删除对应回调,防止再次触发
更新阶段
有两种情况一种是child自身更新,一种是当前组件更新
先看child自身更新,假设child内没有指令,在child更新完成之后,如果child新的根元素和老的根元素不同时,会更新child组件VNode的elm属性,并再次调用cbs.create中的所有钩子函数并将组件VNode传入,触发指令的create函数,将指令inserted钩子函数添加到组件VNode的data.hook.insert中;cbs.create中所有钩子函数执行完后,从1遍历vnode.data.hook.insert数组,触发里面所有函数。从1开始的目的是vnode.data.hook.insert[0]是组件的mounted生命周期函数,为了防止再次调用所以从1开始。如果新的根元素和老的根元素相同则和下面逻辑一致。
如果是当前组件更新,在更新第一个div时,会批量更新属性,也就是调用cbs.update并将新老VNode传入,触发指令的update函数,此时会执行当前VNode上所有指令的update钩子函数,并收集所有的componentUpdated钩子函数,收集完成后,将这些钩子函数添加到vnode.data.hook.postpatch中。当第一个div以及它的子节点都更新完成之后,会执行VNode上所有的postpatch钩子函数。child上面的指令也是如此。也就是说 指令的componentUpdated钩子函数的执行时机是指令所在 VNode 及其子 VNode 全部更新后调用。
v-model
v-model可以绑定在表单元素上,也可以绑定在组件中。分别看下这两种的区别
表单元素 input
这里以input为例,先看下demo
<div class="app">
<input v-model="test" />
</div>
编译后的代码
with (this) {
return _c("div", { attrs: { id: "app" } }, [
_c("input", {
directives: [
{ name: "model", rawName: "v-model", value: test, expression: "test" }
],
domProps: { value: test },
on: {
input: function($event) {
if ($event.target.composing) return;
test = $event.target.value;
}
}
})
]);
}
相对于自定义指令,除了属性中多了一个directives数组之外,还多了一个input事件和DOM属性value
先看下v-model指令的定义,代码在src/platforms/web/runtime/directives/model.js中
const directive = {
inserted (el, binding, vnode, oldVnode) {},
componentUpdated (el, binding, vnode) {}
}
export default directive
v-model定义了inserted钩子函数和componentUpdated钩子函数,componentUpdated钩子函数只针对于select,就不看了。
指令的绑定和执行流程就是上面说的那样,主要看下v-model的inserted钩子函数干啥了
当整个DOM树创建完成并插入到目标位置后,会调用inserted钩子函数,代码如下
const isTextInputType = makeMap('text,number,password,search,email,tel,url');
inserted (el, binding, vnode, oldVnode) {
if (vnode.tag === 'select') {
// ...
} else if (vnode.tag === 'textarea' || isTextInputType(el.type)) {
el._vModifiers = binding.modifiers
if (!binding.modifiers.lazy) {
el.addEventListener('compositionstart', onCompositionStart)
el.addEventListener('compositionend', onCompositionEnd)
// Safari < 10.2 & UIWebView doesn't fire compositionend when
// switching focus before confirming composition choice
// this also fixes the issue where some browsers e.g. iOS Chrome
// fires "change" instead of "input" on autocomplete.
el.addEventListener('change', onCompositionEnd)
}
}
},
对于demo中的input标签isTextInputType为true,首先将修饰符挂载到el._vModifiers中,如果修饰符中没有lazy,则添加compositionstart、compositionend、change监听
compositionstart键盘输入拼音时触发;compositionend选中拼音对应汉字时触发
这三个监听回调如下
function onCompositionStart (e) {
e.target.composing = true
}
function onCompositionEnd (e) {
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)
}
再看下编译后生成的input事件,input事件在创建阶段将其通过el.addEventListener挂载到了DOM上,具体挂载流程可以看下 Vue源码(七)事件机制这篇文章
{
input: function($event) {
if ($event.target.composing) return;
test = $event.target.value;
}
}
当用户输入时,触发input事件,并修改test属性的值。
添加compositionstart、compositionend两个事件的目的是当输入拼音时,由于触发compositionstart回调,并设置$event.target.composing为true,所以不会触发input事件。当选中输入后,执行compositionend回调,将$event.target.composing置为false,并手动触发input事件。
小结
对于input标签的v-model指令,在编译过程中,自动给input标签添加input事件和DOM属性value。如果设置了lazy修饰符,会将input事件改成change事件。然后在执行v-model的inserted钩子函数时,又添加了compositionstart、compositionend两个事件,目的是当输入拼音时,不会触发input事件,在选中中文后触发。
组件v-model
<div class="app">
<child v-model="test" />
</div>
编译后的代码
with (this) {
return _c(
'div',
{ attrs: { id: 'app' } },
[
_c('child', {
model: {
value: title,
callback: function ($$v) {
title = $$v
},
expression: 'title',
},
}),
],
1
)
}
组件上的v-model和表单上的完全不同,组件上没有添加directives数组,但是多了一个model属性
接下来看下原理,在执行render函数时,会调用createComponent去创建组件VNode
export function createComponent (
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>,
tag?: string
): VNode | Array<VNode> | void {
// ...
if (isDef(data.model)) {
transformModel(Ctor.options, data)
}
// ...
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
data, undefined, undefined, undefined, context,
{ Ctor, propsData, listeners, tag, children },
asyncFactory
)
return vnode
}
当data中有model时,会调用transformModel方法处理model属性,在看这个方法之前,看下官网的 model API
允许一个自定义组件在使用
v-model时定制 prop 和 event。默认情况下,一个组件上的v-model会把value用作 prop 且把input用作 event,但是一些输入类型比如单选框和复选框按钮可能想使用valueprop 来达到不同的目的。使用model选项可以回避这些情况产生的冲突。
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
}
}
子组件的value prop 以及派发的 input 事件名是可配的,所以transformModel方法首先获取子组件中定义的prop和事件名,如果没有定义则使用默认值;
接下来将value prop添加到data.attrs中,属性值为父组件响应属性名;将事件名挂载到data.on上并遵循下面逻辑
- 如果
data.on上没有当前名称的自定义事件,则将当前自定义事件挂载到data.on上 - 如果
data.on上有当前名称的自定义事件,并且callback不和现有的事件函数相同,则将data.on[event]变为数组,将callback添加到里面
接下来就是创建实例将data.on中的所有自定义事件挂载到vm._events中,当子组件触发$emit时,执行对应自定义事件
也就是说,对于组件上的v-model其实就是给组件添加props属性和设置自定义事件;这几个步骤是在创建组件VNode时进行的
总结
指令的绑定原理
在patch过程的不同阶段会执行不同的钩子函数,而指令绑定了create、update、destroy三个钩子函数
-
当子元素DOM树创建完成并插入对应位置后(当前元素的DOM还没有插入父元素),调用
create钩子函数;对于指令而言,调用指令的bind钩子函数并收集指令的inserted钩子函数。当页面整个DOM树创建并挂载后,按顺序统一执行收集到的inserted钩子函数 -
在更新阶段 获取VNode子节点前,会对当前VNode 全量执行
update钩子函数;对于指令而言,调用指令 的update钩子函数,并收集componentUpdated钩子函数。当当前VNode及它的子节点都更新完成之后,会执行VNode上所有的postpatch钩子函数,就会调用componentUpdated函数
表单 v-model 原理
对于input标签的v-model指令,在编译过程中,自动给input标签添加input事件和DOM属性value。如果设置了lazy修饰符,会将input事件改成change事件。
然后在执行v-model的inserted钩子函数时,又添加了compositionstart、compositionend两个事件,目的是当输入拼音时,不会触发input事件,在选中中文后手动触发。
组件 v-model 原理
组件标签上的v-model在编译阶段会为组件添加一个model属性,model存储的是v-model的值value和修改这个值的函数callback。在创建组件占位符VNode时,会将model属性中的callback添加到data.on上、value添加到data.attrs中。接下来就是创建实例时将data.on中的所有自定义事件挂载到vm._events中,当子组件触发$emit时,执行对应自定义事件
也就是说,对于组件上的v-model其实就是给组件添加props属性和设置自定义事件;这几个步骤是在创建组件VNode时进行的