Skip to content

自定义指令:指令的生命周期与 v- 系列指令的实现

引言

指令(Directives)是 Vue 中用于直接操作 DOM 的特殊属性,以 v- 开头。虽然 Vue 的核心是数据驱动视图,但有时我们仍需要直接访问 DOM(例如 v-focus 自动聚焦、v-click-outside 点击外部)。

自定义指令就是 Vue 为这些场景提供的DOM 操作接口。本节将分析一个指令从“编译”到“运行”的完整流程。


指令的“两条执行路径”:编译时 vs 运行时

transform(转换)阶段,Vue 编译器会对指令进行分类

路径 1:编译时指令 (Compile-Time)

这类指令在编译阶段就会被转换为其他形式,最终不会以指令的形式进入运行时。

  • v-modeltransformModel 会将其转换为 modelValue prop 和 onUpdate:modelValue event(或用于原生元素的 vModelText 运行时指令)。
  • v-ontransformOn 会将其转换为 onClickonFocusprops
  • v-bindtransformBind 会将其转换为 props 对象。
  • v-slot, v-if, v-for:这些“结构化”指令会重组 AST

路径 2:运行时指令 (Runtime)

这类指令在编译后依然保留,它们会被“附加”到 VNode 上,并在运行时的特定阶段被执行。

  • v-show
  • v-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 是一个运行时指令。它通过生命周期钩子,在 beforeMountupdated 时机去修改 el.style.display。它还集成了 <Transition>,通过调用 transition.enter/leave 来实现平滑的显示/隐藏动画。


总结

Vue 的指令系统有两条清晰的路径:

  1. 编译时指令(如 v-model:在 transform 阶段被转换props/events,指令本身消失
  2. 运行时指令(如 v-show
    • 编译时:被 withDirectives 附加到 VNode 的 dirs 属性上。
    • 运行时patch 算法在执行组件生命周期(mount/update/unmount)的同时,调用 invokeDirectiveHook
    • 执行时invokeDirectiveHook 遍历 dirs,并调用指令对象上对应的钩子(mounted, updated...),将 DOM 元素(el)和绑定值(binding)传递给它,实现 DOM 操作。

微信公众号二维码

Last updated: