指令的职责是,当表达式的值改变时,将其产生的连带影响响应式地作用于DOM。

在模板解析阶段将节点上的指令解析出来并添加到AST的directives属性中,directives数据会传递到VNode中,我们就可以通过vnode.data.directives获取一个节点所绑定的指令。当虚拟DOM进行修补时,会根据节点的对比结果触发一些钩子函数。更新指令的程序会监听create、update和destroy钩子函数,并在这三个钩子函数触发时对VNode和oldVNode进行对比,最终根据对比结果触发指令的钩子函数。(使用自定义指令时,可以监听5种钩子函数:bind、inserted、update、componentUpdated与unbind。)指令的钩子函数被触发后,就说明指令生效了。

# v-if指令的原理概述

模板代码

<li v-if="has">if</li>
<li v-else>else</li>

生成的代码字符串

(has) ? _c('li',[_v("if")]) : _c('li',[_v("else")])

上面的代码会根据has变量的值来选择创建哪个节点

# v-for指令的原理概述

<li v-for="(item, index) in list">v-for {{index}}</li>

生成的代码字符串(格式化后的)

_l((list), function (item, index) {
  return _c('li', [
    _v("v-for " + _s(index))
  ])
})

_l是函数renderList的别名。当执行这段代码字符串时,_l函数会循环变量list并依次调用第二个参数所传递的函数。同时,会传递两个参数:item和index。此外,当 _c函数被调用时,会执行 _v函数创建一个文本节点。

# v-on指令

v-on指令的作用是绑定事件监听器。这里我们介绍v-on用在普通元素上,内部如何监听DOM事件。

从模板解析到生成VNode,最终事件会被保存在VNode中,然后可以通过vnode.data.on得到一个节点注册的所有事件。

<button v-on:click="doThat">我是按钮</button>

通过vnode.data.on读出的事件对象

{
  click: function () {}
}

事件绑定的相关逻辑分别设置了create和update钩子函数,当一个DOM元素被创建或更新时,都会触发事件绑定逻辑的处理。

事件绑定相关的处理逻辑是updateDOMListeners函数:

let target
function updateDOMListeners (oldVnode, vnode) {
  // 两个VNode中的事件对象都不存在,说明上一次没有绑定任何事件,这一次元素更新也没有新增事件绑定,因此并不需要进行事件的绑定与解绑,直接使用return语句终止函数
  if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
    return
  }
  // 新虚拟节点上的事件对象
  const on = vnode.data.on || {}
  // 旧虚拟节点上的事件对象
  const oldOn = oldVnode.data.on || {}
  // vnode.elm保存vnode所对应的DOM元素
  target = vnode.elm
  // 对特殊情况下的事件对象做一些特殊处理
  normalizeEvents(on)
  // 更新事件监听器,对比on与oldOn,然后根据对比结果调用add方法或remove方法执行对应的绑定事件或解绑事件等
  updateListeners(on, oldOn, add, remove, vnode.context)
  target = undefined
}

add和remove方法是如何绑定与解绑DOM原生事件的呢

function add (event, handler, once, capture, passive) {
  // withMacroTask函数的作用是给回调函数做一层包装,当事件触发时,如果因为回调中修改了数据而触发更新DOM的操作,那么该更新操作会被推送到宏任务(macrotask)的任务队列中
  handler = withMacroTask(handler)

  // 如果v-on使用了once修饰符,那么会使用高阶函数createOnceHandler实现once的功能
  if (once) handler = createOnceHandler(handler, event, capture)

  // 将指定的监听器注册到target上,而target就是使用了v-on的DOM元素
  target.addEventListener(
    event,
    handler,
    supportsPassive
      ? { capture, passive }
      : capture
  )
}

// 解绑事件
function remove (event, handler, capture, _target) {
  (_target || target).removeEventListener(
    event,
    // handler._withTask存在,则解绑handler. _withTask。这是因为在绑定事件时经过了withMacroTask的处理,最终被绑定的事件监听器其实是handler._withTask,所以解绑时也需要解绑handler._withTask,只有handler. _withTask不存在时才解绑handler
    handler._withTask || handler,
    capture
  )
}

