Vue源码(九)指令原理


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) } }

也就是说cbscreateupdatedestroy数组中都包含指令的钩子函数

先来回顾下createupdate的执行时机

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钩子函数;上面说过,cbsupdate钩子函数会在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

最后,设置invoker.merged,也就是说如果VNode通过mergeVNodeHook方法绑定过钩子函数的话,它的invoker.mergedtrue。将函数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-checkbind钩子函数,并收集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钩子函数、childmounted生命周期函数、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.jsjavascript const directive = { inserted (el, binding, vnode, oldVnode) {}, componentUpdated (el, binding, vnode) {} } export default directive v-model定义了inserted钩子函数和componentUpdated钩子函数,componentUpdated钩子函数只针对于select,就不看了。

指令的绑定和执行流程就是上面说的那样,主要看下v-modelinserted钩子函数干啥了

当整个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标签isTextInputTypetrue,首先将修饰符挂载到el._vModifiers中,如果修饰符中没有lazy,则添加compositionstartcompositionendchange`监听

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`属性的值。

添加compositionstartcompositionend两个事件的目的是当输入拼音时,由于触发compositionstart回调,并设置$event.target.composingtrue,所以不会触发input事件。当选中输入后,执行compositionend回调,将$event.target.composing置为false,并手动触发input事件。

小结

对于input标签的v-model指令,在编译过程中,自动给input标签添加input事件和DOM属性value。如果设置了lazy修饰符,会将input事件改成change事件。然后在执行v-modelinserted钩子函数时,又添加了compositionstartcompositionend两个事件,目的是当输入拼音时,不会触发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过程的不同阶段会执行不同的钩子函数,而指令绑定了createupdatedestroy三个钩子函数

  • 当子元素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-modelinserted钩子函数时,又添加了compositionstartcompositionend两个事件,目的是当输入拼音时,不会触发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时进行的

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值