Vue 3 设计与实现

第 13 章 指令系统

作者 杨艺韬 · 11,130 字

第 13 章 指令系统

本章要点

  • 指令的本质:将 DOM 操作逻辑封装为可复用的声明式抽象
  • 内置指令的实现:v-model、v-show、v-if、v-for、v-on、v-bind 的编译与运行时协作
  • 自定义指令的完整生命周期:created → beforeMount → mounted → beforeUpdate → updated → beforeUnmount → unmounted
  • 指令的编译时转换:编译器如何将指令语法转换为 withDirectives 调用
  • withDirectives 的运行时机制:指令绑定的创建、更新与销毁
  • v-model 的双向绑定在不同元素类型上的差异化实现
  • 指令与组件的交互:组件上使用指令的限制与解决方案

指令是 Vue 模板语法中最具”魔法感”的部分。当你写下 v-model="name" 时,输入框自动与变量双向绑定;写下 v-show="visible" 时,元素的显隐自动切换。这些”魔法”的背后是编译器和运行时的精密协作——本章要做的,就是把这些”魔法”全部解构成可理解的机制。看完这一章,你对”模板上每一个 v-XX 在 JS 层面到底发生了什么”会有完整的心智模型。

在前面的章节中,我们已经了解了编译器如何处理模板、运行时如何创建和更新 VNode。本章将聚焦于连接这两者的关键机制——指令系统。

指令是”DOM 操作的声明式语法糖”

要理解指令为什么存在,先得回到一个更根本的问题:声明式框架里,遇到”纯 DOM 操作”怎么办?

Vue 的设计理念是”数据驱动”——你改状态,UI 自动变。但真实世界里有些操作 天生就是命令式的、和状态无关——自动聚焦、滚动到某位置、触发第三方 jQuery 插件、控制 <video> 的 play/pause。如果强行把这些事塞进响应式状态里(比如”加一个 shouldFocus 状态,watch 它切换焦点”),会很别扭:状态和 DOM 行为的边界被糊成一团,代码丑、测试难。

指令解决的就是这个问题——把”命令式 DOM 操作”包装成声明式语法。你写 v-focus 的时候是声明式语法(模板里平平无奇的一个属性),但运行时 Vue 会在恰当的生命周期点调用你的 mounted(el) 让你操作 DOM。本质上是把你的命令式代码塞进框架管理的时机里——你不需要自己监听挂载时机、不需要自己 cleanup,框架替你管。

这种”把特定场景的命令式代码抽象成声明式能力”在各种框架里都能找到对应物——React 的 useEffect + useRef 组合可以实现类似效果;Svelte 有 action 指令;Solid 有 use: 前缀语法。都是解决同一个问题让命令式 DOM 操作在声明式组件里有体面的表达方式。Vue 的指令语法最接近”人类直觉”——直接挂在元素上,读起来像 HTML 属性;这是 Vue 在模板可读性这件事上的深耕。

本章的阅读路径

本章从两个视角展开:

  1. 编译器视角(13.1-13.5):编译器是怎么把模板里的指令语法翻译成 JavaScript 调用的。每一条指令在生成代码里长什么样,差异在哪里。
  2. 运行时视角(13.2 的 withDirectives、13.7 的自定义指令):指令绑定怎么执行、生命周期钩子什么时候触发、自定义指令如何接入到这套机制里。

贯穿两个视角的一个核心函数是 withDirectives——所有指令最终都通过它挂到 VNode 上,然后运行时 patch 阶段触发对应的钩子。理解了这个函数,你就理解了 Vue 指令系统的”总线”。

丛书关联:本章和丛书卷《Vue 3 设计与实现》第 11 章(VDOM)在一个数据结构上交汇——VNode 的 dirs 字段。指令最终的落脚点就是 VNode 上这个数组;理解了这一点,你会看到指令系统不是独立的子系统、而是VNode 运行时的一个增强维度。本章结束后若想进一步对照”React 如何用 ref + useEffect 表达类似能力”,可以回到丛书卷《React 19 源码解读》的 Hook 章节——两个框架用不同的语法表达同一类逻辑,对比读能把”如何在声明式系统里表达命令式需求”这一主题看得更通透。

13.1 指令的分类与编译

要搞清楚指令系统,最好的起点是区分”哪些在编译期生效、哪些在运行时生效”。这个分类不是学术的,而是直接影响你作为开发者能做什么——能否自定义、自定义时能控制什么时机点、能否和框架内置指令并列。

编译时指令 vs 运行时指令

Vue 的指令按”它主要在哪个阶段起作用”可以分两类:

  • 编译时指令v-if / v-for / v-else / v-slot —— 这些指令不会留到运行时。编译器看到它们就会直接改变生成的 render 代码v-if 变成 ? ... : ... 三元、v-for 变成 renderList(...) 调用。运行时层面根本见不到”v-if 指令对象”。
  • 运行时指令v-model / v-show / v-on / v-bind / 自定义指令 —— 这些会在生成代码里留下 withDirectives 或对应的 helper 调用,由运行时负责在 VNode mount/update 时执行。

这个分类的价值是:让你明白”为什么有些指令不能自定义”。你可以写 v-focus / v-tooltip,但没法写 v-my-if——因为 v-if 需要编译器特殊支持才能切换整棵 VNode 子树,运行时 hook 这个时机已经太晚了。

这种”编译时 vs 运行时”的职责划分,和之前讲的模板受限 DSL、编译器能静态分析一脉相承——Vue 有选择地把能静态决定的事情全部推到编译期、把真正动态的事情留到运行时。一个好的框架不是”全都在运行时”或”全都在编译时”,而是在每个具体点上做正确的选择

Vue 的指令分为两大类:

  1. 编译时指令v-ifv-elsev-forv-slot——它们在编译阶段被转换为完全不同的代码结构,运行时不存在”指令”的概念
  2. 运行时指令v-modelv-showv-onv-bind、自定义指令——它们在运行时通过 withDirectives 注册生命周期钩子
graph TD
    A[模板指令] --> B{编译时 or 运行时?}
    B -->|编译时| C[v-if / v-else / v-for / v-slot]
    B -->|运行时| D[v-model / v-show / v-on / v-bind / 自定义]
    C --> E[转换为条件/循环/插槽代码结构]
    D --> F[生成 withDirectives 调用]
    F --> G[运行时注册钩子 → 操作 DOM]

v-if 的编译转换

v-if 在 runtime 上不存在——它是一个纯编译时构造。编译器看到 v-if="cond" 时会生成 cond ? _createVNode('div', ...) : _createCommentVNode('v-if') 这样的三元表达式。

