Appearance
自定义指令:指令的生命周期与 v- 系列指令的实现
引言
指令(Directives)是 Vue 中用于直接操作 DOM 的特殊属性,以 v- 开头。虽然 Vue 的核心是数据驱动视图,但有时我们仍需要直接访问 DOM(例如 v-focus 自动聚焦、v-click-outside 点击外部)。
自定义指令就是 Vue 为这些场景提供的DOM 操作接口。本节将分析一个指令从“编译”到“运行”的完整流程。
指令的“两条执行路径”:编译时 vs 运行时
在 transform(转换)阶段,Vue 编译器会对指令进行分类:
路径 1:编译时指令 (Compile-Time)
这类指令在编译阶段就会被转换为其他形式,最终不会以指令的形式进入运行时。
v-model:transformModel会将其转换为modelValueprop 和onUpdate:modelValueevent(或用于原生元素的vModelText运行时指令)。v-on:transformOn会将其转换为onClick、onFocus等props。v-bind:transformBind会将其转换为props对象。v-slot,v-if,v-for:这些“结构化”指令会重组 AST。
路径 2:运行时指令 (Runtime)
这类指令在编译后依然保留,它们会被“附加”到 VNode 上,并在运行时的特定阶段被执行。
v-showv-focus(自定义)v-click-outside(自定义)
本节我们主要分析“运行时指令”的实现原理。
编译时:“附加”指令 (withDirectives)
codegen(代码生成)阶段负责将“运行时指令”附加到 VNode。当编译器遇到一个带有“运行时指令”(如 v-show)的元素时,transformElement 会将其收集起来。
在 genVNodeCall(VNode 生成函数)中,它会检查是否存在指令:
typescript
// core/packages/compiler-core/src/codegen.ts -> genVNodeCall
function genVNodeCall(node: VNodeCall, context: CodegenContext) {
const { push, helper } = context
const { tag, props, children, patchFlag, directives } = node // 'directives' 被收集
// 【关键】如果 directives 存在
if (directives) {
// 1. 打印 _withDirectives 辅助函数
push(helper(WITH_DIRECTIVES) + `(`)
}
// 2. 打印 VNode
// _createElementVNode("p", ...)
push(helper(CREATE_ELEMENT_VNODE) + `(`, ...)
genNodeList([tag, props, children, patchFlag, ...])
push(`)`)
if (directives) {
// 3. 将指令作为 "withDirectives" 的第二个参数打印
push(`, `)
genNode(directives, context) // 打印 [[_vShow, _ctx.show]]
push(`)`)
}
}编译产物: 一个 <p v-show="show"></p> 最终被“翻译”成:
javascript
// 1. 导入 vShow 指令
import { vShow as _vShow } from 'vue'
function render(_ctx, _cache) {
return (
// 2. 使用 _withDirectives 包裹 VNode
_withDirectives(
_createElementVNode("p", null, "Hello"),
[
// 3. 将指令和值作为数组传递
[_vShow, _ctx.show]
]
)
)
}withDirectives 的职责: withDirectives (runtime-core/src/directives.ts) 是一个运行时辅助函数,它接收 VNode 和指令数组,并将指令数组附加到 VNode 的 dirs 属性上,供 patch 算法后续使用。
typescript
// core/packages/runtime-core/src/directives.ts
export function withDirectives(vnode: VNode, directives: DirectiveArguments): VNode {
// ...
// 将 [[vShow, true], [vFocus]] 规范化后
// 存入 vnode.dirs
vnode.dirs = bindings
return vnode
}运行时:patch 与“指令生命周期”
VNode 现在带着 dirs 属性进入了 patch 阶段。patch 算法在处理 VNode(挂载/更新/卸载)时,会检查 dirs,并在关键时刻调用指令钩子。
ObjectDirective:指令的“生命周期”
一个“运行时指令”是一个包含特定生命周期钩子的对象:
typescript
// core/packages/runtime-core/src/directives.ts
export interface ObjectDirective<T = any, V = any> {
// 【创建阶段】
created?: (el, binding, vnode, prevVNode) => void
beforeMount?: (el, binding, vnode, prevVNode) => void
mounted?: (el, binding, vnode, prevVNode) => void
// 【更新阶段】
beforeUpdate?: (el, binding, vnode, prevVNode) => void
updated?: (el, binding, vnode, prevVNode) => void
// 【卸载阶段】
beforeUnmount?: (el, binding, vnode, prevVNode) => void
unmounted?: (el, binding, vnode, prevVNode) => void
}invokeDirectiveHook:钩子“执行器”
patch 算法通过 invokeDirectiveHook 在组件生命周期的对应阶段来执行指令钩子。
typescript
// core/packages/runtime-core/src/renderer.ts -> patchElement
// (在 patchElement 和 unmount 中)
// 挂载时
if (dirs) {
invokeDirectiveHook(vnode, null, instance, 'created')
// ...
invokeDirectiveHook(vnode, null, instance, 'beforeMount')
}
// ... (DOM 插入) ...
if (dirs) {
invokeDirectiveHook(vnode, null, instance, 'mounted')
}
// 更新时
if (dirs) {
invokeDirectiveHook(vnode, prevVNode, instance, 'beforeUpdate')
}
// ... (patch 子节点) ...
if (dirs) {
invokeDirectiveHook(vnode, prevVNode, instance, 'updated')
}
// 卸载时
if (dirs) {
invokeDirectiveHook(vnode, null, instance, 'beforeUnmount')
}
// ... (DOM 移除) ...
if (dirs) {
invokeDirectiveHook(vnode, null, instance, 'unmounted')
}invokeDirectiveHook 的实现: 这个函数遍历 VNode 上的 dirs 数组,找到每个指令对象上对应名称的钩子(如 'mounted'),并执行它。
typescript
// core/packages/runtime-core/src/directives.ts
export function invokeDirectiveHook(
vnode: VNode,
prevVNode: VNode | null,
instance: ComponentInternalInstance | null,
name: keyof ObjectDirective, // 'created', 'mounted', 'updated'...
): void {
const bindings = vnode.dirs!
const oldBindings = prevVNode && prevVNode.dirs!
for (let i = 0; i < bindings.length; i++) {
const binding = bindings[i]
if (oldBindings) {
// 传递 oldValue,用于 'updated' 钩子
binding.oldValue = oldBindings[i].value
}
// 找到 vShow 对象上的 'mounted' 钩子
let hook = binding.dir[name] as DirectiveHook | undefined
if (hook) {
// 暂停依赖收集(指令钩子不应触发依赖)
pauseTracking()
// 【执行钩子】并处理错误
callWithAsyncErrorHandling(hook, instance, ErrorCodes.DIRECTIVE_HOOK, [
vnode.el, // 1. el
binding, // 2. binding { value, oldValue, arg, modifiers }
vnode,
prevVNode,
])
resetTracking()
}
}
}源码解析:v-show 是如何工作的?
v-show 的源码(runtime-dom/src/directives/vShow.ts)完美地演示了上述生命周期:
typescript
// core/packages/runtime-dom/src/directives/vShow.ts
export const vShow: ObjectDirective<VShowElement> = {
// 【挂载阶段】
beforeMount(el, { value: show }, { transition }) {
// 1. 缓存原始的 display 样式
el[vShowOriginalDisplay] =
el.style.display === 'none' ? '' : el.style.display
// 2. 如果 v-show="true" 且有 <Transition>,
// 调用 transition.beforeEnter (设置 v-enter-from)
if (transition && show) {
transition.beforeEnter(el)
} else {
// 否则,立即设置 display
setDisplay(el, show)
}
},
mounted(el, { value: show }, { transition }) {
// 3. 如果 v-show="true" 且有 <Transition>,
// 调用 transition.enter (触发进入动画)
if (transition && show) {
transition.enter(el)
}
},
// 【更新阶段】
updated(el, { value: show, oldValue }, { transition }) {
// 4. 如果值没变,跳过
if (!show === !oldValue) return
if (transition) {
// 5. 配合 <Transition> 执行进入/离开
if (show) {
transition.beforeEnter(el)
setDisplay(el, true)
transition.enter(el)
} else {
// v-show 的“离开”会等待过渡结束后
// 再设置 display: none
transition.leave(el, () => {
setDisplay(el, false)
})
}
} else {
// 6. 没有 <Transition>,立即切换 display
setDisplay(el, show)
}
},
// 【卸载阶段】
beforeUnmount(el, { value: show }) {
// 7. 卸载前,恢复 display 状态
// (这对于 <KeepAlive> 缓存很重要)
setDisplay(el, show)
},
}
// 核心:切换 display
function setDisplay(el: VShowElement, value: unknown): void {
el.style.display = value ? el[vShowOriginalDisplay] : 'none'
}v-show 总结: v-show 是一个运行时指令。它通过生命周期钩子,在 beforeMount 和 updated 时机去修改 el.style.display。它还集成了 <Transition>,通过调用 transition.enter/leave 来实现平滑的显示/隐藏动画。
总结
Vue 的指令系统有两条清晰的路径:
- 编译时指令(如
v-model):在transform阶段被转换为props/events,指令本身消失。 - 运行时指令(如
v-show):- 编译时:被
withDirectives附加到 VNode 的dirs属性上。 - 运行时:
patch算法在执行组件生命周期(mount/update/unmount)的同时,调用invokeDirectiveHook。 - 执行时:
invokeDirectiveHook遍历dirs,并调用指令对象上对应的钩子(mounted,updated...),将 DOM 元素(el)和绑定值(binding)传递给它,实现 DOM 操作。
- 编译时:被