function createOnceHandler (handler, event, capture) {
  const _target = target // 在闭包中保存当前目标元素
  return function onceHandler () {
    const res = handler.apply(null, arguments)

    // 函数的返回值不是null的时候解绑
    if (res !== null) {
      // 执行remove函数来解绑事件,使事件只能被执行一次
      remove(event, onceHandler, capture, _target)
    }
  }
}

v-on指令实现的简单过程: v-on指令的简单过程

# 自定义指令的内部原理

虚拟DOM在渲染时,除了更新DOM内容外,还会触发钩子函数。事件、指令、属性等相关处理逻辑只需要监听钩子函数,在钩子函数触发时执行相关处理逻辑即可实现功能。

指令的处理逻辑分别监听了create、update与destroy

export default {
  create: updateDirectives,
  update: updateDirectives,
  destroy: function unbindDirectives (vnode) {
    updateDirectives(vnode, emptyNode)
  }
}

虚拟DOM在触发钩子函数时,上面代码中对应的函数会被执行且都会执行updateDirectives方法。

function updateDirectives (oldVnode, vnode) {
  // 只要其中有一个虚拟节点存在directives,那么就执行_update函数处理指令,在模板解析时,directives会从模板的属性中解析出来并最终设置到VNode中。
  if (oldVnode.data.directives || vnode.data.directives) {
    _update(oldVnode, vnode)
  }
}

function _update (oldVnode, vnode) {
  // 判断虚拟节点是否是一个新创建的节点
  const isCreate = oldVnode === emptyNode
  // 当新虚拟节点不存在而旧虚拟节点存在时为真
  const isDestroy = vnode === emptyNode

  // 旧的指令集合,指oldVnode中保存的指令
  // normalizeDirectives函数将模板中使用的指令从用户注册的自定义指令集合中取出来
  const oldDirs = normalizeDirectives(oldVnode.data.directives, oldVnode.context)
  // 新的指令集合,指vnode中保存的指令
  const newDirs = normalizeDirectives(vnode.data.directives, vnode.context)

  // 保存需要触发inserted指令钩子函数的指令列表
  const dirsWithInsert = []
  // 保存需要触发componentUpdated钩子函数的指令列表
  const dirsWithPostpatch = []

  let key, oldDir, dir

  // 对比两个指令集合并触发对应的指令钩子函数
  for (key in newDirs) {
    // 从oldDirs取出指令保存到oldDir中
    oldDir = oldDirs[key]
    // 从newDirs取出指令保存到dir中
    dir = newDirs[key]

    // oldDir如果不存在,说明当前循环到的指令是首次绑定到元素
    if (!oldDir) {
      // 新指令,触发bind,callHook的作用是找出指令中对应钩子函数名称的方法,如果该方法存在,则执行它
      callHook(dir, 'bind', vnode, oldVnode)

      // 如果该指令在注册时设置了inserted方法,那么将指令添加到dirsWithInsert中,可以保证执行完所有指令的bind方法后再执行指令的inserted方法
      if (dir.def && dir.def.inserted) {
        dirsWithInsert.push(dir)
      }
    } else { // oldDir存在说明指令之前绑定过了,这次的操作是更新指令
      // 在dir上添加oldValue属性并在其中保存上一次指令的value属性值
      dir.oldValue = oldDir.value

      callHook(dir, 'update', vnode, oldVnode)

      // 判断注册自定义指令时,该指令是否设置了componentUpdated方法。如果设置了,则将该指令添加到dirsWithPostpatch列表中。这样做的目的是让指令所在组件的VNode及其子VNode全部更新后,再调用指令的componentUpdated方法。
      if (dir.def && dir.def.componentUpdated) {
        dirsWithPostpatch.push(dir)
      }
    }
  }
  // 判断dirsWithInsert列表中是否有元素
  if (dirsWithInsert.length) {
    const callInsert = () => {
      // 则循环dirsWithInsert依次调用callHook执行每一个指令的inserted钩子函数
      for (let i = 0; i < dirsWithInsert.length; i++) {
        callHook(dirsWithInsert[i], 'inserted', vnode, oldVnode)
      }
    }

    // 判断虚拟节点是否为一个新创建的节点,如果是,等到元素被插入到父节点之后再执行指令的inserted方法。
    if (isCreate) {
      // mergeVNodeHook可以将一个钩子函数与虚拟节点现有的钩子函数合并在一起,这样当虚拟DOM触发钩子函数时,新增的钩子函数也会被执行。这里将callInsert添加到虚拟节点的insert钩子函数列表中,这样可以将钩子函数的执行推迟到被绑定的元素插入到父节点之后进行。
      mergeVNodeHook(vnode, 'insert', callInsert)
    } else {
      // 如果isCreate不为真,那么不需要将执行指令的操作推迟到元素被插入到父节点之后,直接执行callInsert执行指令的inserted方法即可。
      // 这个函数执行时,才会循环dirsWithInsert依次调用每一个指令的inserted钩子函数,这样做其实是为了让指令的inserted方法在被绑定元素插入到父节点后再调用。
      callInsert()
    }
  }

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

  // 判断当前虚拟节点是否为新创建的
  if (!isCreate) {
    // 循环旧的指令列表oldDirs
    for (key in oldDirs) {
      // 查看它在newDirs中是否存在
      if (!newDirs[key]) {
        // 不存在则说明这个指令在旧虚拟节点的指令列表中存在,但在新虚拟节点的指令列表中不存在,此时调用callHook执行指令的unbind方法即可
        callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy)
      }
    }
  }
}