当 cond 从 false 变 true 时,左右两个表达式产生不同类型的 VNode——div vs Comment,diff 算法检测到类型变了、直接卸载 Comment 换上 div。这条路径复用了 VNode diff 的标准能力,没有专门的”v-if 切换”逻辑。

placeholder Comment 这个细节值得一提:为什么不生成 null 而生成一个注释节点?答案是占位——如果生成 null,该位置就在 VNode 列表里消失,和后续兄弟节点的位置关系会变;用 Comment 保留”有一个节点在这里”的语义,后续兄弟的位置稳定,diff 逻辑简化。这是”用一个廉价的占位维持结构稳定性”的典型技巧。

v-if 在编译阶段被完全消解:

<template>
  <div v-if="show">Hello</div>
  <div v-else>Bye</div>
</template>

编译为:

function render(_ctx) {
  return _ctx.show
    ? (_openBlock(), _createElementBlock("div", { key: 0 }, "Hello"))
    : (_openBlock(), _createElementBlock("div", { key: 1 }, "Bye"))
}

变成了一个简单的三元表达式。注意 key: 0key: 1——编译器自动为不同的分支添加不同的 key,确保 Diff 算法能正确识别它们是不同的节点。

v-for 的编译转换

v-for 编译成 renderList(source, (item, index) => createVNode(...)) 调用——运行时对 source 遍历、每个元素生成一个 VNode。renderList 这个 helper 对 Array、Object、Number、Iterable 等多种 source 类型做了统一处理。

值得注意:v-for 生成的 VNode 列表会被标记为一个 Block——它的子节点集合是”动态的”(source 长度可能变),所以需要完整的 diff。Block Tree 的嵌套边界之一就是”v-for 开始一个新 Block”——这也是第 11 章讲过的”v-if / v-for 为什么要开新 Block”的源头。

编译期把这条路径固化下来后,运行时的 v-for 非常轻——它不再是”指令”而是”普通的 renderList 函数调用”,没有额外抽象。这正是 Vue 对”能编译掉的指令就编译掉,不给运行时负担”原则的严格执行。

<template>
  <div v-for="item in items" :key="item.id">{{ item.name }}</div>
</template>

编译为:

function render(_ctx) {
  return (_openBlock(true), _createElementBlock(
    _Fragment, null,
    _renderList(_ctx.items, (item) => {
      return (_openBlock(), _createElementBlock("div", { key: item.id },
        _toDisplayString(item.name), 1 /* TEXT */))
    }),
    128 /* KEYED_FRAGMENT */
  ))
}

v-for 被转换为 _renderList 调用(本质是 Array.map),每个迭代项生成一个独立的 Block。外层包裹一个 Fragment,并标记 KEYED_FRAGMENT PatchFlag。

openBlock(true) 中的 true 参数表示禁用 Block 追踪——因为 v-for 的子节点数量是动态的,不能用固定的 dynamicChildren 来优化,必须走完整的 keyed Diff。

13.2 withDirectives:运行时指令的核心

withDirectives 是所有运行时指令汇聚的 API。每一个 <div v-custom> 在编译结果里都会变成 withDirectives(h('div', ...), [[vCustom]])——一个高阶函数,把指令绑定数组附着到 VNode 上。

读这一节最大的价值不在记住”withDirectives 有几个参数、返回什么”——而在看清楚一个扩展点(指令)如何干净地嵌入到一个已经存在的抽象(VNode + patch)里,不搞乱原有结构

这个函数的设计有个值得注意的地方:它返回同一个 VNode,只是把 dirs 字段填上了。这意味着指令系统完全不改变 VNode 的类型、不引入特殊 VNode 种类——它只是给普通 VNode 加一层”附加信息”。运行时 patch 阶段发现 vnode.dirs 有内容,就在合适的生命周期点触发这些钩子。

这种”用数据附着而非类型扩展”的设计比”为每种指令定义一种特殊 VNode”要好得多——VNode 的核心逻辑保持纯净、指令像插件一样可插拔。丛书卷《React 19 源码解读》讲 React 的 ref 机制时也提到过同样的模式——ref 不是特殊的 fiber、只是 fiber 上的一个字段,在 commit 阶段被单独处理。框架设计中”保持核心小、用附加字段扩展”是反复出现的最佳实践

读这个函数有一个微妙的小惊喜:Vue 没有为指令系统发明全新抽象,只是复用了 VNode 已有的 dirs 字段、复用了 patch 阶段的生命周期钩子调用点、复用了 invokeDirectiveHook 这样的内部 helper。整个指令系统的”新代码”非常少——大部分逻辑已经在 VNode / patch 里就位,指令只是”在已有时机点插入用户定义的函数”。一个精心设计的内核能以最少的增量支持不断扩展的功能,这就是”把抽象做对”的价值。

运行时指令通过 withDirectives 函数附加到 VNode 上:

// 编译器输出示例
_withDirectives(
  _createElementVNode("input", {
    "onUpdate:modelValue": $event => (_ctx.name = $event)
  }, null, 8, ["onUpdate:modelValue"]),
  [
    [_vModelText, _ctx.name]
  ]
)

withDirectives 的实现:

// packages/runtime-core/src/directives.ts
export function withDirectives<T extends VNode>(
  vnode: T,
  directives: DirectiveArguments
): T {
  if (currentRenderingInstance === null) {
    warn(`withDirectives can only be used inside render functions.`)
    return vnode
  }

  const instance = getExposeProxy(currentRenderingInstance) || currentRenderingInstance.proxy
  const bindings: DirectiveBinding[] = vnode.dirs || (vnode.dirs = [])

  for (let i = 0; i < directives.length; i++) {
    let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]

    // 函数简写:将函数同时注册为 mounted 和 updated 钩子
    if (isFunction(dir)) {
      dir = {
        mounted: dir,
        updated: dir
      } as ObjectDirective
    }

    // 如果指令有 deep 选项,创建深度响应式 watch
    if (dir.deep) {
      traverse(value)
    }

    bindings.push({
      dir,
      instance,
      value,
      oldValue: void 0,
      arg,
      modifiers
    })
  }

  return vnode
}

withDirectives 不做任何 DOM 操作——它只是将指令信息挂到 VNode 的 dirs 属性上。真正的操作在 patch 阶段触发。

指令钩子的调用时机

指令有 7 个生命周期钩子:created / beforeMount / mounted / beforeUpdate / updated / beforeUnmount / unmounted。每一个都对应 Vue 组件渲染流程的一个精确时机点。这 7 个钩子不是”历史堆积”的产物,是一套精心设计的时机网——每个位置都对应一个真实用户需求

