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> 等价:
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 源码里的逻辑:
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 里,挂载一个元素的顺序是这样的:
- 创建 DOM 元素,挂载子节点之后调用
created。这时元素的属性和事件监听还没设置,对应官方文档说的“在绑定元素的属性或事件监听器被应用之前调用”。 - 设置属性和事件监听,然后调用
beforeMount。 - 把元素插入容器。
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 会暂停依赖追踪,所以在钩子里读取响应式数据不会把它收集成某个副作用的依赖。