/* 
dir:指令对象。
hook:将要触发的钩子函数名。
vnode:新虚拟节点。
oldVnode:旧虚拟节点。
isDestroy:当新虚拟节点不存在而旧虚拟节点存在时为真。
*/
function callHook (dir, hook, vnode, oldVnode, isDestroy) {
  // 取出对应的钩子函数
  const fn = dir.def && dir.def[hook]
  // 判断钩子函数是否存在
  if (fn) {
    // 使用try...catch语句捕获钩子函数在执行时可能会抛出的错误
    try {
      // 
      fn(vnode.elm, dir, vnode, oldVnode, isDestroy)
    } catch (e) {
      // 处理错误逻辑
      handleError(e, vnode.context, `directive ${dir.name} ${hook} hook`)
    }
  }
}

这里说下_update函数中的normalizeDirectives函数将模板中使用的指令从用户注册的自定义指令集合中取出来

{
  v-focus: {
    def: {inserted: ?},
    modifiers: {},
    name: "focus",
    rawName: "v-focus"
  }
}

自定义指令的代码为

Vue.directive('focus', {
  inserted: function (el) {
    el.focus()
  }
})

自定义指令简要过程:

# 虚拟DOM钩子函数

虚拟DOM在渲染时会触发的所有钩子函数及其触发时机

名称 触发时机 回调参数
init 已添加vnode,在修补期间发现新的虚拟节点时被触发 vnode
create 已经基于VNode创建了DOM元素 emptyNode和vnode
activate keepAlive组件被创建 emptyNode和innerNode
insert 一旦vnode对应的DOM元素被插入到视图中并且修补周期的其余部分已经完成,就会触发 vnode
prepatch 一个元素即将被修补 oldVnode和vnode
update 一个元素正在被更新 oldVnode和vnode
postpatch 一个元素已经被修补 oldVnode和vnode
destroy 它的DOM元素从DOM中移除时或者它的父元素从DOM中移除时触发 vnode
remove vnode对应的DOM元素从DOM中被移除时触发此钩子函数。需要说明的是,只有一个元素从父元素中被移除时会触发,但是如果它是被移除的元素的子项,则不会触发 vnode和removeCallback