这 7 个钩子的选择不是”越多越好”,而是每一个都对应一个合理的使用场景

  • created:元素的 VNode 已创建、但还没挂到 DOM。适合做 VNode 级别的初始化(注册全局引用)
  • beforeMount:DOM 已创建、还没插到父容器。适合做 DOM 初始化(读取尺寸、计算样式)但不能操作父容器关系
  • mounted:DOM 已在文档里、所有初始化完成。最常用的钩子——绝大多数自定义指令的核心逻辑放这里
  • beforeUpdate / updated:响应式数据变化引起 re-render 时触发
  • beforeUnmount / unmounted:元素即将移除时触发。用于 cleanup(移除事件监听、销毁第三方库实例)

这个 7 钩子的划分和 Vue 的组件生命周期钩子形状完全一致——指令和组件共享同一套时机模型。这不是巧合,而是 Vue 让开发者只需要学一套时机模型、两个不同抽象复用的设计。丛书卷《Vue 3 设计与实现》第 12 章讲组件调度器时深入讨论过这些时机点在 scheduler 里的具体触发顺序——跨章阅读能拿到立体的理解。

// packages/runtime-core/src/directives.ts
export function invokeDirectiveHook(
  vnode: VNode,
  prevVNode: VNode | null,
  instance: ComponentInternalInstance | null,
  name: keyof ObjectDirective
) {
  const bindings = vnode.dirs!
  const oldBindings = prevVNode && prevVNode.dirs!

  for (let i = 0; i < bindings.length; i++) {
    const binding = bindings[i]
    if (oldBindings) {
      binding.oldValue = oldBindings[i].value
    }
    let hook = binding.dir[name] as DirectiveHook | DirectiveHook[] | undefined

    if (hook) {
      pauseTracking()
      callWithAsyncErrorHandling(hook, instance, ErrorCodes.DIRECTIVE_HOOK, [
        vnode.el,      // 真实 DOM 元素
        binding,       // 指令绑定对象
        vnode,         // 当前 VNode
        prevVNode      // 旧 VNode
      ])
      resetTracking()
    }
  }
}

mountElementpatchElement 中调用:

// packages/runtime-core/src/renderer.ts(简化)

// 挂载元素时
const mountElement = (vnode, container, anchor, ...) => {
  // 创建 DOM
  el = vnode.el = hostCreateElement(vnode.type)

  // 设置属性
  if (props) { /* ... */ }

  // 指令 created 钩子
  if (dirs) {
    invokeDirectiveHook(vnode, null, parentComponent, 'created')
  }

  // 挂载子节点
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    hostSetElementText(el, vnode.children as string)
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    mountChildren(vnode.children, el, null, ...)
  }

  // 指令 beforeMount 钩子
  if (dirs) {
    invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
  }

  // 插入 DOM
  hostInsert(el, container, anchor)

  // 指令 mounted 钩子(Post 队列)
  if (dirs) {
    queuePostRenderEffect(() => {
      invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
    }, parentSuspense)
  }
}

// 更新元素时
const patchElement = (n1, n2, ...) => {
  const el = (n2.el = n1.el!)

  const { dirs } = n2

  // 指令 beforeUpdate 钩子
  if (dirs) {
    invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate')
  }

  // 更新属性和子节点 ...

  // 指令 updated 钩子(Post 队列)
  if (dirs) {
    queuePostRenderEffect(() => {
      invokeDirectiveHook(n2, n1, parentComponent, 'updated')
    }, parentSuspense)
  }
}

指令的生命周期与元素的生命周期完美对齐:

sequenceDiagram
    participant Renderer as 渲染器
    participant Directive as 指令钩子
    participant DOM as 真实 DOM

    Note over Renderer: 挂载阶段
    Renderer->>DOM: createElement
    Renderer->>Directive: created(DOM 已创建,未插入)
    Renderer->>DOM: 设置 props / 挂载子节点
    Renderer->>Directive: beforeMount(即将插入 DOM)
    Renderer->>DOM: insertBefore
    Renderer->>Directive: mounted(已插入 DOM)

    Note over Renderer: 更新阶段
    Renderer->>Directive: beforeUpdate(DOM 即将更新)
    Renderer->>DOM: patchProps / patchChildren
    Renderer->>Directive: updated(DOM 已更新)

    Note over Renderer: 卸载阶段
    Renderer->>Directive: beforeUnmount(即将从 DOM 移除)
    Renderer->>DOM: removeChild
    Renderer->>Directive: unmounted(已从 DOM 移除)

13.3 v-model 的实现

v-model 是 Vue 最标志性的指令——一行代码搞定表单输入到状态的双向同步。但它的实现远比想象中复杂,因为它要处理完全不同种类的输入元素:text 是 input 事件 + value 属性;checkbox 是 change 事件 + checked 属性;select 是 change 事件 + value 属性;自定义组件则是 update:modelValue 事件 + modelValue prop。

对比 React 的处理:React 没有 v-model 等价物。想要受控 input 就要写 <input value={x} onChange={e => setX(e.target.value)} />——两行代码。用多了就是大量重复模板。Vue 的 v-model 把这两行收缩成一行,在表单密集的业务(后台管理系统、电商表单)里省下的代码量非常可观。这是 Vue 在”业务开发体验”这条 axis 上长期深耕的成果。

v-model 的底层是编译器针对不同 target 生成不同的运行时 helper——<input type="text" v-model="x"> 生成 vModelText<input type="checkbox"> 生成 vModelCheckbox,组件则生成一对 modelValue prop + update:modelValue listener。同一语法、不同目标、不同实现——这种”API 统一、实现按目标特化”的模式是语言设计里的经典做法(想想 C++ 的 template specialization)。

作为开发者,这个分化对你通常是透明的——你只需要写 v-model="x",Vue 帮你挑实现。但当你自己写一个自定义组件想支持 v-model 时就要了解这个机制:你要接受一个 modelValue prop、emit('update:modelValue', newValue) 来告知父组件更新;另外 3.x 后支持多个 v-model v-model:foo,对应 foo prop + update:foo 事件——让一个组件可以同时 v-model 多个字段。

v-model 是 Vue 中最复杂的指令——它需要根据不同的表单元素类型(input、textarea、select、checkbox、radio)采用不同的实现策略。

编译阶段

编译器看到 v-model 时做的事因 target 不同而分叉。先说”用户看到的语法”到”最终 render 代码”的映射:

  • <input v-model="x">withDirectives(h('input', ...), [[vModelText, x]])
  • <Child v-model="x">h(Child, { modelValue: x, 'onUpdate:modelValue': v => (x = v) })

