前端知库

Vue 自定义指令是怎样被调用的?钩子在什么时机执行?

模板里的 v-xxx 会被编译成 withDirectives(vnode, [[指令, 值, 参数, 修饰符]]),把指令信息挂到 vnode 的 dirs 上。渲染器在创建、插入、更新、卸载这个元素的固定位置调用对应钩子,钩子拿到 el、binding、vnode、prevVnode。函数简写会同时用作 mounted 和 updated。

参考答案

简答

比如写了 <div v-pin:top.animate="200">。模板编译以后,这个 div 的 vnode 会被 withDirectives 包一层,参数是 [[pin, 200, 'top', { animate: true }]],也就是指令对象、绑定值、参数、修饰符。withDirectives 把这些信息整理成 binding 对象,存进 vnode 的 dirs 数组。

之后渲染器处理这个元素时,会在固定的位置检查 dirs,调用对应的钩子:元素刚创建、属性还没设置时调 created;插入页面之前调 beforeMount;父组件和所有子组件都挂载完以后调 mounted。更新时先调 beforeUpdate,更新完调 updated;卸载前后分别调 beforeUnmount 和 unmounted。

每个钩子拿到 el、binding、vnode,更新的两个钩子还多一个 prevVnode。binding 里有 value、oldValue、arg、modifiers,还有所在组件实例 instance 和指令对象本身 dir。oldValue 只在 beforeUpdate 和 updated 里有意义。

要注意,指令写成一个函数时,Vue 会把它同时当作 mounted 和 updated,其他钩子都不会调用。除了 el,其他参数都应该当作只读;要在钩子之间共享数据,官方建议存在元素的 dataset 上。

模板里的指令被编译成了什么

先看官方文档给的渲染函数写法,它和模板 <div v-pin:top.animate="200"></div> 等价:

JavaScript
import { h, withDirectives } from 'vue';

const pin = {
  mounted() {},
  updated() {},
};

const vnode = withDirectives(h('div'), [[pin, 200, 'top', { animate: true }]]);

withDirectives 的第二个参数是一个数组,每个指令也是一个数组,格式是 [指令, 值, 参数, 修饰符],后面不需要的项可以省略。模板编译器做的事情就是把 v-pin:top.animate="200" 拆成这四项。指令如果是按名字全局注册的,渲染函数里拿不到对象,就用 resolveDirective('pin') 按名字查出来。

那 withDirectives 拿到这些以后做了什么?它并不调用任何钩子,只是往 vnode 上记一笔。下面用简化的代码模拟 vuejs/core 源码里的逻辑:

JavaScript
function withDirectives(vnode, directives) {
  const bindings = vnode.dirs || (vnode.dirs = []);
  for (const [raw, value, arg, modifiers = {}] of directives) {
    // 函数简写被展开成同时拥有 mounted 和 updated 的对象
    const dir = typeof raw === 'function' ? { mounted: raw, updated: raw } : raw;
    bindings.push({ dir, value, oldValue: undefined, arg, modifiers });
  }
  return vnode;
}

const color = (el, binding) => {
  el.style.color = binding.value;
};
const pin = { mounted() {} };
const vnode = withDirectives({ type: 'div' }, [
  [pin, 200, 'top', { animate: true }],
  [color, 'red'],
]);

const [first, second] = vnode.dirs;
console.log(vnode.dirs.length); // 2
console.log(first.arg + ' ' + first.modifiers.animate); // top true
console.log(Object.keys(second.dir).join(',')); // mounted,updated
console.log(second.dir.mounted === second.dir.updated); // true

第三行说明了函数简写的真实含义:源码里遇到函数,就直接包成 { mounted: dir, updated: dir }。所以函数简写的指令只会在挂载和更新时执行,unmounted 不会被调用,需要在卸载时清理的指令必须写成对象。

真实源码里还会记录 instance(当前组件的公开实例),并且在指令对象设置了 deep: true 时深度遍历绑定值,让对象内部的变化也能触发更新。另外,withDirectives 只能在渲染函数里调用,否则开发模式下会警告并原样返回 vnode。

钩子分别在什么时候被调用

记在 dirs 上的指令,由渲染器在处理元素的过程中调用。vuejs/core 的 renderer.ts 里,挂载一个元素的顺序是这样的:

  1. 创建 DOM 元素,挂载子节点之后调用 created。这时元素的属性和事件监听还没设置,对应官方文档说的“在绑定元素的属性或事件监听器被应用之前调用”。
  2. 设置属性和事件监听,然后调用 beforeMount。
  3. 把元素插入容器。
  4. mounted 不是马上调用,而是放进渲染完成后的回调队列,等这一轮渲染结束才执行。所以官方文档说它在“绑定元素的父组件及它自己的所有子节点都挂载完成后调用”。

更新时,beforeUpdate 在比对新旧 vnode 的属性和子节点之前调用,updated 同样放进渲染完成后的队列。卸载时,beforeUnmount 在移除元素之前调用,unmounted 放进渲染完成后的队列,在元素移除之后执行。

为什么 mounted 要延后?因为在它执行时,整棵子树都已经在页面上了,这时读取尺寸、调用 focus()、计算位置才可靠。beforeMount 时元素还没插入页面,这些操作拿不到正确结果。

oldValue 是怎么来的

每次更新,组件重新渲染得到新的 vnode,新 vnode 上的 dirs 也是 withDirectives 重新生成的,里面 oldValue 一开始都是 undefined。渲染器调用 beforeUpdate、updated 时,会按下标从旧 vnode 的 dirs 里取出上一次的 value,填进新 binding 的 oldValue。

这解释了官方文档里的两句话:oldValue 只在 beforeUpdate 和 updated 中可用,而且不论值有没有变都会有。挂载阶段没有旧 vnode,自然没有旧值。父组件每次重新渲染都会触发这两个钩子,所以钩子里最好先比较 binding.value 和 binding.oldValue,没变就直接返回,省掉不必要的 DOM 操作。

为什么 binding 不能改

binding 和 vnode 在每次渲染时都会重新生成,在钩子里改它们,下次渲染就被覆盖了,而且 binding 还会被渲染器用来算下一次的 oldValue。所以官方要求除了 el,其他参数都当作只读。

需要在 mounted 和 unmounted 之间传东西时,比如事件处理函数,就把它挂在元素上。官方建议用 dataset。dataset 只能存字符串,函数引用存不进去,《Vue 自定义指令适合什么场景?》里的点击外部关闭就把处理函数挂在 el 的一个自定义属性上,卸载时取出来移除。

常见追问

指令用在组件上会怎样?

官方不推荐这么用。指令会作用在组件的根元素上;组件有多个根节点时,指令会被忽略并抛出警告。和普通属性不同,指令也不能通过 v-bind="$attrs" 转交给组件内部的其他元素。

同一个元素上有多个指令,钩子按什么顺序调用?

按 dirs 数组的顺序,也就是 withDirectives 收到的指令顺序。渲染器调用某个钩子时,会遍历 dirs,对每个指令依次调用同名钩子。

指令里能用响应式数据吗?钩子会不会被意外追踪?

绑定值本身是在渲染函数里求值的,它变了会触发组件重新渲染,进而调用 beforeUpdate 和 updated。钩子执行时,vuejs/core 会暂停依赖追踪,所以在钩子里读取响应式数据不会把它收集成某个副作用的依赖。