highlight: tomorrow-night-eighties
theme: cyanosis
前言
通过这篇文章可以了解如下内容
- 指令的绑定原理
- 表单 v-model 原理
- 组件 v-model 原理
看这篇文章之前,要理清楚 patch 过程和事件机制,可以看下之前写的^_^,文中涉及的所有流程在之前的文章中都很详细的分析过
进入正题前先看下Demo,下面是两个自定义指令,分别绑定在普通标签和组件标签上
```html
```
编译后的代码如下
```javascript 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过程的不同时机执行钩子函数
```javascript 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
数据结构如下
javascript cbs = { create: [], activate: [], ... }
其中就包含指令的钩子函数,它定义在src/core/vdom/modules/directives.js
中,先看下导出
javascript 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
javascript function updateDirectives (oldVnode: VNodeWithData, vnode: VNodeWithData) { if (oldVnode.data.directives || vnode.data.directives) { _update(oldVnode, vnode) } }
对于created
钩子函数来说oldVnode
永远为空,由于div
的VNode的data.directives
有值,执行_update
方法
```javascript 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
将新老节点的指令数组转换成指令对象的形式
```javascript const emptyModifiers = Object.create(null)
function normalizeDirectives ( dirs: ?Array , 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 } ```
将所有指令变为属性名为指令名,属性值为指令内容的对象
javascript 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
,获取到指令对象后,继续执行
```javascript 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
函数
javascript 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
继续执行
javascript 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
方法
```javascript 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不是最新的,所以需要再次执行
javascript // 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
,继续执行
```javascript 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
```html
```
创建阶段
先从创建阶段开始说起,创建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 ```html
编译后的代码
javascript 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
中 javascript 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
钩子函数,代码如下 javascript const isTextInputType = makeMap('text,number,password,search,email,tel,url');
```javascript 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
选中拼音对应汉字时触发
这三个监听回调如下 ```javascript 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源码(七)事件机制](https://juejin.cn/post/6995747891474087967)这篇文章
javascript { 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
```html
编译后的代码
javascript 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 ```javascript export function createComponent ( Ctor: Class
| Function | Object | void, data: ?VNodeData, context: Component, children: ?Array
, tag?: string ): VNode | Array
| 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,但是一些输入类型比如单选框和复选框按钮可能想使用value
prop 来达到不同的目的。使用model
选项可以回避这些情况产生的冲突。
javascript 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时进行的