这段代码展开是编译器的决策:看到 v-model on input/textarea → 走指令路径;看到 v-model on 组件 → 走 props/emit 路径。同一语法、两条编译路径——这个分叉本身就是一个有趣的设计。

<input v-model="name" />

编译为:

_withDirectives(
  _createElementVNode("input", {
    "onUpdate:modelValue": $event => (_ctx.name = $event)
  }, null, 8, ["onUpdate:modelValue"]),
  [
    [_vModelText, _ctx.name]
  ]
)

编译器做了两件事:

  1. 生成 onUpdate:modelValue 事件处理器(值的回写)
  2. 附加 vModelText 运行时指令(值的正向同步 + 事件监听)

vModelText:文本输入的实现

vModelText 是所有 v-model 实现里最基础、也最能说明问题的一个。它做四件事:

  1. 在 mounted 时绑定 input 事件监听器——用户输入时触发 emit 或更新状态。
  2. 在 mounted / updated 时设置 value 属性——把当前状态写到 DOM 上。
  3. 处理修饰符.lazy → 改用 change 事件;.number → emit 前把值转 Number;.trim → emit 前 trim 空白。
  4. 处理中文输入法(composition):输入法选词期间不应触发 emit(否则选到一半的拼音字符也会进去)——用 compositionstart / compositionend 事件 gate 住。

第 4 点是 v-model 源码里最体现”打磨”的地方——很多自己手写 input 同步的开发者根本想不到要处理 composition event,但遗漏这一点在中文/日文/韩文输入时会出明显 bug(用户看到的输入值不停闪烁、还没选完词就已经发出去了)。Vue 把这些坑内置处理——这就是框架的价值:“你以为你只是省了点代码、其实你省了所有前辈已经踩过的坑”。

// packages/runtime-dom/src/directives/vModel.ts
export const vModelText: ModelDirective<
  HTMLInputElement | HTMLTextAreaElement
> = {
  created(el, { modifiers: { lazy, trim, number } }, vnode) {
    el._assign = getModelAssigner(vnode)
    const castToNumber = number || (vnode.props && vnode.props.type === 'number')

    // 选择事件类型
    addEventListener(el, lazy ? 'change' : 'input', e => {
      if ((e.target as any).composing) return  // 输入法组合中,不触发

      let domValue: string | number = el.value
      if (trim) {
        domValue = domValue.trim()
      }
      if (castToNumber) {
        domValue = looseToNumber(domValue)
      }
      el._assign(domValue)
    })

    if (trim) {
      addEventListener(el, 'change', () => {
        el.value = el.value.trim()
      })
    }

    if (!lazy) {
      // 处理中文/日文/韩文输入法的组合输入
      addEventListener(el, 'compositionstart', onCompositionStart)
      addEventListener(el, 'compositionend', onCompositionEnd)
      addEventListener(el, 'change', onCompositionEnd)
    }
  },

  mounted(el, { value }) {
    el.value = value == null ? '' : value
  },

  beforeUpdate(el, { value, modifiers: { lazy, trim, number } }, vnode) {
    el._assign = getModelAssigner(vnode)

    // 输入法组合中不更新
    if ((el as any).composing) return

    const elValue = (number || el.type === 'number')
      ? looseToNumber(el.value)
      : el.value
    const newValue = value == null ? '' : value

    // 避免不必要的 DOM 更新
    if (elValue === newValue) return

    // 如果元素正在聚焦,且值在处理中,延迟更新
    if (document.activeElement === el && el.type !== 'range') {
      if (lazy) return
      if (trim && el.value.trim() === newValue) return
    }

    el.value = newValue
  }
}

几个值得注意的设计:

  1. compositionstart / compositionend:处理 CJK 输入法。在输入法组合过程中(如拼音输入未确认时),不触发值更新,避免中间状态污染数据。

  2. lazy 修饰符:使用 change 事件替代 input 事件,只在失去焦点时同步值。

  3. 焦点保护:如果用户正在编辑(元素聚焦),不主动覆盖 DOM 值,避免光标位置跳动。

vModelCheckbox:复选框的特殊处理

复选框的 v-model 有一个独特问题:“绑定的值是布尔还是数组?”

  • 单个 <input type="checkbox" v-model="agree">agree 是 ref(false))—— 布尔绑定。
  • 多个 <input type="checkbox" v-model="selected" value="A">selected 是 ref([]))—— 数组绑定,选中的 value 加入数组。

vModelCheckbox 在 mounted / updated 时根据 modelValue当前类型决定怎么处理——是 Array 就对比元素是否在里面、是 boolean 就直接设 checked。这种”动态语义”在强类型语言里会被骂死(一个 API 做两种完全不同的事),但 JavaScript 和 Vue 都接受这种”根据运行时类型自适应”的风格。

争议点:有些开发者批评这种”一个 API 多重语义”让代码难懂。Vue 团队的回应是”让最常见的用法最短”——写过 HTML 表单的人都知道 checkbox 既可以代表”同意/拒绝”(布尔)也可以代表”多选”(数组),Vue 的 v-model 让这两种自然写法都直接能用,是符合开发者直觉的选择。

export const vModelCheckbox: ModelDirective<HTMLInputElement> = {
  deep: true,  // 需要深度追踪(数组值)

  created(el, _, vnode) {
    el._assign = getModelAssigner(vnode)
    addEventListener(el, 'change', () => {
      const modelValue = (el as any)._modelValue
      const elementValue = getValue(el)
      const checked = el.checked
      const assign = el._assign

      if (isArray(modelValue)) {
        // 数组模式:选中时添加,取消时移除
        const index = looseIndexOf(modelValue, elementValue)
        const found = index !== -1
        if (checked && !found) {
          assign(modelValue.concat(elementValue))
        } else if (!checked && found) {
          const filtered = [...modelValue]
          filtered.splice(index, 1)
          assign(filtered)
        }
      } else if (isSet(modelValue)) {
        // Set 模式
        const cloned = new Set(modelValue)
        if (checked) {
          cloned.add(elementValue)
        } else {
          cloned.delete(elementValue)
        }
        assign(cloned)
      } else {
        // 布尔模式
        assign(getCheckboxValue(el, checked))
      }
    })
  },

  mounted: setChecked,
  beforeUpdate(el, binding, vnode) {
    el._assign = getModelAssigner(vnode)
    setChecked(el, binding, vnode)
  }
}

