Vue源码(九)指令原理

912 阅读3分钟

前言

通过这篇文章可以了解如下内容

  • 指令的绑定原理
  • 表单 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)
  }
}

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

先来回顾下createupdate的执行时机

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

最后,设置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不是最新的,所以需要再次执行

// 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-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

<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-modelinserted钩子函数干啥了

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

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

添加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

<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,但是一些输入类型比如单选框和复选框按钮可能想使用 value prop 来达到不同的目的。使用 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过程的不同阶段会执行不同的钩子函数,而指令绑定了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时进行的