function setChecked(
  el: HTMLInputElement,
  { value, oldValue }: DirectiveBinding,
  vnode: VNode
) {
  ;(el as any)._modelValue = value
  if (isArray(value)) {
    el.checked = looseIndexOf(value, vnode.props!.value) > -1
  } else if (isSet(value)) {
    el.checked = value.has(vnode.props!.value)
  } else if (value !== oldValue) {
    el.checked = looseEqual(value, getCheckboxValue(el, true))
  }
}

复选框的 v-model 支持三种数据类型:数组、Set、布尔值。这种多态性是在运行时通过类型检查实现的——编译器无法在编译时确定绑定值的类型。

组件上的 v-model

组件上的 v-model 和元素上的 v-model 完全是两种机制,虽然语法一致。

  • 元素 v-model:编译时挂 directive、运行时绑事件、改 DOM 属性。
  • 组件 v-model:编译时拆成一对 prop + listener,运行时走正常的 props/emit 链路,不经过 withDirectives / 指令钩子

这种分化体现了 Vue “用最合适的机制解决每个具体问题”的哲学——表单元素有自己的交互特性(composition、各种特殊属性),用指令直接操作 DOM 最自然;组件是黑盒,没法强塞 DOM 操作,那就用标准的 props/emit 契约。两种机制统一在相同的模板语法下,开发者心智负担保持最小

组件上的 v-model 与元素上的完全不同——它被编译为 props + emit:

<MyInput v-model="name" />

编译为:

_createVNode(MyInput, {
  modelValue: _ctx.name,
  "onUpdate:modelValue": $event => (_ctx.name = $event)
}, null, 8, ["modelValue", "onUpdate:modelValue"])

没有 withDirectives,没有运行时指令——纯粹的 props 和事件。组件内部需要声明 modelValue prop 和 update:modelValue emit。

Vue 3.4+ 引入了 defineModel 宏进一步简化:

// MyInput.vue
const model = defineModel<string>()

// 等价于
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const model = computed({
  get: () => props.modelValue,
  set: (val) => emit('update:modelValue', val)
})

13.4 v-show 的实现

v-show 是最简单的运行时指令——改一个 CSS 属性,done。它的实现通过 vShow 对象,用指令系统的 beforeMount 和 updated 钩子完成 el.style.display 的切换。

但”简单”不等于”无聊”——vShow 的实现里藏着一些需要特别注意的边界情况。比如:如果元素上已经有 display: flex(不是默认的 block),v-show 在 hide 时改成 none、show 时应该恢复到什么值?Vue 的答案是记住元素原始的 display 值——在 beforeMount 时把 el.style.display 存到 _vod 自定义字段、在切换到 hide 时改为 none、在切换回 show 时恢复 _vod。这种”保存原值以便恢复”的模式在 DOM 操作里非常常见——Vue 把它内置处理,不给开发者踩坑的机会。

v-show 是最简单的运行时指令之一:

// packages/runtime-dom/src/directives/vShow.ts
export const vShow: ObjectDirective<VShowElement> & { name?: 'show' } = {
  beforeMount(el, { value }, { transition }) {
    el._vod = el.style.display === 'none' ? '' : el.style.display
    if (transition && value) {
      transition.beforeEnter(el)
    } else {
      setDisplay(el, value)
    }
  },

  mounted(el, { value }, { transition }) {
    if (transition && value) {
      transition.enter(el)
    }
  },

  updated(el, { value, oldValue }, { transition }) {
    if (!value === !oldValue) return  // 布尔值未变化
    if (transition) {
      if (value) {
        transition.beforeEnter(el)
        setDisplay(el, true)
        transition.enter(el)
      } else {
        transition.leave(el, () => {
          setDisplay(el, false)
        })
      }
    } else {
      setDisplay(el, value)
    }
  },

  beforeUnmount(el, { value }) {
    setDisplay(el, value)
  }
}

function setDisplay(el: VShowElement, value: unknown): void {
  el.style.display = value ? el._vod : 'none'
}

关键细节:

  1. 保存原始 display 值el._vod(原始 display)在 beforeMount 时保存,隐藏时设为 none,显示时恢复原值。

  2. 过渡动画集成v-show<Transition> 组件深度集成。当有过渡效果时,隐藏操作延迟到过渡动画结束后执行。

  3. 短路优化!value === !oldValue 用双重取反将值转为布尔后比较,避免 undefinedfalse 等不必要的更新。

v-show vs v-if

v-showv-if 看起来能互换,但它们的运行时成本完全不同,选错会踩坑。

  • v-show:通过 CSS display 控制显示/隐藏。元素始终存在于 DOM 里,只是”藏起来”。切换成本是一次 CSS 修改,极轻。
  • v-if:通过条件渲染控制 VNode 是否存在。元素真的会被移除或插入,带动其内部所有子组件的挂载/卸载生命周期。切换成本包括 DOM 操作、组件初始化、潜在的响应式 effect 重建。

选择原则:切换频繁选 v-show,切换罕见或初始不需要就选 v-if。极端例子:一个 tooltip 鼠标移入就显示、移出就隐藏,一秒可能切几十次——必须 v-show;一个仅特权用户才见的面板,99% 用户一生看不到——必须 v-if(省掉初始化成本)。

这个选择其实是”存在 vs 可见”的哲学问题。v-if 切换的是”这个东西在不在”,v-show 切换的是”这个东西看不看得到”。从语义上想清楚”你要的是哪种”,代码自然就对了。

graph LR
    subgraph "v-show"
        A[始终渲染 DOM] --> B[切换 display 属性]
        B --> C[初始渲染开销大]
        C --> D[切换开销小]
    end
    subgraph "v-if"
        E[条件渲染 DOM] --> F[销毁/重建整个子树]
        F --> G[初始渲染开销小]
        G --> H[切换开销大]
    end

13.5 v-on 的编译与优化

v-on最常用的指令之一——每个”交互式”Vue 项目几乎都每个组件都在用。它在 Vue 3 里被编译器做了专门优化,值得单独讲。

事件处理的编译

@click="handleClick" 编译成 { onClick: handleClick }——事件名首字母大写加 on 前缀。这和第 10 章讲 Emit 时提到的”父组件的监听器以 props 形式传入子组件”背后是同一套机制v-on 和 Emit 共用”以 on 前缀的 props 表达事件监听”这一底层协定。统一抽象、多套语法——这是 Vue 编译层的典型套路。

对于原生 DOM 元素(<button> 等),patch 阶段会调 addEventListener 把 onClick prop 的函数注册为 click 事件监听器。对于组件,这个 onClick 只是一个普通 prop,子组件 emit(‘click’) 时去 props 里找到并调用它。两种场景共用同一形式的代码,运行时区分行为——又是 Vue 编译 + 运行时协作的经典案例。

<button @click="handleClick">Click</button>

编译为:

_createElementVNode("button", {
  onClick: _ctx.handleClick
}, "Click", 8, ["onClick"])

注意:@click 被编译为 onClick prop,不是运行时指令。Vue 3 统一使用 prop 机制处理事件(以 on 开头的 prop 被识别为事件监听器)。

事件缓存

v-on 的一个细小但重要的优化:内联函数的缓存

@click="count++" 时,编译器会生成类似 onClick: (...args) => count++ 的内联箭头函数。每次 render 这个箭头函数是新引用——意味着VNode 的 onClick prop 每次 render 都”变了”,下游 patch 会认为”事件监听器变了、要重绑”,这是无意义的开销。

Vue 3.0 引入 cacheHandler 优化:编译器识别出”内联函数体依赖的变量没变过”时,把这个函数缓存起来复用。具体实现是 render 生成的代码里会有 _cache[0] || (_cache[0] = fn) 这种形式——第一次 render 创建函数、后续 render 从 cache 里拿同一个引用。

这个优化对大型表单、列表特别显著——一个有 100 个按钮的后台页面,每次更新省下 100 次重绑事件。细小但累积起来是明显的性能差。这是 Vue 编译器”看得懂模板、替你省事”的又一例证。

编译器的一个重要优化——事件处理器缓存:

<button @click="count++">+1</button>

编译为(启用缓存):

function render(_ctx, _cache) {
  return _createElementVNode("button", {
    onClick: _cache[0] || (_cache[0] = $event => (_ctx.count++))
  }, "+1")
}

事件处理器被缓存到 _cache 数组中,后续渲染直接复用同一个函数引用,避免不必要的 prop 变化检测。

事件修饰符

事件修饰符(.stop / .prevent / .once / .self / .capture / .passive 等)是 Vue 给事件处理加的语法糖包——把那些原生 JS 里反复写的 e.stopPropagation() / e.preventDefault() 收进声明式语法。

具体实现路径有两种:

  1. 编译时展开.stop 直接编译成 withModifiers(handler, ['stop']),运行时 helper 在调 handler 前做 stopPropagation
  2. 转 event name.capture / .once / .passive 这三个是 addEventListener 的第三个参数选项,编译时直接把事件名改成 onClickCapture 这样的特殊命名——patch 阶段识别这个命名并传对应的 options 给 addEventListener

为什么要分两种方式?因为修饰符的语义天然分两类.stop 是**“事件在 handler 里要做的事”(runtime 行为),.capture”addEventListener 时的注册选项”**(绑定时配置)。两类在实现上路径完全不同,但对开发者保持同一套语法——这就是”API 一致、实现分化”的设计美学。

<form @submit.prevent="onSubmit">
  <input @keydown.enter.ctrl="onCtrlEnter" />
  <button @click.stop.once="onClick">Submit</button>
</form>

修饰符在编译时被转换为包装函数:

// .prevent → withModifiers
_createElementVNode("form", {
  onSubmit: _withModifiers(_ctx.onSubmit, ["prevent"])
}, [
  // .enter.ctrl → 键盘修饰符
  _createElementVNode("input", {
    onKeydown: _withKeys(_ctx.onCtrlEnter, ["enter", "ctrl"])
  }),
  // .stop.once → 运行时处理
  _createElementVNode("button", {
    onClickOnce: _withModifiers(_ctx.onClick, ["stop"])
  }, "Submit")
])

withModifiers 的实现:

// packages/runtime-dom/src/directives/vOn.ts
export const withModifiers = <
  T extends (event: Event, ...args: unknown[]) => any
>(
  fn: T & { _withMods?: { [key: string]: T } },
  modifiers: string[]
) => {
  const cache = fn._withMods || (fn._withMods = {})
  const cacheKey = modifiers.join('.')
  return (
    cache[cacheKey] ||
    (cache[cacheKey] = ((event, ...args) => {
      for (let i = 0; i < modifiers.length; i++) {
        const guard = modifierGuards[modifiers[i]]
        if (guard && guard(event, modifiers)) return
      }
      return fn(event, ...args)
    }) as T)
  )
}

const modifierGuards: Record<string, (e: Event, modifiers: string[]) => void | boolean> = {
  stop: e => e.stopPropagation(),
  prevent: e => e.preventDefault(),
  self: e => e.target !== e.currentTarget,
  ctrl: e => !(e as KeyboardEvent).ctrlKey,
  shift: e => !(e as KeyboardEvent).shiftKey,
  alt: e => !(e as KeyboardEvent).altKey,
  meta: e => !(e as KeyboardEvent).metaKey,
  left: e => 'button' in e && (e as MouseEvent).button !== 0,
  middle: e => 'button' in e && (e as MouseEvent).button !== 1,
  right: e => 'button' in e && (e as MouseEvent).button !== 2,
  exact: (e, modifiers) =>
    systemModifiers.some(m => (e as any)[`${m}Key`] && !modifiers.includes(m))
}

.once 修饰符更有趣——编译器将它转换为事件名的前缀:@click.once 变成 onClickOnce。运行时在 patchEvent 中检测到 Once 后缀,使用 addEventListener{ once: true } 选项。

13.6 v-bind 的动态绑定

v-bind最基础的指令——它不做什么”魔法”,就是把 JavaScript 表达式的值绑到 DOM 属性上。但正因为它是所有动态绑定的底层,Vue 3 在它身上做了一系列细致的优化:class/style 的特殊归一化、动态 props 的 v-bind="obj" 语法、对 SVG 和 HTML 属性差异的透明处理

一个”看似简单但实际做了大量工作”的 API——这也是 Vue 框架的典型气质。

一个让人印象深刻的细节:v-bind 会智能区分”这个属性该作为 attribute 还是 property 设到 DOM”。比如 input 的 value,用 setAttribute 设 value 只影响初始 HTML 属性、不会改变用户正在编辑的输入框;必须走 DOM property(el.value = x)才能真正同步到用户看到的输入。Vue 的 patchProp 函数专门维护了一份”这个属性是 attr 还是 prop”的判断表,内部根据元素类型和属性名选择正确的设置方式。这是开发者几乎永远不会意识到的坑,Vue 帮你处理了——一个典型的”框架替你踩过的坑”。

v-bind 在编译时被转换为 props:

<div :class="cls" :style="sty" :id="id" v-bind="dynamicAttrs">

编译为:

_createElementVNode("div", _mergeProps({
  class: _ctx.cls,
  style: _ctx.sty,
  id: _ctx.id
}, _ctx.dynamicAttrs), null, 16 /* FULL_PROPS */)

当使用 v-bind="obj" 绑定一个对象时,因为 key 是动态的,编译器标记 FULL_PROPS,运行时必须做全量属性 Diff。

13.7 自定义指令的注册与使用

自定义指令是 Vue 提供给开发者扩展 DOM 操作抽象的能力。虽然大多数场景用组件就够了,但有些 DOM 操作的粒度小到不值得写组件、又有独立的生命周期——这正是自定义指令的甜蜜点。

典型例子:v-focus(挂载时自动聚焦)、v-click-outside(点击元素外触发回调)、v-tooltip(悬浮显示提示)、v-lazy(懒加载图片)、v-scroll-to(滚动到某位置)。这些用组件实现都别扭——它们不渲染自己的内容、只增强已有元素的行为。用指令最自然。

注册方式

Vue 提供两种注册路径:全局注册(app.directive局部注册(在组件的 directives option 里)。全局注册让指令在整个应用里可用——适合真正通用的能力(v-focus、v-tooltip);局部注册只在声明的组件内生效——适合只在特定场景用的逻辑。

推荐做法:默认局部注册;真正横跨整个应用的才全局。理由是局部注册让指令的”作用范围可追溯”——搜代码能看到谁用了这个指令;全局注册的指令看起来像”天上掉下来”的魔法,协作时容易让人困惑。这和 Vue 组件的注册策略是同一套哲学——默认显式 import、只把真正基础的能力做成全局

// 全局注册
app.directive('focus', {
  mounted(el) {
    el.focus()
  }
})

// 局部注册
export default {
  directives: {
    focus: {
      mounted(el) {
        el.focus()
      }
    }
  }
}

// setup 中的简写(以 v 开头的变量自动识别为指令)
const vFocus = {
  mounted: (el: HTMLElement) => el.focus()
}

指令的解析

模板里写 v-my-directive 时,运行时需要找到这个指令的定义。查找顺序是:先看当前组件的 local directives、再看 app 的 global directives、还找不到就报 warning

这个查找是每次 render 时做的——看起来是重复工作,其实成本很低(几次属性查找而已),但换来的是热模块替换(HMR)时指令定义更新能立即生效。如果缓存指令引用,HMR 改了定义也不会反映——Vue 选择了”每次查找但保持响应性”的权衡。

这种”运行时多做一点工作换取开发体验”的选择在 Vue 的很多地方能看到——比如 <component :is> 每次解析组件名、slot 每次调用函数、inject 每次沿 parent 链查找。看起来”不必要”的重复,其实都在保持响应式语义。这是 Vue 和一些更追求极致静态优化的框架(Svelte、Solid)最明显的分岔点——Vue 倾向”运行时开销换开发体验”,另一派倾向”编译时努力换运行时零成本”。两条路都正确,取决于你权衡哪一边。

编译器在解析指令名时,会查找组件实例和全局注册:

// packages/runtime-core/src/helpers/resolveAssets.ts
export function resolveDirective(name: string): Directive | undefined {
  return resolveAsset(DIRECTIVES, name)
}

function resolveAsset(
  type: typeof COMPONENTS | typeof DIRECTIVES,
  name: string,
  warnMissing = true,
  maybeSelfReference = false
) {
  const instance = currentRenderingInstance || currentInstance
  if (instance) {
    const Component = instance.type

    // 1. 先从组件本身查找(局部注册)
    const res =
      resolve(instance[type] || (Component as ComponentOptions)[type], name) ||
      // 2. 再从全局查找
      resolve(instance.appContext[type], name)

    return res
  }
}

自定义指令的完整示例

我在项目里反复写过的几个自定义指令——把它们梳理出来作为”模板”,你可以直接参考实现自己的需要。

  • v-focus:挂载时自动聚焦。最简单的指令之一,3 行代码。
  • v-click-outside:点击元素外部触发回调。实现要处理 document 级事件监听、mounted 时注册、unmounted 时清理——是学习”指令生命周期完整闭环”的最佳案例。
  • v-intersect:用 IntersectionObserver 观察元素进入视口。适合图片懒加载、无限滚动、曝光埋点等场景。
  • v-resize:用 ResizeObserver 观察尺寸变化。适合需要响应元素尺寸的动态布局。
  • v-tooltip:悬浮显示提示。逻辑更复杂(mouseenter 创建提示元素、mouseleave 销毁)——学习”指令可以管理自己的 DOM 资源”。

这些指令都遵循同一个模式:mounted 时建立资源、unmounted 时释放资源。如果你的自定义指令涉及任何”持续存在的副作用”(事件监听、观察器、定时器、第三方库实例),unmounted 清理是强制要求——不清理就是内存泄漏。

// 一个可拖拽指令
const vDraggable: Directive<HTMLElement, boolean> = {
  created(el, binding) {
    // DOM 已创建但未挂载
    el.style.cursor = binding.value !== false ? 'grab' : 'default'
  },

  mounted(el, binding) {
    if (binding.value === false) return

    let startX: number, startY: number
    let initialX: number, initialY: number

    const onMouseDown = (e: MouseEvent) => {
      startX = e.clientX
      startY = e.clientY
      const rect = el.getBoundingClientRect()
      initialX = rect.left
      initialY = rect.top
      el.style.cursor = 'grabbing'
      document.addEventListener('mousemove', onMouseMove)
      document.addEventListener('mouseup', onMouseUp)
    }

    const onMouseMove = (e: MouseEvent) => {
      const dx = e.clientX - startX
      const dy = e.clientY - startY
      el.style.position = 'fixed'
      el.style.left = `${initialX + dx}px`
      el.style.top = `${initialY + dy}px`
    }

    const onMouseUp = () => {
      el.style.cursor = 'grab'
      document.removeEventListener('mousemove', onMouseMove)
      document.removeEventListener('mouseup', onMouseUp)
    }

    el.addEventListener('mousedown', onMouseDown)

    // 将清理函数保存到元素上
    ;(el as any)._dragCleanup = () => {
      el.removeEventListener('mousedown', onMouseDown)
      document.removeEventListener('mousemove', onMouseMove)
      document.removeEventListener('mouseup', onMouseUp)
    }
  },

  updated(el, binding) {
    el.style.cursor = binding.value !== false ? 'grab' : 'default'
  },

  beforeUnmount(el) {
    // 清理事件监听器
    const cleanup = (el as any)._dragCleanup
    if (cleanup) cleanup()
  }
}

指令的函数简写

Vue 3.x 支持把整个指令定义简写成一个函数——相当于同时注册到 mounted + updated 两个钩子。这对”逻辑在 mount 和 update 时一致”的简单指令特别适用:

app.directive('focus', (el) => el.focus())

上面这个 v-focus 会在每次 mount 和 update 时都调 el.focus()。虽然”每次 update 都 focus”听起来怪,但大多数场景下元素内容变化不会强制 focus 也没副作用。

这种”简写版”的设计让开发者在80% 的简单场景下省掉”写完整对象”的心智成本。API 要在”最常见场景下最短”——Vue 一以贯之的 ergonomics 哲学。

如果指令只需要在 mountedupdated 时执行相同逻辑,可以使用函数简写:

// 等价于 { mounted: fn, updated: fn }
const vColor: Directive = (el, binding) => {
  el.style.color = binding.value
}

withDirectives 内部检测到函数类型会自动展开:

if (isFunction(dir)) {
  dir = {
    mounted: dir,
    updated: dir
  } as ObjectDirective
}

13.8 指令的参数与修饰符

自定义指令可以接受”参数”和”修饰符”——这让指令的行为可以被使用处的声明灵活调整。

  • 参数(v-foo:argarg 作为 binding.arg 传给钩子,适合”指令根据参数改变行为”。
  • 修饰符(v-foo.modmod: true 作为 binding.modifiers 的一部分,适合”开关式的行为变体”。

举个设计得好的 API:Vuetify 的 v-ripple:center.stop——参数 center 决定波纹从哪里扩散、修饰符 .stop 决定要不要阻止事件传播。一句声明表达了完整意图。这种”参数 + 修饰符的小型 DSL”让指令声明极其简洁——比写一堆 prop 自然得多。

设计自定义指令的 API 时,利用好参数和修饰符能让使用体验显著提升。我的经验法则:能用修饰符表达的开关、不要用参数对象;能用参数表达的”选一个”、不要用多个修饰符互斥。保持 API 形态和语义匹配。

指令支持参数(arg)和修饰符(modifiers):

<div v-my-directive:foo.bar.baz="value">

对应的 binding 对象:

{
  dir: { /* 指令定义 */ },
  instance: /* 组件实例 */,
  value: /* 绑定值 */,
  oldValue: /* 上一次的值 */,
  arg: 'foo',           // 参数
  modifiers: {          // 修饰符
    bar: true,
    baz: true
  }
}

动态参数:

<div v-my-directive:[dynamicArg]="value">

编译为:

_withDirectives(_createElementVNode("div"), [
  [_directive_my, _ctx.value, _ctx.dynamicArg]
])

13.9 指令与 Transition 的交互

Transition 是 Vue 的动画系统;指令是 DOM 行为扩展。两者在同一个 VNode 的生命周期上有交叉——一个元素离开时可能同时触发 <transition> 的 leave 动画和指令的 unmounted 钩子。

Vue 的处理策略是:Transition 先于 indiviual 指令完成——等过渡动画 complete 后才触发 unmounted。这让你的自定义指令 cleanup 不会发生在元素”还在动”的时候,避免视觉闪烁。

这种不同系统之间的时序协调是框架最细腻的设计面——各子系统都工作得好,它们之间的配合却有无数细节。Vue 源码里关于生命周期时序的代码块是 runtime-core 里最让人”哦原来是这样”的部分。

指令系统与 <Transition> 组件之间有微妙的协作关系。以 v-show 为例,它在 updated 钩子中需要与 transition 配合:

updated(el, { value, oldValue }, { transition }) {
  if (!value === !oldValue) return

  if (transition) {
    if (value) {
      // 显示:先执行 beforeEnter,再设置 display,最后触发 enter 动画
      transition.beforeEnter(el)
      setDisplay(el, true)
      transition.enter(el)
    } else {
      // 隐藏:先触发 leave 动画,动画结束后再设置 display: none
      transition.leave(el, () => {
        setDisplay(el, false)
      })
    }
  } else {
    setDisplay(el, value)
  }
}

transition 对象从 VNode 上获取,由 <Transition> 组件在渲染时注入。指令不需要知道过渡动画的具体实现——它只需要在正确的时机调用 transition.enter()transition.leave()

13.10 性能考量

指令的开销

运行时指令不是免费的。每个带指令的 VNode 都会在 dirs 数组中存储绑定信息,每次更新都会遍历这个数组调用钩子函数。对于大量使用指令的场景,这个开销是可观的。

优化建议:

  1. 优先使用编译时指令(v-if、v-for)而非运行时模拟
  2. 简单逻辑用 ref 替代指令——直接在 onMounted 中操作 DOM ref 比定义指令更直接
  3. 避免在 v-for 列表中使用复杂自定义指令——每个列表项都会创建独立的指令绑定

Vapor Mode 对指令系统的影响

在 Vue 3.6 的 Vapor Mode 中,指令系统有两个重要变化:

  1. 编译时指令(v-if、v-for)被直接编译为 DOM 操作代码,没有 VNode 和 Fragment 的中间层
  2. 运行时指令仍然存在,但通过不同的机制调用——因为没有 VNode,指令钩子直接绑定到 DOM 元素的生命周期

13.11 本章小结

指令系统是 Vue 模板能力的重要组成部分:

  1. 编译时指令(v-if、v-for、v-slot)在编译阶段被完全转换为 JavaScript 代码结构,运行时不存在指令的概念。

  2. 运行时指令通过 withDirectives 将绑定信息附加到 VNode 的 dirs 属性上,在 mountElementpatchElement 的各个阶段调用对应的钩子函数。

  3. v-model 是最复杂的指令,根据元素类型(text、checkbox、radio、select)采用不同的实现策略。组件上的 v-model 被编译为 props + emit,不使用运行时指令。

  4. v-show 通过 display 属性控制显隐,与 <Transition> 深度集成。

  5. v-on 在编译时被转换为 onXxx props,修饰符通过 withModifiers / withKeys 包装为卫兵函数。

  6. 自定义指令提供了直接操作 DOM 的逃生出口,但应谨慎使用——它们在 Vapor Mode 中的行为可能与经典模式有差异。


思考题

  1. 为什么 Vue 3 将 v-if 和 v-for 设计为编译时指令,而不是像 v-model 那样在运行时处理?如果 v-if 作为运行时指令实现,会有什么问题?

  2. v-model 的 compositionstart / compositionend 事件处理是必要的吗?如果去掉这个逻辑,中文用户会遇到什么问题?

  3. 为什么事件修饰符 .once 被编译为事件名前缀(onClickOnce)而不是通过 withModifiers 处理?这种设计有什么优势?

  4. 自定义指令的 deep: true 选项是如何工作的?它与 watchdeep 选项有什么关系?

  5. 在 SSR 环境中,指令系统如何工作?哪些钩子会被调用,哪些不会?为什么?