Vue 3 核心源码深度剖析系列(v3.5.44)-第 06 章 编译器内幕(二):`transform` 管线与结构指令

Vue 3 核心源码深度剖析系列(v3.5.44)-第 06 章 编译器内幕(二):transform 管线与结构指令

本章对应 vuejs/core 3.5.44,主讲 packages/compiler-core/src/transform.ts 及 transforms/*.ts 插件,回答一个问题:第 05 章 parse 出来的中立 AST,是怎样被 transform 阶段重写成 codegen AST 的?

上一章(05)讲 parse(模板 → 带 loc 的类型化 AST),下一章(07)讲 codegen(AST → render 函数字符串);而 transform 才是 Vue 3 编译期所有性能优化(静态提升、patchFlag、Block 树、v-once/v-memo)真正"做决定"的地方------codegen 只是忠实翻译这些决策。


导语:为什么 transform 是编译器的"大脑"

如果把 Vue 3 编译器比作一个编译前端:parse 是前端 (源码 → IR,即 RootNode/ElementNode/DirectiveNode);transform 是中端 (分析与改写,把"用户意图"降级成"运行时能高效执行的形式");codegen 是后端 (把 IR 打印成 _createElementVNode 之类的调用)。

多数文章直接背结论("v-if 生成三目"、"v-for 用 renderList"、"静态节点提升成 _hoisted_1"),只能让你"认得生成的代码",无法在动机层面理解设计。本章补上动机层,每一处改写都回答三个问题:

  1. 数据结构上发生了什么?(节点被替换成什么形状)
  2. 算法步骤是怎样的?(哪一步在哪个回调里执行)
  3. 为什么必须这么做?(不做要付什么代价------运行时/体积账本)

transform 骨架出奇地小:transform.ts 519 行,真正的算法集中在 traverseNode(transform.ts:425-486)与 createStructuralDirectiveTransform(transform.ts:488-519)。复杂性不在骨架,而在插件 :transformElement 965 行、vFor 375 行、vSlot 423 行、vIf 348 行。骨架提供"何时调用、以什么顺序、如何回滚",插件提供"改写成什么"。6.16.4 拆骨架,6.56.11 逐个深挖最重的插件,6.12 用可运行的自定义 transform 串起全部机制,6.13 收束。

术语约定:nodeTransforms 中的每一项称 node transform (作用于节点本身),directiveTransforms 中的每一项称 directive transform(作用于元素上的单个指令属性),二者共同构成 transform 阶段的可扩展契约。


6.1 TransformContext 全景:三段式与逐字段解剖

6.1.1 入口:transform(root, options)

transform 是整个阶段的公开入口,位于 packages/compiler-core/src/transform.ts:334-356:

ts 复制代码
export function transform(root: RootNode, options: TransformOptions): void {
  const context = createTransformContext(root, options)
  traverseNode(root, context)
  if (options.hoistStatic) {
    cacheStatic(root, context)
  }
  if (!options.ssr) {
    createRootCodegen(root, context)
  }
  // finalize meta information
  root.helpers = new Set([...context.helpers.keys()])
  root.components = [...context.components]
  root.directives = [...context.directives]
  root.imports = context.imports
  root.hoists = context.hoists
  root.temps = context.temps
  root.cached = context.cached
  root.transformed = true

  if (__COMPAT__) {
    root.filters = [...context.filters!]
  }
}

这 22 行是全阶段的总纲,可以拆成 5 个阶段:

阶段 代码 作用
① 建上下文 createTransformContext(root, options) (transform.ts:128) 把选项归一化,初始化所有可变状态容器(helpers/hoists/cached...)
② 递归遍历 traverseNode(root, context) (transform.ts:425) 深度优先遍历整棵树,每进入/退出一个节点就触发 node transforms
③ 静态提升 cacheStatic(root, context) (cacheStatic.ts:40) 只在 hoistStatic: true 时 执行;对遍历结果做第二遍分析,把常量子树提升为 _hoisted_n 或标记 CACHED
④ 根节点 codegen createRootCodegen(root, context) (transform.ts:358) 决定根节点返回什么(单元素 block / Fragment / null)
⑤ 元信息定稿 root.helpers = ... (transform.ts:344-350) 把 context 里的临时容器"封存"到 RootNode 上,供 codegen 读取

关键观察:阶段 ③ 和 ④ 不在 traverseNode 里,而是在遍历结束后 。这说明 transform 的设计是"两遍式"的:第一遍(traverseNode)负责结构改写 + 局部优化(patchFlag 的"部分"),第二遍(cacheStatic)负责需要全局视野的优化(一个节点是否整体常量,必须等它的 children 都 transform 完才知道)。而 patchFlag 的最终定稿又发生在第一遍的退出回调里(6.11 详述)。这种"遍数分离"是理解整个阶段的关键锚点。

6.1.2 三段式的真相:rootTransforms 已经不存在了

大纲中提到的"三段式"------rootTransforms / nodeTransforms / directiveTransforms------需要做一个版本校正 。在 Vue 3 早期的设计(@vue/compiler-core 3.0 的最初几个 beta)里,createTransformContext 的第 4 个解构参数确实是 rootTransforms: [],用来处理 RootNode(比如 transformElement 对根节点的特判)。但从 3.0 稳定版开始,rootTransforms 这个字段被移除了 ,TransformOptions(options.ts:218-303)里只剩 nodeTransforms、directiveTransforms 与 transformHoist。

在 3.5.44 中,实际只有两段(外加一个可选的 hoist 钩子):

段 类型 载体 谁提供 调用时机
nodeTransforms NodeTransform[] TransformOptions.nodeTransforms (options.ts:226) compile.ts:32-63 的 getBaseTransformPreset + 用户自定义 每进入一个节点按数组顺序调用(transform.ts:433-449)
directiveTransforms Record<string, DirectiveTransform> TransformOptions.directiveTransforms (options.ts:231) getBaseTransformPreset 返回 { on, bind, model },compiler-dom 覆盖/扩展 buildProps 内对每个非结构指令调用(transformElement.ts:699-702)
transformHoist `HoistTransform null` TransformOptions.transformHoist (options.ts:237) compiler-dom 提供 stringifyStatic(compiler-dom/src/index.ts:62)

那么"根节点特判"去哪了?答案:它被折叠进了 nodeTransforms 里"能处理 ROOT 类型的插件" + transform() 的后继步骤。看两个证据:

  1. traverseNode 的 switch 里显式带有 case NodeTypes.ROOT: 分支(transform.ts:475),把根节点的 children 交给 traverseChildren。也就是说根节点本身也会被 nodeTransforms 逐个调用 (traverseNode 第一步就是跑 nodeTransforms,不区分节点类型)------任何 transform 只要在自己的函数体里写 if (node.type === NodeTypes.ROOT) 就能处理根。
  2. 真正"只作用于根"的收尾逻辑(决定根返回单 block 还是 Fragment、给根打 STABLE_FRAGMENT)被搬到了 transform() 里直接调用 createRootCodegen,不再走插件体系。

所以"三段式"在 3.5 的准确表述是:nodeTransforms(含对 ROOT 的通用遍历)+ directiveTransforms(元素级)+ transformHoist(静态提升钩子)。本章后续提到"rootTransforms"时,都按这个映射理解。

6.1.3 TransformContext 的字段:选项区

TransformContext 的接口定义在 transform.ts:84-126,createTransformContext 的主体在 transform.ts:128-332。它是整个 transform 阶段唯一的"共享黑板"------所有插件通过它读写状态、互相通信。字段可以分成三组:选项区(options) 、状态区(state) 、方法区(methods)。

选项区来自 Required<Omit<TransformOptions, keyof CompilerCompatOptions>> & CompilerCompatOptions(注意 Required<...>:解构时给了默认值,所以全部必填)。逐个说明:

字段 初值 语义与影响
filename '' 用于 source map 与 selfName 推导
selfName 由 filename 推导(transform.ts:155-159) 递归自引用组件名。foo/Bar.vue → Bar;resolveComponentType 用它判断"组件是否在引用自己"(transformElement.ts:313-324)
prefixIdentifiers false 是否把 {{ msg }} 的 msg 改写成 _ctx.msg。决定 transformExpression 是否进入 nodeTransforms,也决定 codegen 用 with(this) 还是 _ctx
hoistStatic false 是否在 traverseNode 后跑 cacheStatic(transform.ts:337-339)
hmr false 影响 #6938/#7138 等 HMR 边界:某些缓存数组需要 spread(见 cacheStatic.ts:219-228)
cacheHandlers false 传给 transformOn,决定是否把 inline handler 缓存进 _cache[n](vOn.ts:81,99-125,169-174)
nodeTransforms [] node transform 数组,顺序敏感
directiveTransforms {} 指令名 → directive transform 的字典
transformHoist null 静态提升后的字符串化钩子,compiler-dom 注入 stringifyStatic
isBuiltInComponent NOOP 平台内置组件判定(如 <Transition>),resolveComponentType 用它(transformElement.ts:287)
isCustomElement NOOP 自定义元素判定
expressionPlugins [] Babel parser 插件列表(jsx/typescript...)
scopeId null SFC scoped id;写入 slot 调用的第 5 参数(transformSlotOutlet.ts:42-45)
slotted true SFC <style :slotted> 是否被使用;为 false 时 renderSlot 需要 noSlotted 参数
ssr false 是否生成 SSR 代码。ssr: true 时跳过 createRootCodegen,且很多 DOM 分支被裁剪
inSSR false "正在生成 SSR"。与 ssr 的区别:ssr 还可以是"SSR 的 fallback 分支"(此时 ssr=false 但 inSSR=true),影响 getVNodeHelper / getVNodeBlockHelper
ssrCssVars `` SFC <style vars> 注入的 CSS 变量对象表达式
bindingMetadata EMPTY_OBJ <script setup> 里绑定的类型元数据,resolveSetupReference 依赖它决定用 $setup[...] 还是直接变量(transformElement.ts:332-380)
inline false 是否内联进 setup()。开启时 resolveSetupReference 直接返回变量名而非 $setup[...](transformElement.ts:357-360)
isTS false 是否输出 TS 代码(vOn 用它生成 ($event: any) 等)
onError / onWarn defaultOnError / defaultOnWarn 错误/警告回调(errors.ts)
compatConfig --- Vue 2 兼容开关(compat/compatConfig.ts)

一个容易忽略的设计细节:createTransformContext 用的是解构默认值 (filename = '' ...),而不是 options.filename || ''。这意味着调用方若显式传 undefined 也会走默认值,但传 null 会影响某些分支(如 scopeId: null 是合法语义,表示"无 scoped id")。这就是为什么 TransformContext extends Required<Omit<...>>------接口层用 Required 把可选字段"变"成必填,实现在解构处兜底。

6.1.4 TransformContext 的字段:状态区

状态区是 transform 的"运行时记忆"。这些容器负责累积遍历过程中产生的副作用,最终在 transform() 末尾定稿到 RootNode(transform.ts:344-350)。

字段 类型 初值 写入者 读取者 语义
root RootNode 入参 --- 多处 根引用
helpers Map<symbol, number> new Map() helper() / removeHelper() transform.ts:344、codegen 引用计数的 helper 集合。value 是出现次数,不是布尔
components Set<string> new Set() resolveComponentType codegen.genAssets 需要在 render 开头 resolveComponent(...) 的组件名
directives Set<string> new Set() buildDirectiveArgs codegen.genAssets 需要 resolveDirective(...) 的用户指令名
hoists `(JSChildNode null)[]` [] context.hoist()、cacheStatic codegen.genHoists
imports ImportItem[] [] 少量内部用法 codegen.genImports 需要生成的额外 import 语句
cached `(CacheExpression null)[]` [] context.cache()、vFor/vMemo 直接 push(null) codegen 的 _cache[n]
constantCache WeakMap<TemplateChildNode, ConstantTypes> new WeakMap() getConstantType getConstantType 常量类型分析的 memo,避免 O(n²) 重复计算
vForMemoKeyedNodes WeakSet<ElementNode> new WeakSet() vFor.ts:96 transformExpression.ts:71-77 标记"<template v-for> + v-memo + 动态 key"节点,避免表达式被重复处理
temps number 0 极少数 transform codegen 声明 let _temp0 临时变量计数
identifiers Record<string, number> Object.create(null) addIdentifiers / removeIdentifiers hasScopeRef 作用域标识符的引用计数(不是布尔!见 6.1.6)
scopes {vFor,vSlot,vPre,vOnce} 全 0 各结构指令 ++/-- buildProps、buildSlots、cacheStatic 嵌套深度计数,用于判定"是否在 v-for / v-slot 内"
parent / grandParent `ParentNode null` null traverseChildren replaceNode/removeNode/vIf
childIndex number 0 traverseChildren replaceNode / removeNode 当前节点在父 children 中的索引
currentNode `RootNode TemplateChildNode null` root traverseNode / transforms
inVOnce boolean false transformOnce transformOn 是否正处于 v-once 子树内(决定 handler 是否还缓存)

identifiers 用"引用计数"而非 Set 是个精妙设计:同一个名字可能被多层 v-for/v-slot 同时声明(比如嵌套 v-for 用同名变量),退出一层时不能直接删除,只能减计数;计数归零才真正"出作用域"。这就是 addId/removeId 的写法(transform.ts:319-329):

ts 复制代码
function addId(id: string) {
  const { identifiers } = context
  if (identifiers[id] === undefined) {
    identifiers[id] = 0
  }
  identifiers[id]!++
}
function removeId(id: string) {
  context.identifiers[id]!--
}

(removeId 直接 -- 不做存在性判断,隐含前提是"加了才减"。若某个 transform 减了一个从未加过的名字,会得到 NaN------hasScopeRef 里 !!ids[name] 对 NaN 求值为 false,所以不会崩,但语义已经错了。这类不变量靠"配对使用"维持。)

6.1.5 TransformContext 的方法区

方法区是插件与骨架交互的唯一 API。逐个走读:

helper(name) / removeHelper(name) / helperString(name) (transform.ts:207-225):

ts 复制代码
helper(name) { helpers.set(name, (helpers.get(name) || 0) + 1); return name },
removeHelper(name) {
  const c = helpers.get(name)
  if (c) c > 1 ? helpers.set(name, c - 1) : helpers.delete(name)
},
helperString(name) { return `_${helperNameMap[context.helper(name)]}` },

注意三点:

  1. helper 是引用计数式 的。Map<symbol, number> 里 value 是次数。这解决了一个真实问题:同一个 helper 可能被多个节点需要,而某些优化(如 getConstantType 把 block 降级为普通 vnode,cacheStatic.ts:342-347)要"撤销一次 helper 请求"。若用 Set,撤销会把别的节点也需要的 helper 一起删掉。
  2. helperString 是 helper 的语法糖:既注册计数,又立即 返回 _name 字符串(helperNameMap 查询)。vOn.ts:63 的 TO_HANDLER_KEY、vFor.ts:229 的 IS_MEMO_SAME 等都用它。
  3. removeHelper 只减不报错:count 为 undefined 时静默返回。

replaceNode(node) (transform.ts:226-238):

ts 复制代码
replaceNode(node) {
  // __DEV__ 下断言 currentNode 存在、parent 存在(否则抛错)
  context.parent!.children[context.childIndex] = context.currentNode = node
},

它同时做两件事:改父 children 的槽位 、更新 currentNode 。这是结构指令(v-if/v-for)把自己的 ElementNode 替换成 IfNode/ForNode 的唯一途径。两个 DEV 断言守护不变量:"正在被替换的节点必须还在"(currentNode 非空)、"根不能被替换"(parent 非空)。

removeNode(node?) (transform.ts:239-266):比 replaceNode 复杂得多,因为它要处理"删除的是当前节点还是兄弟节点"两种情形:

ts 复制代码
removeNode(node) {
  const list = context.parent!.children
  const removalIndex = node ? list.indexOf(node)
    : context.currentNode ? context.childIndex : -1
  if (!node || node === context.currentNode) {
    context.currentNode = null; context.onNodeRemoved()   // 删当前节点
  } else if (context.childIndex > removalIndex) {
    context.childIndex--; context.onNodeRemoved()         // 删前置兄弟
  }
  list.splice(removalIndex, 1)
}

四个关键点:

  • 无参调用时删"当前节点",用 context.childIndex 定位,并把 currentNode 置 null(这是 traverseNode 在跑完 transform 后检测到 !context.currentNode 就 return 的信号,见 transform.ts:442-444);
  • 带 sibling 参数时删指定兄弟,若被删兄弟在当前节点之前 (childIndex > removalIndex),必须把 childIndex-- 修正,否则后续 replaceNode 会写错槽位;
  • 删除当前节点时调用 context.onNodeRemoved();删除前面的兄弟时也调用。onNodeRemoved 默认是 NOOP(transform.ts:267),只有 traverseChildren 会把它临时换成 () => { i-- }(transform.ts:411-413),让父级的 for 循环索引回退一位,避免"删除后跳过下一个子节点"。
  • splice 是 MS 简写的 O(n) 操作------但模板 AST 的 children 通常很短,可接受。

hoist(exp) (transform.ts:291-302):

ts 复制代码
hoist(exp) {
  if (isString(exp)) exp = createSimpleExpression(exp)
  context.hoists.push(exp)
  const identifier = createSimpleExpression(
    `_hoisted_${context.hoists.length}`, false, exp.loc, ConstantTypes.CAN_CACHE)
  identifier.hoisted = exp
  return identifier
},

它把表达式压入 hoists 数组,并返回一个"指向它的标识符" _hoisted_N。注意 N 是 hoists.length(push 之后),所以第一个被提升的是 _hoisted_1。返回的 SimpleExpressionNode 带 hoisted 字段指回原表达式------codegen.genHoists 会把它打印成 const _hoisted_1 = ...,而代码里用到的地方只打印 _hoisted_1。标识符的 constType 被硬编码为 CAN_CACHE,因为"已经提升到常量"的表达式天然可缓存。

cache(exp, isVNode, inVOnce) (transform.ts:303-312):

ts 复制代码
cache(exp, isVNode = false, inVOnce = false) {
  const cacheExp = createCacheExpression(context.cached.length, exp, isVNode, inVOnce)
  context.cached.push(cacheExp)
  return cacheExp
},

index 取 cached.length(在 push 之前),所以第一个缓存槽是 _cache[0]。isVNode 参数最终落到 CacheExpression.needPauseTracking,在 codegen.genCacheExpression(codegen.ts:1015-1044)里决定是否包裹 setBlockTracking(-1)(v-once 缓存需要暂停 block 追踪)。

addIdentifiers / removeIdentifiers (transform.ts:268-290):只在 !__BROWSER__ 时生效(浏览器构建不做作用域分析)。三种入参形态:字符串、带 identifiers 数组的表达式、SIMPLE_EXPRESSION。第三种的注释值得注意------"v-for alias / v-slot props 被当作函数参数处理时,processExpression 会把声明收集到 exp.identifiers"。


6.2 递归遍历 traverseNode:进入、退出与节点替换

traverseNode 是 transform 的心脏,全文只有 62 行(transform.ts:425-486),却同时承担 4 件事:跑 node transforms、分发子节点遍历、收集退出回调、逆序执行退出回调。

6.2.1 函数签名与入口状态

ts 复制代码
export function traverseNode(
  node: RootNode | TemplateChildNode,
  context: TransformContext,
): void {
  context.currentNode = node
  // apply transform plugins
  const { nodeTransforms } = context
  const exitFns = []
  ...

第一件事是把 context.currentNode 指向自己。这个赋值在函数开头、for 循环之前 很关键:nodeTransforms[i] 执行期间若调用 context.replaceNode,会读 context.currentNode(transform.ts:237 用它写入父数组槽位),所以进入前必须先设置。

exitFns 是本次遍历的局部数组,收集本节点所有 transform 返回的退出回调。

6.2.2 进入阶段:顺序调用 node transforms(transform.ts:433-449)

ts 复制代码
  for (let i = 0; i < nodeTransforms.length; i++) {
    const onExit = nodeTransforms[i](node, context)
    if (onExit) {
      if (isArray(onExit)) {
        exitFns.push(...onExit)
      } else {
        exitFns.push(onExit)
      }
    }
    if (!context.currentNode) {
      // node was removed
      return
    } else {
      // node may have been replaced
      node = context.currentNode
    }
  }

逐点拆解:

  1. for 用 nodeTransforms.length 作为即时上界------理论上 transform 可以在运行中改数组(没人这么做,但契约允许)。
  2. 回调返回值 :void | (() => void) | (() => void)[](类型见 transform.ts:48-51)。返回函数 = "我要在这个节点退出 时再做点事";返回数组 = 多个退出回调。transformElement 返回单个 postTransformElement(transformElement.ts:76),createStructuralDirectiveTransform 返回 exitFns 数组(transform.ts:517)。
  3. if (!context.currentNode) return :某个 transform 把自己删了(比如 vIf.processIf 在 else 分支里 context.removeNode(),vIf.ts:154),立刻中止遍历------因为"这个节点已经不存在了",后续 transform 与子节点遍历都无意义。
  4. node = context.currentNode :某个 transform 把节点替换 了(如 vIf 用 context.replaceNode(ifNode),vIf.ts:123),就把局部 node 变量更新为替换后的新节点,供后续 transform 看到。这一步是"结构指令能被后续 transform 继续处理"的关键 :transformIf 在 nodeTransforms 数组里排在 transformElement 之前 (compile.ts:38-53),替换后 transformElement 拿到的就是 IfNode------但它其实不关心(因为 postTransformElement 只认 ELEMENT/COMPONENT)。真正关心替换结果的是 transformText 等按类型分发的插件,以及后续的 switch 分发。

6.2.3 分发阶段:按 node.type 递归子节点(transform.ts:451-478)

ts 复制代码
  switch (node.type) {
    case NodeTypes.COMMENT:
      if (!context.ssr) {
        context.helper(CREATE_COMMENT)
      }
      break
    case NodeTypes.INTERPOLATION:
      if (!context.ssr) {
        context.helper(TO_DISPLAY_STRING)
      }
      break

    // for container types, further traverse downwards
    case NodeTypes.IF:
      for (let i = 0; i < node.branches.length; i++) {
        traverseNode(node.branches[i], context)
      }
      break
    case NodeTypes.IF_BRANCH:
    case NodeTypes.FOR:
    case NodeTypes.ELEMENT:
    case NodeTypes.ROOT:
      traverseChildren(node, context)
      break
  }

四个要点:

  1. "叶子即副作用" :COMMENT 与 INTERPOLATION 没有 children,遍历它们唯一的目的就是注册 helper (CREATE_COMMENT / TO_DISPLAY_STRING)。这解释了一个常见疑问------为什么 {{ msg }} 的 msg 明明在 transformExpression 里被改写,却还要在 traverseNode 里注册 TO_DISPLAY_STRING?因为 codegen.genInterpolation(codegen.ts:763-769)会把 {{ msg }} 打印成 _toDisplayString(msg),它需要这个 helper。helper 的注册点分散在 transform 各处,而非集中一处,这是有意为之:谁生成调用谁注册。
  2. IF 特殊 :IfNode 的 children 不在 node.children,而在 node.branches(ast.ts:276-280),所以单独循环 branches,每个 branch 再走 IF_BRANCH 分支(进而 traverseChildren(branch))。这一层间接是 v-if 能"每个分支独立编译"的结构基础。
  3. 不含 SLOT / TEMPLATE :注意 ElementTypes.SLOT(<slot> outlet)与 TEMPLATE 仍然是 NodeTypes.ELEMENT,会被 case NodeTypes.ELEMENT 捕获正常遍历;tagType 只影响具体 transform 的行为,不影响遍历分派。switch 分派的是 node.type(语法结构),不是 node.tagType(元素语义) ------这是两套正交的分类维度,容易混淆(tagType 见 6.2.4)。
  4. TEXT_CALL 不在 switch 里 :TextCallNode(NodeTypes.TEXT_CALL,ast.ts:309-313)只在 transformText 退出时 被创建(transformText.ts:106-114),那时遍历早已结束,所以不需要遍历它。CacheExpression / JS_* 等 codegen 节点同理。

6.2.4 node.tagType:ELEMENT / COMPONENT / SLOT / TEMPLATE

ElementTypes 定义在 ast.ts:63-68:

ts 复制代码
export enum ElementTypes {
  ELEMENT,
  COMPONENT,
  SLOT,
  TEMPLATE,
}

这四个值由 parser 阶段根据标签名决定(不在这里重写,本章只需知道它已固定):

tagType 判定(parser) 典型节点 谁处理
ELEMENT (0) 原生/自定义元素标签(<div>、<my-el>、小写且非内置组件) PlainElementNode(ast.ts:137-146) transformElement 生成 createElementVNode
COMPONENT (1) 大写开头、或内置组件(<MyComp>、<Transition>) ComponentNode(ast.ts:148-156) transformElement 生成 createVNode + buildSlots
SLOT (2) <slot> SlotOutletNode(ast.ts:158-165) transformSlotOutlet 生成 renderSlot
TEMPLATE (3) <template> TemplateNode(ast.ts:167-171) 结构指令会"消化"它(v-if/v-for/v-slot),否则它被编译掉

tagType 参与的关键判断(后文会逐个回到):

  • transformElement 开头只处理 ELEMENT/COMPONENT,其余直接 return(transformElement.ts:79-85);
  • createStructuralDirectiveTransform 对 TEMPLATE + v-slot 提前 return(transform.ts:501-503),把"<template v-slot>"留给 vSlot;
  • buildProps 对 name === 'slot' 的指令,只有 isComponent 才不报错(transformElement.ts:566-573);
  • buildSlots 用 isTemplateNode() 回调(utils.ts:355-361)识别 <template v-slot>。

6.2.5 子节点遍历与状态保存/恢复:traverseChildren

traverseChildren(transform.ts:406-423):

ts 复制代码
export function traverseChildren(
  parent: ParentNode,
  context: TransformContext,
): void {
  let i = 0
  const nodeRemoved = () => {
    i--
  }
  for (; i < parent.children.length; i++) {
    const child = parent.children[i]
    if (isString(child)) continue
    context.grandParent = context.parent
    context.parent = parent
    context.childIndex = i
    context.onNodeRemoved = nodeRemoved
    traverseNode(child, context)
  }
}

三个隐藏设计:

  1. onNodeRemoved 的闭包捕获 :nodeRemoved 闭包捕获的是本次 traverseChildren 调用 的 i。当子节点 transform 调用 removeNode 删除自己或前面的兄弟时,onNodeRemoved() 会把 i--,于是 for 的 i++ 恰好抵消,指针停在原位置(删除后该位置变成下一个元素)。这就避免了"删一个跳一个"。
  2. 状态"压栈/出栈"的极简实现 :context.parent/grandParent/childIndex 在每个 child 前都被覆盖写 为新值。但注意------没有显式恢复 。为什么?因为:i 前进时,下一个 child 会把这三个字段再覆盖一次;而退回父层时 (traverseChildren 返回后回到 traverseNode 的 switch 之后),父层的 traverseChildren 循环仍持有父层自己的 parent... 等等,这里需要说清楚:父层的 context.parent 在进入子层 traverseChildren 时被覆盖成了子层的 parent 。子层返回后,父层的 traverseChildren 循环继续下一轮 child,会再次覆盖------但由于它传给 context.parent = parent 的 parent 是它自己的入参(即父层节点),所以下一轮会立即修正 。唯一"不立即修正"的窗口是:父层的 traverseChildren 循环走完后,traverseNode 执行 exitFns(transform.ts:480-485)------此时 context.parent 仍指向最后遍历的那个子节点的父 (即父层节点自身)......但正确值应该是父层的父 。这看起来像 bug,实则不然:因为父层的 traverseNode 是由祖父层的 traverseChildren 调用 的,祖父层在调用前设置了 context.parent = 祖父层节点;父层 traverseChildren 末尾把 context.parent 覆盖成了父层节点自身;父层 exitFns 期间 context.parent 确实错了。但 exitFns 里几乎没人读 context.parent (transformElement.postTransformElement 读的是 context.currentNode;vIf 的 exit 读 ifNode)。真正读 context.parent 的是进入阶段 (createStructuralDirectiveTransform 里 vIf.processIf 读 context.parent!.children,vIf.ts:129)------而那发生在父层 traverseNode 的 for 循环里,此时 context.parent 恰好正确(由祖父层设置)。这个"恰好正确"的窗口依赖调用顺序,是这套极简状态管理的隐含不变量,属于读源码时要特别小心的点。
  3. isString(child) 跳过 :CompoundExpressionNode.children 里允许字符串(ast.ts:259-266),但 traverseChildren 只被 ELEMENT/ROOT/FOR/IF_BRANCH 调用,这些容器的 children 是 TemplateChildNode[],理论上无字符串。isString 检查是防御性的(也覆盖了历史遗留)。注意 continue 会跳过 i++ 吗?不会------for 的 update 在 continue 后仍执行,所以正常前进。

6.2.6 退出阶段:exitFns 逆序执行(transform.ts:480-485)

ts 复制代码
  // exit transforms
  context.currentNode = node
  let i = exitFns.length
  while (i--) {
    exitFns[i]()
  }
}

三点:

  1. context.currentNode = node 重新赋值 :进入阶段末尾 node 可能已是"替换后的节点",但遍历子节点时 context.currentNode 被 traverseChildren/递归改成了子节点。退出前必须把 currentNode 指回本节点 ,否则 postTransformElement 里 node = context.currentNode!(transformElement.ts:77)会拿到错误的节点(比如最后一个子节点)。
  2. 逆序(LIFO)执行 :进入顺序是 A → B → C ,退出是 C → B → A 。原因(6.11 详述):退出回调依赖"内层信息已就绪"。transformElement 排在 nodeTransforms 靠后,其退出回调位于本节点 exitFns 的末尾、逆序执行时第一个 跑;而 children 遍历发生在 exitFns 之前,故它跑时子节点的 codegenNode/patchFlag 已就绪。
  3. 为什么用 while (i--) 而不是 for :i 从 length 递减到 0 时执行,i 为 -1 时循环条件 -1 为 falsy 退出。标准倒序写法。

6.2.7 一图看懂:进入/退出时序

sequenceDiagram participant T as traverseNode(node) participant NT as nodeTransforms[] participant C as traverseChildren participant EX as exitFns (LIFO) T->>T: context.currentNode = node loop i: 0..n-1 (进入) T->>NT: nodeTransforms[i](node, ctx) NT-->>T: onExit? → exitFns.push T->>T: node 被删? return / 被替换? node=currentNode end alt node.type 有 children (ELEMENT/ROOT/FOR/IF_BRANCH/IF) T->>C: 递归子节点 (设置 parent/childIndex/onNodeRemoved) C->>T: traverseNode(child) ...(深度优先) else COMMENT / INTERPOLATION T->>T: 注册 helper (CREATE_COMMENT / TO_DISPLAY_STRING) end T->>T: context.currentNode = node loop j: n-1..0 (退出, 逆序) T->>EX: exitFns[j]() end

用一段真实模板对照(以 <div v-if="ok" :class="cls">x</div> 为例):

text 复制代码
traverseNode(ELEMENT div)
├─ 进入: transformVBindShorthand (无 v-bind 简写 → noop)
├─ 进入: transformOnce (无 v-once → 无)
├─ 进入: transformIf (有 v-if)
│     └─ processIf → replaceNode(IfNode) → 返回 processCodegen 的退出回调
│        (此刻 node 变量 = IfNode)
├─ 进入: transformMemo / transformFor (类型不符 → 无)
├─ 进入: trackVForSlotScopes / transformExpression (非浏览器 build)
│     └─ transformExpression 处理 :class="cls" → cls 变 _ctx.cls
├─ 进入: transformSlotOutlet (非 slot → 无)
├─ 进入: transformElement → 返回 postTransformElement
├─ 进入: trackSlotScopes (无 v-slot → 无)
├─ 进入: transformText → 返回退出回调(合并文本)
├─ switch(node.type = IF): 遍历 branches[0] (IF_BRANCH)
│     └─ traverseChildren(branch) → traverseNode(div 原件)
│           ├─ 进入各 transform(此刻 div 已不是 v-if,但 props 里的 v-if 已被摘除)
│           ├─ 进入 transformElement → 返回 postTransformElement
│           └─ 退出: postTransformElement 先跑 → 生成 VNodeCall(codegenNode)
├─ 退出(逆序): transformText 的退出 → 处理 branch 文本
├─ 退出: postTransformElement (transformElement) → node 现在是 IfNode,不是 ELEMENT → return(不处理)
├─ 退出: transformExpression? (无退出)
├─ 退出: transformIf 的退出回调 → createCodegenNodeForBranch → 给 IfNode 设 codegenNode

这张图揭示一个反直觉的事实:transformElement 注册了两次退出回调 ------一次是在 traverseNode 第一次进入 ELEMENT div 时(此时 div 还带着 v-if,随后被 replaceNode 换成 IfNode,退出时 postTransformElement 读 currentNode 得到 IfNode,因类型不符直接 return,什么也不做 );另一次是在遍历 IF_BRANCH 展开后、重新 traverseNode(div原件) 时。这就是为什么 transformIf 必须排在 transformElement 之前 :若反过来,transformElement 会先给带 v-if 的 div 生成一个错误的 VNodeCall,然后 transformIf 再替换节点------那个 VNodeCall 就成了孤儿。

一条重要的边界 :若进入阶段某 transform 删除了节点,traverseNode 会因 !context.currentNode 直接 return(transform.ts:442-444),已收集的 exitFns 永不执行 。这通常无害(节点已不存在),但也有代价:vFor.processFor 的退出回调(vFor.ts:311-319)要 scopes.vFor--,若不执行会让后续节点被误判"在 v-for 内"。这条"进入副作用必须退出配对"的不变量,实际是靠"节点不被随意删除"来兜底。


6.3 helper 注册与 runtimeHelpers:从 context.helper() 到 tree-shaking

6.3.1 问题的起点:生成的 render 函数需要哪些运行时函数?

codegen 生成的代码长这样(mode: 'module'):

js 复制代码
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", null, _toDisplayString(_ctx.msg), 1 /* TEXT */))
}

编译器需要知道要用到哪些 vue 运行时的导出 ,才能生成第一行 import。但 codegen 只是一台"打印器",它并不"知道"整个模板用了哪些函数------信息必须在 transform 阶段就收集好。这就是 context.helpers 存在的意义。

6.3.2 收集:context.helper() 的引用计数

回顾 helper 实现(transform.ts:207-211):

ts 复制代码
helper(name) {
  const count = context.helpers.get(name) || 0
  context.helpers.set(name, count + 1)
  return name
},

设计要点:

  1. 参数是 symbol 而非字符串 。所有 helper 常量定义在 runtimeHelpers.ts:1-82,例如:
ts 复制代码
export const OPEN_BLOCK: unique symbol = Symbol(__DEV__ ? `openBlock` : ``)
export const CREATE_BLOCK: unique symbol = Symbol(__DEV__ ? `createBlock` : ``)
export const CREATE_ELEMENT_BLOCK: unique symbol = Symbol(__DEV__ ? `createElementBlock` : ``)
export const CREATE_VNODE: unique symbol = Symbol(__DEV__ ? `createVNode` : ``)
export const CREATE_ELEMENT_VNODE: unique symbol = Symbol(__DEV__ ? `createElementVNode` : ``)

注意 Symbol(__DEV__ ? \openBlock` : ``)------**生产构建里 Symbol 的描述是空串**。这是刻意的体积优化:Symbol的描述字符串在生产代码里用不到(只用于调试),空串能让压缩后的Symbol()调用更短。但这也意味着**生产构建下你不能靠symbol.description调试**,必须靠helperNameMap`(见下)。

  1. 返回值是 name 本身,方便链式写法:
ts 复制代码
return createCallExpression(context.helper(RESOLVE_DYNAMIC_COMPONENT), [exp])

(transformElement.ts:270-272)------注册 + 把 symbol 用作 callee,一步到位。

  1. 计数不是布尔。为什么要计数?看两个真实逆向场景:

    • cacheStatic.getConstantType 对一个"静态但被构造成 block 的 <svg>/<foreignObject>"做降级(cacheStatic.ts:331-348):它 removeHelper(OPEN_BLOCK) + removeHelper(getVNodeBlockHelper(...)),再 helper(getVNodeHelper(...)),把一个 block 变回普通 vnode;
    • vFor 的退出回调里切换 childBlock 的 block 与否(vFor.ts:189-215):从 block 切普通 vnode 时同样要 removeHelper(OPEN_BLOCK) 和 block helper。

    如果 helpers 是 Set,这两处的 removeHelper 会把其他节点也需要的 openBlock 一起删掉,生成的 render 就会引用未 import 的变量。计数式让"每个需求各记一笔,撤销只撤自己那笔",直到计数归零才真正从集合移除。这是 transform 里少数"必须用 Map<symbol, number> 而非 Set"的地方(另一个是 identifiers)。

  2. helper 注册点是分散的 。列表(高亮:createVNodeCall 内部会自动注册 vnode helper):

helper 触发点
OPEN_BLOCK / CREATE_ELEMENT_BLOCK / CREATE_BLOCK / CREATE_ELEMENT_VNODE / CREATE_VNODE createVNodeCall 内根据 isBlock/isComponent 自动注册(ast.ts:640-650)
FRAGMENT createRootCodegen(transform.ts:390)、vIf 分支包装(vIf.ts:279)、vFor(vFor.ts:112,171)
CREATE_COMMENT traverseNode 的 COMMENT 分支(transform.ts:456)、createCodegenNodeForBranch 的 else 占位(vIf.ts:230)
TO_DISPLAY_STRING traverseNode 的 INTERPOLATION 分支(transform.ts:462)、compiler-dom 的 transformVText(vText.ts:31-34)
CREATE_TEXT transformText(transformText.ts:111)
RENDER_LIST vFor.transformFor(vFor.ts:59)、buildSlots 对 v-for 插槽(vSlot.ts:274)
RENDER_SLOT transformSlotOutlet(transformSlotOutlet.ts:47)
CREATE_SLOTS buildSlots 有动态插槽时(vSlot.ts:370)
WITH_CTX buildSlots 开头无条件注册(vSlot.ts:126)
WITH_DIRECTIVES createVNodeCall 有 directives 时(ast.ts:647-649)
RESOLVE_COMPONENT / RESOLVE_DYNAMIC_COMPONENT resolveComponentType(transformElement.ts:291,318,327)
RESOLVE_DIRECTIVE buildDirectiveArgs(transformElement.ts:921)
MERGE_PROPS / TO_HANDLERS buildProps 里 v-bind/v-on 无参展开(transformElement.ts:677,736)
NORMALIZE_CLASS / NORMALIZE_STYLE / NORMALIZE_PROPS / GUARD_REACTIVE_PROPS buildProps 的 props 预归一化(transformElement.ts:805,821,828,841)
SET_BLOCK_TRACKING transformOnce(vOnce.ts:15)
WITH_MEMO / IS_MEMO_SAME transformMemo(vMemo.ts:32)、vFor 的 memo 分支(vFor.ts:229)
TO_HANDLER_KEY transformOn 动态事件名(vOn.ts:63,71)
CAMELIZE transformBind 的 .camel(vBind.ts:53,56)
UNREF resolveSetupReference(transformElement.ts:370,376)
TO_DISPLAY_STRING(TS/$setup) 同上

(注意 WITH_CTX 是 buildSlots 无条件 注册的------只要元素是组件且有 children,就会走 buildSlots,buildSlots 第一行就 context.helper(WITH_CTX)(vSlot.ts:126)。即使 slotsProperties 最终为空?不------它至少会有 default 槽,所以 withCtx 一定会被打印。这解释了为什么每个有子节点的组件都会生成 _withCtx(() => [...])。)

6.3.3 映射:helperNameMap 与 registerRuntimeHelpers

收集到的是 Symbol,打印时要用字符串名字。映射表在 runtimeHelpers.ts:86-126:

ts 复制代码
export const helperNameMap: Record<symbol, string> = {
  [FRAGMENT]: `Fragment`,
  [TELEPORT]: `Teleport`,
  [OPEN_BLOCK]: `openBlock`,
  [CREATE_BLOCK]: `createBlock`,
  [CREATE_ELEMENT_BLOCK]: `createElementBlock`,
  [CREATE_VNODE]: `createVNode`,
  [CREATE_ELEMENT_VNODE]: `createElementVNode`,
  ...
  [WITH_MEMO]: `withMemo`,
  [IS_MEMO_SAME]: `isMemoSame`,
}

codegen 里到处用它(codegen.ts:104 的 aliasHelper、codegen.ts:180 的 context.helper):

ts 复制代码
const aliasHelper = (s: symbol) => `${helperNameMap[s]}: _${helperNameMap[s]}`
...
helper(key) {
  return `_${helperNameMap[key]}`
},

为什么不能直接把字符串当 helper id? 因为 Vue 的编译器是跨包协作 的:compiler-core 定义核心 helper 的 Symbol,compiler-dom 与 compiler-ssr 各自定义/注册自己的(runtimeHelpers.ts:128-132):

ts 复制代码
export function registerRuntimeHelpers(helpers: Record<symbol, string>): void {
  Object.getOwnPropertySymbols(helpers).forEach(s => {
    helperNameMap[s] = helpers[s]
  })
}

SFC/SSR 场景下,compiler-dom 的 runtimeHelpers.ts 里注册了 V_SHOW(对应 vShow)、TRANSITION、V_MODEL_* 等(compiler-dom/src/runtimeHelpers.ts),compiler-ssr 又注册了 ssrRenderAttrs、SSR_INTERPOLATE 等。用 Symbol 作键,各包互不覆盖、增量注册。若用字符串 id,两个包都想叫 "transition" 就会撞名;Symbol 天然唯一。这就是 05 章末尾提到的"Symbol 是跨包安全的身份证"的落地细节。

一个实战陷阱 :既然映射靠 registerRuntimeHelpers 注册,那么在只 import @vue/compiler-core 的 baseCompile 而没走 compiler-dom/compiler-ssr 入口的脚本里,helperNameMap[OPEN_BLOCK] 会返回 undefined,生成的代码是 _undefined(...)。05 章的实战脚本和本章 6.12 的实战都会显式注意这一点。

6.3.4 打印:genModulePreamble 与 genFunctionPreamble

transform() 末尾把计数 Map 的 keys 封存成 root.helpers = new Set([...context.helpers.keys()])(transform.ts:344)。注意 Map.keys() 只给计数非零 的键(removeHelper 减到 0 时会 delete,transform.ts:217),所以最终 ast.helpers 恰好是"真正被用到"的集合。codegen.generate 再读它(codegen.ts:302):

ts 复制代码
const helpers = Array.from(ast.helpers)
const hasHelpers = helpers.length > 0

然后分两种模式打印:

module 模式(genModulePreamble,codegen.ts:470-535):

ts 复制代码
if (ast.helpers.size) {
  const helpers = Array.from(ast.helpers)
  if (optimizeImports) {
    push(`import { ${helpers.map(s => helperNameMap[s]).join(', ')} } from ${JSON.stringify(runtimeModuleName)}\n`, ...)
    push(`\n// Binding optimization for webpack code-split\nconst ${helpers.map(s => `_${helperNameMap[s]} = ${helperNameMap[s]}`).join(', ')}\n`, ...)
  } else {
    push(`import { ${helpers.map(s => `${helperNameMap[s]} as _${helperNameMap[s]}`).join(', ')} } from ${JSON.stringify(runtimeModuleName)}\n`, ...)
  }
}

两条分支的动机(源码注释写得很清楚):

  • 默认(optimizeImports: false):生成 import { createElementVNode as _createElementVNode } from "vue",复用 import 名 ,生成的代码里直接用 _createElementVNode;
  • optimizeImports: true:生成 import { createElementVNode } from "vue" + const _createElementVNode = createElementVNode。为什么多此一举? 因为 webpack 做 code-split 时,如果把一个 import 绑定当函数直接调用((0, a.b)(...) 或 Object(a.b)(...)),会引入包装开销与体积膨胀。先赋给本地 const 再调用,const 是稳定的局部绑定,webpack 能直接内联。代价是每个组件多约 50 字节,但不随模板规模线性增长 (注释:a constant ~50b cost per-component instead of scaling with template size)。

function 模式(genFunctionPreamble,codegen.ts:408-468):

ts 复制代码
const helpers = Array.from(ast.helpers)
if (helpers.length > 0) {
  if (!__BROWSER__ && prefixIdentifiers) {
    push(`const { ${helpers.map(aliasHelper).join(', ')} } = ${VueBinding}\n`, NewlineType.End)
  } else {
    push(`const _Vue = ${VueBinding}\n`, NewlineType.End)
    if (ast.hoists.length) {
      const staticHelpers = [CREATE_VNODE, CREATE_ELEMENT_VNODE, CREATE_COMMENT, CREATE_TEXT, CREATE_STATIC]
        .filter(helper => helpers.includes(helper))
        .map(aliasHelper).join(', ')
      push(`const { ${staticHelpers} } = _Vue\n`, NewlineType.End)
    }
  }
}

两种子模式:

  • prefixIdentifiers 为真(module 模式强制为真):const { openBlock: _openBlock, ... } = Vue 生成在函数体顶部 (codegen.ts:428-432,注释:只声明一次);
  • with 模式(prefixIdentifiers 为假,即 new Function 运行时编译):生成 const _Vue = Vue,且 helper 声明放进 with(_ctx){...} 块内部 (codegen.ts:342-349),避免每次访问 helper 都触发 with 的 in 检查。但 hoists 必须在函数体外 (genHoists 在 codegen.ts:465 被调用,此时 with 还没进入),所以被提升的静态节点用到的 createElementVNode / createCommentVNode 等要额外在函数体外再声明一次 ------这就是 staticHelpers 那段代码的动机。

6.3.5 tree-shaking 账本:这套机制到底省了什么

收益一:只 import 用到的 helper。 一个纯静态模板 <div>static</div> 只生成:

js 复制代码
const _hoisted_1 = /*#__PURE__*/_createElementVNode("div", null, "static", -1 /* CACHED */, ...)
export function render(_ctx, _cache) { return _hoisted_1 }

注意它没有 openBlock、没有 createElementBlock------因为静态节点被提升后,getConstantType 把它的 block 降级成普通 vnode(cacheStatic.ts:342-348 里的 removeHelper(OPEN_BLOCK)),还打了 CACHED(-1)并提升为 _hoisted_1。helper 集合因此只有 createElementVNode + createStaticVNode(若被 stringify)。如果 helpers 是用 Set 且不撤销,这个 openBlock 会白 import 一次 (其实有 tree-shaking 兜底,但 import {openBlock} 本身会在 bundle 里保留引用------ESM tree-shaking 能摇掉未使用的 import,但前提是打包器能静态分析且没有副作用标注问题;Vue 干脆在编译期就不生成它,更干净)。

收益二:/*#__PURE__*/ 注解。 codegen.ts:102 定义 const PURE_ANNOTATION = \/@PURE /`,在 hoists 与 vnode 调用前打印(codegen.ts:571、codegen.ts:858、codegen.ts:894)。context.pure = true只在genHoists 期间开启(codegen.ts:571,584)。@PURE 告诉压缩器"这个调用无副作用,如果结果没被用就可以删除"。**未使用的静态提升节点**(比如v-if为 false 的分支里的_hoisted_n)就能被完全摇掉。这是一个从 transform(用 hoist()` 决定什么算静态)到 codegen(打印注解)的完整链条。

收益三:helper 集合的"封存"时机。 root.helpers = new Set([...context.helpers.keys()])(transform.ts:344)发生在 cacheStatic 之后 。为什么关键?因为 cacheStatic 会 removeHelper(撤销 block helper)------如果封存在 traverseNode 之后、cacheStatic 之前,被撤销的 helper 仍会留在集合里,多 import。顺序即语义 ,这是 transform() 那 5 个阶段顺序的又一处硬约束。


6.4 createStructuralDirectiveTransform 通用骨架

6.4.1 契约:什么是"结构指令"

ts 复制代码
// A structural directive transform is technically also a NodeTransform;
// Only v-if and v-for fall into this category.
export type StructuralDirectiveTransform = (
  node: ElementNode,
  dir: DirectiveNode,
  context: TransformContext,
) => void | (() => void)

(transform.ts:71-77。)v-if/v-else-if/v-else/v-for 与 v-slot 统称"结构指令":它们不是给元素加一个 prop (那是 v-bind/v-on/v-show 的语义),而是改变模板的结构 ------决定某个节点是否存在、重复几次、或改变它在容器中的角色。因此它们不能走 directiveTransforms(那条路是"往 VNodeCall.props 里塞属性"),必须走 nodeTransforms,并在运行时把当前 ElementNode 从父 children 里替换/移除。

6.4.2 为什么结构指令必须"替换当前节点"

考虑 <div v-if="ok">A</div><p v-else>B</p>。v-if 的语义是"二选一渲染",而 AST 里它们是两个同级元素 。如果 v-if transform 只改 div 而不动结构,codegen 会看到两个顶层 vnode------无法表达"二选一"。所以 v-if 必须:

  1. 创建 IfNode { branches: [branch] };
  2. context.replaceNode(ifNode) 把 div 在父 children 里的槽位换成 IfNode;
  3. 把 p v-else 通过 context.removeNode() 从父 children 摘掉,塞进 ifNode.branches(vIf.ts:154-155,187)。

v-for 同理:ForNode 是容器,children 是循环体(vFor.ts:286-297)。"替换+摘除"是结构指令唯一能表达结构变化的手段 ,因为 AST 的 children 是数组,没有"语法树内部的 if 节点"这种原生表达------必须人造一个 IfNode/ForNode 容器再替换进去。

6.4.3 骨架逐行走读

createStructuralDirectiveTransform(transform.ts:488-519):

ts 复制代码
export function createStructuralDirectiveTransform(
  name: string | RegExp,
  fn: StructuralDirectiveTransform,
): NodeTransform {
  const matches = isString(name)
    ? (n: string) => n === name
    : (n: string) => name.test(n)

  return (node, context) => {
    if (node.type === NodeTypes.ELEMENT) {
      const { props } = node
      // structural directive transforms are not concerned with slots
      // as they are handled separately in vSlot.ts
      if (node.tagType === ElementTypes.TEMPLATE && props.some(isVSlot)) {
        return
      }
      const exitFns = []
      for (let i = 0; i < props.length; i++) {
        const prop = props[i]
        if (prop.type === NodeTypes.DIRECTIVE && matches(prop.name)) {
          // structural directives are removed to avoid infinite recursion
          // also we remove them *before* applying so that it can further
          // traverse itself in case it moves the node around
          props.splice(i, 1)
          i--
          const onExit = fn(node, prop, context)
          if (onExit) exitFns.push(onExit)
        }
      }
      return exitFns
    }
  }
}

步骤分解:

  1. matches 工厂 :v-if 传入 /^(?:if|else|else-if)$/(vIf.ts:44-46),v-for 传入字符串 'for'(vFor.ts:52-54)。用闭包把"名字匹配逻辑"固化,避免每次调用都判断 isString。
  2. 只处理 ELEMENT :其他类型直接返回 undefined(不是 exitFns)------注意 return(无值)与 return exitFns(可能是空数组)的区别:空数组 [] 是 truthy,会让 traverseNode 把它 push(...[])(展开后什么都没加,无害);而 undefined 让 traverseNode 跳过 push。两者行为等价但语义不同。这里 ELEMENT 且无匹配指令 时返回空数组 exitFns(因为 return exitFns 在函数体末尾),traverseNode 会 exitFns.push(...[])------照样无害。
  3. TEMPLATE + v-slot 提前 return (transform.ts:501-503):<template v-slot:foo> 上的 v-if 不该被 transformIf 当作普通 v-if 处理,因为 v-slot 的处理在 vSlot.ts 的 buildSlots 里(vSlot.ts:218-230 对插槽做 condition 处理)。若不提前 return,<template v-slot:foo v-if="x"> 会被 transformIf 拆成 IfNode,而 buildSlots 又想在 <template v-slot> 上找 v-if(findDir(slotElement, 'if'),vSlot.ts:218)------两者会抢同一个指令,导致结构混乱。这是"归属权先到先得"的仲裁。
  4. 遍历 props 并 splice(i, 1); i-- :摘除匹配的指令属性。两个注释点明了动机:
    • "structural directives are removed to avoid infinite recursion"------v-if 的 dir.exp 在 processIf 里会被 processExpression 处理;如果不摘除,transformExpression(排在 transformIf 之后,compile.ts:43-51)会再处理一次 dir.exp,而此时 dir 已经不再挂在 node 上......实际上更直接的原因是:同一个 v-if 在 <template v-if> 的 children 被再次 traverseNode 时不能再次触发 transformIf (否则无限递归地造 IfNode)。摘除保证了"一个指令只被消费一次"。
    • "we remove them before applying so that it can further traverse itself in case it moves the node around"------先摘除再调用 fn。这样当 fn 内部 context.replaceNode(forNode/ifNode) 或把 node.children 移进 branch 时,node.props 里已经没有这条指令,再遍历 node 时不会又匹配到它。
    • i-- 配合 splice:删除当前元素后,后面的元素前移,i-- 抵消 for 的 i++,保证不跳过。而 props[i] 的 prop 变量已经拿到手,splice 不影响它。
  5. 收集 exitFns 后统一返回 :每个匹配的指令产生一个退出回调(v-if/v-for 都在此生成 codegenNode)。多个结构指令?语法上 <div v-if v-for> 只该有一个(见 6.4.5),但代码允许收集多个(后文会看到 v-if 与 v-for 的互斥警告)。

6.4.4 v-if / v-for / v-slot 三者共用逻辑对照

维度 v-if v-for v-slot
是否用本骨架 是(createStructuralDirectiveTransform,vIf.ts:44) 是(vFor.ts:52) 否 (结构处理在 buildSlots,见 6.7)
匹配 `/^(?:if else else-if)$/`
替换成的容器 IfNode{ branches: IfBranchNode[] } ForNode{ source, parseResult, children } ---
消费时机 进入时建容器,退出时 createCodegenNodeForBranch 进入时建 renderList(...) 外壳,退出时装 iterator ---
作用域 无(condition 是同一作用域求值) scopes.vFor++ + addIdentifiers(value/key/index) scopes.vSlot++ + addIdentifiers(slotProps)
优先级 vs element 在 transformElement 之前 → 决定元素是否被 codegen 同 独立于 transformElement(buildSlots 是 transformElement 内部回调)
提前 return 条件 --- --- TEMPLATE + v-slot(对本骨架而言)

为什么 v-slot 不走本骨架? 因为 v-slot 不"替换节点",它改变的是"children 如何被编译成 slots 对象"。一个 <Comp><template v-slot:a>...</template></Comp> 里,<template> 本身会消失 (被 buildSlots 消化成 slots 对象的一个 property),但它的 children 会被重新组织成函数体------这是一种"就地消化"而非"替换成容器"。所以在 buildSlots(vSlot.ts:118-380)里,<template v-slot> 是被遍历 children 时按需识别 的(vSlot.ts:173-186),而不是通过 nodeTransforms 的替换机制。

不过 v-slot 仍有两个 nodeTransform(vSlot.ts:48 的 trackSlotScopes、vSlot.ts:75 的 trackVForSlotScopes),它们只做作用域记账 (scopes.vSlot++、addIdentifiers),不做结构替换。这再次印证:nodeTransforms 的职责可以很轻(纯记账、纯注册 helper),也可以很重(替换节点),骨架只负责调度。

6.4.5 一个经典边界:v-if 与 v-for 的优先级

Vue 2 里 v-for 优先级高于 v-if;Vue 3 里反过来------v-if 优先级更高 。这个结论如何从源码读到?看 compile.ts:32-63 的数组顺序:

ts 复制代码
[
  transformVBindShorthand,
  transformOnce,
  transformIf,       // ← 先
  transformMemo,
  transformFor,      // ← 后
  ...
]

transformIf 在 transformFor 之前 。而 transformIf 会 context.replaceNode(ifNode)(vIf.ts:123)------这个替换发生在 transformFor 拿到节点之前。所以对 <div v-if="ok" v-for="i in list">:

  1. transformIf 先把 div 换成 IfNode(branch 的 children 里装着"还带 v-for 的 div");
  2. 遍历到 IF_BRANCH 的 children 时,重新 traverseNode(div原件),此时才轮到 transformFor 把它换成 ForNode。

结果:if 在外、for 在内------即"先判断条件,再循环"。

v-if 与 v-for 写在同一元素上不会在 compiler-core 报错:v-if 先被 replaceNode 隔离成 IfNode,branch children 里的元素再走 v-for,二者共存(vFor.ts:130-145 的 DEV 警告针对的是 <template v-for> 内部子元素的 key 位置,X_V_FOR_TEMPLATE_KEY_PLACEMENT)。官方建议拆开写。这就是"优先级"在源码里只体现为 transform 数组顺序的最好例证:没有显式优先级字段,顺序即优先级。

6.4.6 骨架的返回值契约与 traverseNode 的配合

把 6.2 与 6.4 串起来:骨架返回的 node transform 在进入阶段被调用并返回 exitFns;traverseNode 把它们展开进本节点的 exitFns,子节点遍历完后逆序执行。因为结构指令的退出回调必须等 children 编译完 (v-for 要知道 childBlock 形态才能定 renderList 第二参数;v-if 要等 branch 的 children 有 codegenNode),所以"返回 exitFn"不只是顺手,而是骨架的必然选择。


6.5 vIf 深度剖析:分支容器、稳定 key 与条件表达式

6.5.1 入口:transformIf 与"兄弟累计 key"算法

transformIf 由骨架包装(vIf.ts:44-82):

ts 复制代码
export const transformIf: NodeTransform = createStructuralDirectiveTransform(
  /^(?:if|else|else-if)$/,
  (node, dir, context) => {
    return processIf(node, dir, context, (ifNode, branch, isRoot) => {
      // #1587: We need to dynamically increment the key based on the current
      // node's sibling nodes, since chained v-if/else branches are
      // rendered at the same depth
      const siblings = context.parent!.children
      let i = siblings.indexOf(ifNode)
      let key = 0
      while (i-- >= 0) {
        const sibling = siblings[i]
        if (sibling && sibling.type === NodeTypes.IF) {
          key += sibling.branches.length
        }
      }

      // Exit callback. Complete the codegenNode when all children have been
      // transformed.
      return () => {
        if (isRoot) {
          ifNode.codegenNode = createCodegenNodeForBranch(branch, key, context) as IfConditionalExpression
        } else {
          // attach this branch's codegen node to the v-if root.
          const parentCondition = getParentCondition(ifNode.codegenNode!)
          parentCondition.alternate = createCodegenNodeForBranch(branch, key + ifNode.branches.length - 1, context)
        }
      }
    })
  },
)

逐点:

  1. 正则 /^(?:if|else|else-if)$/ :注意顺序无关紧要(正则是精确匹配三选一)。它同时匹配 v-if、v-else、v-else-if(parser 已把 v-else-if 的 name 归一化为 'else-if')。
  2. processCodegen 回调 是 transformIf 传给 processIf 的"后半段逻辑"。processIf 负责结构改写 (建容器、找兄弟),processCodegen 负责生成 codegenNode(在退出时)。
  3. 兄弟累计 key 算法(vIf.ts:51-59) :这是 #1587 的修复。while (i-- >= 0) 从 ifNode 的位置往前 扫,每遇到一个 IfNode 兄弟就把它的 branches.length 累加到 key。目的:给每个分支一个"全局唯一且稳定"的整数 key,用于 createChildrenCodegenNode 里的 keyProperty(vIf.ts:246-254)。
    • 为什么 key 必须唯一? 因为 v-if/v-else 的分支会渲染成同层级的不同 vnode 。运行时 diff 时,如果分支 A 的根 vnode 和分支 B 的根 vnode 没有 key ,patch 算法会认为"它们是同一个节点",从而错误复用(比如从 <div> 切到 <span> 时复用 div 的 DOM,只改 tag 属性------这在某些场景下是合法的,但会丢失状态/dom 引用)。给每个分支一个不同的 key(0、1、2...)强制 patch 时"不复用",触发正确的卸载/挂载。
    • 为什么是"累计兄弟"而不是"用 branch 自己的索引"? 因为 IfNode 的每个 branch 是分开遍历、分开注册退出回调的。若只用 branchIndex(0-based),两个相邻但独立的 IfNode 链会产生相同的 key (都从 0 开始),导致 _createElementBlock(..., 0) 与另一个 _createElementBlock(..., 0) 撞 key。累计所有前置 IfNode 的 branches.length,就把"整个模板里此前出现过的所有 v-if 分支数"当作偏移,保证 key 全局唯一。这是"局部信息不足、需要兄弟上下文"的典型例子。
  4. isRoot 分支 :
    • isRoot === true(dir.name === 'if',即 v-if 本源):创建 IfNode.codegenNode;
    • isRoot === false(v-else/v-else-if):用 getParentCondition(ifNode.codegenNode!) 找到当前条件链的最末端 conditional,把它的 alternate 设为新分支的 codegenNode。这就是"三目表达式层层嵌套"的构造:cond1 ? A : cond2 ? B : C。

6.5.2 processIf:结构改写的主流程

processIf(vIf.ts:85-205)是 client 与 SSR共享 的(注释 target-agnostic transform used for both Client and SSR)。它先做三件校验/预处理(vIf.ts:95-114):

ts 复制代码
if (dir.name !== 'else' && (!dir.exp || !dir.exp.content.trim())) {
  context.onError(createCompilerError(ErrorCodes.X_V_IF_NO_EXPRESSION, dir.loc))
  dir.exp = createSimpleExpression(`true`, false, dir.exp ? dir.exp.loc : node.loc)
}
if (!__BROWSER__ && context.prefixIdentifiers && dir.exp) {
  // vIf 先于 expression transform,故自己 processExpression
  dir.exp = processExpression(dir.exp, context)
}
if (__DEV__ && __BROWSER__ && dir.exp) validateBrowserExpression(dir.exp, context)
  1. 空表达式兜底 :v-if 没写表达式(或写了空白)→ 报 X_V_IF_NO_EXPRESSION,并把 dir.exp 补成 true(v-if 等价于 v-if="true",保证后续 codegen 不为空)。注意 dir.name !== 'else':v-else 合法地没有表达式,不能报错。
  2. 提前 processExpression :注释点明"---because vIf transform is applied before expression transform"。transformIf 在数组里排在 transformExpression 之前 (compile.ts:38-51),所以 transformExpression 还没机会处理 dir.exp;processIf 只好自己处理(把 ok 变 _ctx.ok)。这是"顺序即依赖"的直接体现 :既然 transformIf 抢在 transformExpression 前面消费并摘除了该指令,它就得自己负责表达式处理。
  3. DEV 浏览器构建校验 :浏览器构建没有 Babel,只能做粗粒度语法校验(validateExpression.ts)。

然后是分叉:

① v-if 本源(vIf.ts:116-126):

ts 复制代码
if (dir.name === 'if') {
  const branch = createIfBranch(node, dir)
  const ifNode: IfNode = { type: NodeTypes.IF, loc: cloneLoc(node.loc), branches: [branch] }
  context.replaceNode(ifNode)
  if (processCodegen) return processCodegen(ifNode, branch, true)
}

createIfBranch 造出第一个 IfBranchNode,ifNode.branches = [branch],然后 replaceNode。cloneLoc 的原因:ifNode.loc 是 IfNode 自己的位置,克隆避免与 node.loc 共享引用被后续修改污染。

② v-else / v-else-if(vIf.ts:127-203) :核心是"向前找相邻的 IfNode":

ts 复制代码
const siblings = context.parent!.children
const comments = []
let i = siblings.indexOf(node)
while (i-- >= -1) {
  const sibling = siblings[i]
  if (sibling && isCommentOrWhitespace(sibling)) {
    context.removeNode(sibling)
    if (__DEV__ && sibling.type === NodeTypes.COMMENT) comments.unshift(sibling)
    continue
  }
  if (sibling && sibling.type === NodeTypes.IF) {
    if ((dir.name === 'else-if' || dir.name === 'else') &&
        sibling.branches[sibling.branches.length - 1].condition === undefined) {
      context.onError(createCompilerError(ErrorCodes.X_V_ELSE_NO_ADJACENT_IF, node.loc))
    }
    context.removeNode()
    const branch = createIfBranch(node, dir)
    sibling.branches.push(branch)
    const onExit = processCodegen && processCodegen(sibling, branch, false)
    traverseNode(branch, context) // 节点已搬走,需手动补遍历
    if (onExit) onExit()
    context.currentNode = null
  } else {
    context.onError(createCompilerError(ErrorCodes.X_V_ELSE_NO_ADJACENT_IF, node.loc))
  }
  break
}
  • while (i-- >= -1) 的 >= -1 :允许 i 减到 -1 再判 siblings[-1](undefined)。这样当 v-else 是父节点第一个子节点 (前面没东西)时,循环体第一次就以 sibling === undefined 走到 else 分支报 X_V_ELSE_NO_ADJACENT_IF。若写 >= 0,则会跳过循环、静默不处理------错误被吞掉。
  • 跳过注释/空白 :isCommentOrWhitespace(utils.ts:623-625)判定 COMMENT 或全空白 TEXT。这些"邻居噪声"在 v-else 前是允许的(<div v-if/><!-- x --><p v-else>),但必须先删除 它们(context.removeNode(sibling)),否则它们会残留在父 children 里,导致 codegen 时它们出现在 IfNode 之外被单独渲染。DEV 下把它们暂存到 comments,稍后塞回 branch.children 头部 (vIf.ts:156-168):
ts 复制代码
if (__DEV__ && comments.length && !(context.parent && context.parent.type === NodeTypes.ELEMENT && (context.parent.tag === 'transition' || context.parent.tag === 'Transition'))) {
  branch.children = [...comments, ...branch.children]
}

#3619 特判:如果父节点是 <transition>/<Transition>,不 把注释塞回------因为 transition 的子节点会被运行时特殊处理,注释会干扰"单子节点"判定。

  • "已终结分支"检查(vIf.ts:143-151) :若前一个 IfNode 的最后一个 branch 的 condition === undefined(即已经是 v-else),再出现 v-else 或 v-else-if 就报 X_V_ELSE_NO_ADJACENT_IF。这是"两个 v-else 相邻"的经典报错。
  • context.removeNode() 无参 :把当前 v-else 元素从父 children 删除(它要被"吸收"进 branch)。此时 currentNode 被置 null 并触发 onNodeRemoved(父循环 i--)。
  • createIfBranch(node, dir) :dir.name === 'else' → condition = undefined;否则 condition = dir.exp。
  • sibling.branches.push(branch) :branch 挂到前置 IfNode 上。
  • traverseNode(branch, context)(vIf.ts:191) :注释解释------"since the branch was removed, it will not be traversed. make sure to traverse here"。因为 node(v-else 元素)已被 removeNode() 移出父 children,它不会被父级 traverseChildren 再遍历,所以这里必须手动 遍历新造的 branch。这是"手动补遍历"的教科书级例子:节点搬家后,遍历责任也搬家了。
  • context.currentNode = null(vIf.ts:196) :遍历完后重置,告诉外层 traverseNode 的 for 循环"这个节点已经被移走了",从而 return(transform.ts:442-444),不再对本节点做后续处理。
  • DEV key 冲突检查(vIf.ts:170-185) :若同一 IfNode 的多个 branch 都写了相同的 key,报 X_V_IF_SAME_KEY。isSameKey(vIf.ts:305-332)同时支持 ATTRIBUTE(key="a")与 DIRECTIVE(:key="a")两种形态,比对 type/isStatic/content。

注意:processIf 遍历兄弟时,注释里的 context.removeNode(sibling) 与 context.removeNode() 都在动父 children 数组 ------而外层 traverseNode 的 traverseChildren 循环的 i 正通过 onNodeRemoved 回退。这就是 6.1.5 里那个 i-- 闭包的实战价值:删除"当前节点"和"前置兄弟"都要正确地让父循环指针不跳步,否则 v-if 链上紧挨着的元素会被跳过。

6.5.3 createIfBranch:<template v-if> 与普通元素的分野

ts 复制代码
function createIfBranch(node: ElementNode, dir: DirectiveNode): IfBranchNode {
  const isTemplateIf = node.tagType === ElementTypes.TEMPLATE
  return {
    type: NodeTypes.IF_BRANCH,
    loc: node.loc,
    condition: dir.name === 'else' ? undefined : dir.exp,
    children: isTemplateIf && !findDir(node, 'for') ? node.children : [node],
    userKey: findProp(node, `key`),
    isTemplateIf,
  }
}

(vIf.ts:207-217。)关键字段:

字段 值 语义
condition dir.exp 或 undefined(else) 该 branch 的条件表达式
children 见下 branch 的渲染内容
userKey findProp(node,'key') 用户在 branch 根元素上写的 key,用于冲突检查与后续注入
isTemplateIf tagType === TEMPLATE 区分 <template v-if> 与 <div v-if>

children 的两种形态:

  • <template v-if="ok">...</template> 且 没有 v-for:children = node.children(展开 template 里的内容,template 标签本身不产生 vnode);
  • 其他(<div v-if> 或 <template v-if v-for>):children = [node](把元素本身当唯一子节点,因为它要渲染成 vnode)。

为什么 <template v-if> 且带 v-for 时不能展开? 因为 <template v-for> 有自己的语义(v-for 会接管 template,见 6.6),此时 v-if 只是条件,children 必须是 [node] 好让它继续走 v-for 的 transform。若展开成 node.children,v-for 的 template 就消失了。

6.5.4 createCodegenNodeForBranch / createChildrenCodegenNode:条件表达式与 key 注入

退出回调最终落到 createCodegenNodeForBranch(vIf.ts:219-238):

ts 复制代码
function createCodegenNodeForBranch(branch, keyIndex, context) {
  if (branch.condition) {
    return createConditionalExpression(
      branch.condition,
      createChildrenCodegenNode(branch, keyIndex, context),
      // make sure to pass in asBlock: true so that the comment node call
      // closes the current block.
      createCallExpression(context.helper(CREATE_COMMENT), [
        __DEV__ ? '"v-if"' : '""',
        'true',
      ]),
    ) as IfConditionalExpression
  } else {
    return createChildrenCodegenNode(branch, keyIndex, context)
  }
}
  • 有 condition → 造 condition ? consequent : alternate 三目。注意 alternate 初始是 createCommentVNode("v-if", true) (DEV 下带字符串 "v-if",PROD 下空串)------这是"条件不成立时的占位注释节点"。第二参数 true = asBlock,源码注释解释:"让 comment node 调用闭包当前 block"(这样 block 树在分支切换时正确关闭)。
  • 无 condition(v-else)→ 直接返回 createChildrenCodegenNode,它将成为前一个 condition 的 alternate (由 transformIf 的 parentCondition.alternate = ... 挂上,vIf.ts:73-78)。

createChildrenCodegenNode(vIf.ts:240-303)负责"把 branch 的 children 装成 vnode 并注入 key":

ts 复制代码
function createChildrenCodegenNode(branch, keyIndex, context) {
  const { helper } = context
  const keyProperty = createObjectProperty(
    `key`, createSimpleExpression(`${keyIndex}`, false, locStub, ConstantTypes.CAN_CACHE))
  const { children } = branch
  const firstChild = children[0]
  const needFragmentWrapper = children.length !== 1 || firstChild.type !== NodeTypes.ELEMENT
  if (needFragmentWrapper) {
    if (children.length === 1 && firstChild.type === NodeTypes.FOR) {
      const vnodeCall = firstChild.codegenNode!   // 复用 v-for 的 codegenNode,避免嵌套 Fragment
      injectProp(vnodeCall, keyProperty, context)
      return vnodeCall
    } else {
      let patchFlag = PatchFlags.STABLE_FRAGMENT
      if (__DEV__ && !branch.isTemplateIf && children.filter(c => c.type !== NodeTypes.COMMENT).length === 1)
        patchFlag |= PatchFlags.DEV_ROOT_FRAGMENT
      return createVNodeCall(context, helper(FRAGMENT), createObjectExpression([keyProperty]),
        children, patchFlag, undefined, undefined, true, false, false, branch.loc)
    }
  } else {
    const ret = (firstChild as ElementNode).codegenNode as BlockCodegenNode | MemoExpression
    const vnodeCall = getMemoedVNodeCall(ret)
    if (vnodeCall.type === NodeTypes.VNODE_CALL) convertToBlock(vnodeCall, context)
    injectProp(vnodeCall, keyProperty, context)
    return ret
  }
}

三种形态:

形态 条件 生成
单 ForNode children.length === 1 && firstChild.type === FOR 直接复用 ForNode 的 codegenNode,injectProp 把 branch key 注入它的 props
需要 Fragment 多 children,或单 child 非 ELEMENT(如纯文本、注释) 造 createElementBlock(Fragment, { key: N }, [...], STABLE_FRAGMENT[, DEV_ROOT_FRAGMENT])
单元素 children.length === 1 && firstChild.type === ELEMENT 复用子元素 codegenNode,convertToBlock 变 block,注入 key

设计动机("优化掉嵌套 Fragment") :若 branch 里只有一个 v-for,v-for 自己已经生成一个 Fragment block(vFor.ts:110-122)。若再套一层 Fragment 就是"两层 fragment 包一个列表",白白多一个 vnode 且运行时多一层 diff。所以直接复用 ForNode 的 codegenNode。同理,单元素 branch 直接复用子元素的 codegenNode,再把它从 createVNode 升级成 createBlock (convertToBlock,ast.ts:887-897)------因为 branch 是"可能整块切换"的边界,必须是 block 才能被父 block 追踪为 dynamicChildren 的一项。

getMemoedVNodeCall (utils.ts:595-603):若子元素被 v-memo 包裹(codegenNode 是 withMemo(...) 调用),需要取出里面的真正 VNodeCall 才能 convertToBlock/injectProp。v-memo 见 6.10。

injectProp 的复杂度 (utils.ts:390-478):注入 key 不是简单 props.key = N,因为 props 可能已经被 normalizeProps/mergeProps/guardReactiveProps/toHandlers 包裹(getUnnormalizedProps 逐层剥开,utils.ts:371-389),也可能根本不存在(要新建对象)。还要处理 RenderSlotCall 的特殊分支(injectSlotKey,utils.ts:480-508:把 key 写进 renderSlot 调用的第 5 个参数 branchKey,而非 props)。这是 transform 里最"工程化"的一段:用户写一个 key,编译器要穿透四层包装把它放对位置。

6.5.5 AST 与生成代码对照

模板:

html 复制代码
<div v-if="ok">A</div>
<span v-else-if="maybe">B</span>
<p v-else>C</p>

结构改写后(transform 期间)AST:

text 复制代码
IfNode                          ← replaceNode 把 <div> 换成它
├─ loc / branches: [
│    IfBranchNode{ condition: _ctx.ok,     children: [div],  userKey: undefined }
│    IfBranchNode{ condition: _ctx.maybe,  children: [span], userKey: undefined }
│    IfBranchNode{ condition: undefined,   children: [p],    userKey: undefined }
│  ]
└─ codegenNode: IfConditionalExpression
     test: _ctx.ok
     consequent: ElementBlock(div, {key:0}, "A")   ← convertToBlock + injectProp
     alternate:
       IfConditionalExpression
         test: _ctx.maybe
         consequent: ElementBlock(span, {key:1}, "B")
         alternate: ElementBlock(p, {key:2}, "C")

(<span v-else-if> 与 <p v-else> 被 removeNode 摘掉、push 进 branches。)

生成的 render(PROD,粗略):

js 复制代码
return _ctx.ok
  ? (_openBlock(), _createElementBlock("div", { key: 0 }, "A"))
  : _ctx.maybe
    ? (_openBlock(), _createElementBlock("span", { key: 1 }, "B"))
    : (_openBlock(), _createElementBlock("p", { key: 2 }, "C"))
  • 每个分支的根是独立 block (_openBlock(), _createElementBlock(...)),因为它们由 convertToBlock 升级;
  • key 是 0/1/2(本 IfNode 是首个 IF,前置累计为 0,keyIndex 从 0 起);
  • 没有 v-else 的 alternate 是空的 comment :若模板是 <div v-if="ok">A</div>(无 else),生成 _ctx.ok ? block : _createCommentVNode("v-if", true)。

6.5.6 v-if 的 patchFlag 账本

v-if 是结构 不是属性 ,本身不产生 patchFlag,但影响两处:① 分支根 block 的 patchFlag 由子元素的 transformElement 决定(v-if 只负责 convertToBlock);② 分支需 Fragment 包裹时打 STABLE_FRAGMENT(vIf.ts:266),DEV 下"单有效子节点 + 若干注释"再加 DEV_ROOT_FRAGMENT(vIf.ts:270-275,PROD 剥离)。


6.6 vFor 深度剖析:renderList、key 表达式与 block 切换

6.6.1 ForNode 的数据结构与入口

v-for 把元素替换成 ForNode(ast.ts:290-299):

ts 复制代码
export interface ForNode extends Node {
  type: NodeTypes.FOR
  source: ExpressionNode
  valueAlias: ExpressionNode | undefined
  keyAlias: ExpressionNode | undefined
  objectIndexAlias: ExpressionNode | undefined
  parseResult: ForParseResult
  children: TemplateChildNode[]
  codegenNode?: ForCodegenNode
}

各字段来源(由 processFor 填充,vFor.ts:286-295):

字段 语义 对应语法
source 被遍历的源 list in v-for="item in list"
valueAlias 值别名 item
keyAlias 键别名(对象/Map) name in (item, name) in obj
objectIndexAlias 索引别名(数组下标/对象第几个) idx in (item, name, idx) in obj
parseResult ForParseResult{ source, value, key, index, finalized }(ast.ts:301-307) 缓存解析结果,避免重复 parse
children 循环体 <template v-for> → node.children;普通元素 → [node]

transformFor 由骨架包装(vFor.ts:52-256),'for' 是精确匹配(字符串形式)。它的核心结构是"进入时先造 renderList 外壳 ,退出时装 iterator 函数":

ts 复制代码
export const transformFor: NodeTransform = createStructuralDirectiveTransform(
  'for',
  (node, dir, context) => {
    const { helper, removeHelper } = context
    return processFor(node, dir, context, forNode => {
      const renderExp = createCallExpression(helper(RENDER_LIST), [forNode.source]) as ForRenderListExpression
      ...
      forNode.codegenNode = createVNodeCall(...) as ForCodegenNode
      return () => { /* 退出时装 iterator */ }
    })
  },
)

为什么要"先造外壳"? 因为 renderList 的调用是 renderList(source, (item, key, index) => { return childBlock })------第二个参数(iterator)依赖 childBlock,而 childBlock 要等 children 遍历完(子元素的 codegenNode 生成完)才知道。外壳是 source 立刻可算的部分,iterator 是延迟部分。这是"进入时能算的立刻算、退出时才能算的延后"这一原则的又一例证。

6.6.2 processFor:解析、作用域与替换

processFor(vFor.ts:259-320)步骤:

ts 复制代码
if (!dir.exp) {
  context.onError(createCompilerError(ErrorCodes.X_V_FOR_NO_EXPRESSION, dir.loc))
  return
}

const parseResult = dir.forParseResult
if (!parseResult) {
  context.onError(createCompilerError(ErrorCodes.X_V_FOR_MALFORMED_EXPRESSION, dir.loc))
  return
}

finalizeForParseResult(parseResult, context)
  1. 无表达式 → X_V_FOR_NO_EXPRESSION;
  2. forParseResult :parser 阶段(05 章)已把 "item in list" 解析成结构化结果并挂在 dir.forParseResult 上(ast.ts:207)。若解析失败,forParseResult 为空 → X_V_FOR_MALFORMED_EXPRESSION。这说明 v-for 的表达式解析是 parser 的活儿,不是 transform 的------transform 只消费结果。这是"职责分离"的一个信号。
  3. finalizeForParseResult (vFor.ts:322-380):对 source/key/index/value 逐个 processExpression(把 list → _ctx.list)。注意它只做一次(if (result.finalized) return; 开头,末尾 result.finalized = true),因为 <template v-for> 在 vSlot 里也可能读到 forParseResult(vSlot.ts:270、trackVForSlotScopes 里 vSlot.ts:84),必须幂等。参数 asParams = true 传给 key/index/value(vFor.ts:333-352):因为它们是函数参数 ((item, key, index) => ...),不能前缀化成 _ctx.item。
  4. 建 ForNode 并 replaceNode (vFor.ts:286-297):children: isTemplateNode(node) ? node.children : [node]------与 v-if 的 createIfBranch 同样逻辑:<template v-for> 展开 children,普通元素用 [node]。
  5. 作用域记账 (vFor.ts:299-319):
ts 复制代码
context.replaceNode(forNode)
scopes.vFor++
if (!__BROWSER__ && context.prefixIdentifiers) {
  value && addIdentifiers(value)
  key && addIdentifiers(key)
  index && addIdentifiers(index)
}
const onExit = processCodegen && processCodegen(forNode)
return (): void => {
  scopes.vFor--
  if (!__BROWSER__ && context.prefixIdentifiers) {
    value && removeIdentifiers(value)
    key && removeIdentifiers(index)  // 实际是各自 remove
  }
  if (onExit) onExit()
}

scopes.vFor++ 让子树的 v-for 别名进入 context.identifiers,从而使 transformExpression 不再前缀化 它们(hasScopeRef/processExpression 会识别"这是作用域变量")。退出时 scopes.vFor-- + removeIdentifiers 恢复。

注意退出回调里 onExit() 在最后 :scopes 恢复必须先于 codegen 生成?其实顺序是"先减作用域、再 codegen"。这很重要------processCodegen 返回的退出回调(vFor.ts:124-253)在生成 childBlock 时会读 context.inSSR、removeHelper 等,但不再依赖 scopes.vFor。而 pushRefVForMarker(transformElement.ts:429-438)是在 buildProps 里读 context.scopes.vFor------那是进入阶段 (子元素自己的 transformElement 进入时)发生的,那时 scopes.vFor 已经 ++ 过了。✓

6.6.3 外壳与 fragmentFlag:STABLE / KEYED / UNKEYED

进入时(vFor.ts:59-122):

ts 复制代码
const renderExp = createCallExpression(helper(RENDER_LIST), [forNode.source]) as ForRenderListExpression
const isTemplate = isTemplateNode(node)
const memo = findDir(node, 'memo')
const keyProp = findProp(node, `key`, false, true)
const isDirKey = keyProp && keyProp.type === NodeTypes.DIRECTIVE
let keyExp = keyProp && (keyProp.type === NodeTypes.ATTRIBUTE
  ? keyProp.value ? createSimpleExpression(keyProp.value.content, true) : undefined
  : keyProp.exp)
const keyProperty = keyExp ? createObjectProperty(`key`, keyExp) : null
...
const isStableFragment =
  forNode.source.type === NodeTypes.SIMPLE_EXPRESSION &&
  forNode.source.constType > ConstantTypes.NOT_CONSTANT
const fragmentFlag = isStableFragment
  ? PatchFlags.STABLE_FRAGMENT
  : keyProp
    ? PatchFlags.KEYED_FRAGMENT
    : PatchFlags.UNKEYED_FRAGMENT

forNode.codegenNode = createVNodeCall(
  context, helper(FRAGMENT), undefined, renderExp, fragmentFlag, undefined, undefined,
  true /* isBlock */, !isStableFragment /* disableTracking */, false /* isComponent */, node.loc,
) as ForCodegenNode

fragmentFlag 的判定链(这是 v-for patchFlag 的核心):

条件 flag 运行时 diff 行为
source 是常量 (constType > NOT_CONSTANT,如字面量 [1,2,3]) STABLE_FRAGMENT (1<<6) 子节点顺序不变,不需要移动,diff 只做逐项 patch,最快
source 动态,但有 key (keyProp) KEYED_FRAGMENT (1<<7) 走 keyed diff(map + LIS),允许重排复用
source 动态,且无 key UNKEYED_FRAGMENT (1<<8) 走 unkeyed diff(只按索引 patch,长度变化时截断/补挂)

三个判定要点:

  1. isStableFragment 优先于 key :常量数组顺序恒不变,即使有 key 也无需 keyed diff,直接 STABLE_FRAGMENT。这时 patchFlag 里 KEYED_FRAGMENT 反而不该出现------因为"key 比较"的代价白付。
  2. disableTracking: !isStableFragment :createVNodeCall 的第 9 个参数,落到 VNodeCall.disableTracking,最终打印成 openBlock(true)(codegen.ts:856)。当 fragment 不稳定时,需要暂停 block 追踪 ------因为不稳定的 fragment 会重排子节点,而被追踪的 dynamicChildren 是按"生成顺序"收集的,重排后顺序会错乱。暂停追踪迫使父级走完整 diff(BAIL 语义)。
  3. keyProp 用 findProp(node, 'key', false, true) :第 4 参 allowEmpty=true。为什么?:key 允许空表达式(<div v-for :key>)------此时也会被当作"有 key"(isDirKey),但 keyExp 可能为 undefined,于是 keyProperty = null。这是一个微妙点 :fragmentFlag 用 keyProp(属性存在与否)判定,而 keyProperty(对象属性)用 keyExp(表达式存在与否)判定。二者可能不一致(:key 无值 → 有 keyProp 但无 keyProperty),此时判成 KEYED_FRAGMENT 但注入不进 key------实践中 :key 必带值,这是防御性写法。

<template v-for> 的 key/exp 预备处理 (vFor.ts:76-99)------注释 #2085 / #5288:

ts 复制代码
if (!__BROWSER__) {
  // `<template v-for>` 的节点会被丢弃、不被遍历,其 :key / v-memo 需手动 processExpression
  if (isTemplate && memo)
    memo.exp = processExpression(memo.exp!, context)
  if ((isTemplate || memo) && keyProperty && isDirKey) {
    keyExp = keyProp.exp = keyProperty.value = processExpression(keyProperty.value, context)
    if (memo) context.vForMemoKeyedNodes.add(node)
  }
}

"节点被丢弃,不会被遍历" 是这段注释的关键:<template v-for :key="x" v-memo="[y]"> 里,<template> 会变成 ForNode、其 children 被展开,tag 上的 props(:key、v-memo)不会被任何 transform 再处理 ,所以这里要手动 processExpression。而 vForMemoKeyedNodes.add(node) 是给 transformExpression 的信号(transformExpression.ts:71-77):"key 已经处理过了,别再处理一遍"。

6.6.4 退出回调和 childBlock 的三种形态

退出回调(vFor.ts:124-253)先做 DEV 的 <template v-for> key 位置检查(vFor.ts:130-145,X_V_FOR_TEMPLATE_KEY_PLACEMENT)------<template v-for> 上的 key 必须写在 <template> 上,不能写在内部子元素上(否则运行时无法正确 keyed diff),这也是注释 #2099。

然后计算 childBlock(iterator 函数体返回的那个块 ),三种形态(vFor.ts:147-216):

ts 复制代码
const needFragmentWrapper = children.length !== 1 || children[0].type !== NodeTypes.ELEMENT
const slotOutlet = isSlotOutlet(node) ? node
  : isTemplate && node.children.length === 1 && isSlotOutlet(node.children[0]) ? node.children[0] : null

if (slotOutlet) {
  childBlock = slotOutlet.codegenNode as RenderSlotCall
  if (isTemplate && keyProperty) injectProp(childBlock, keyProperty, context)
} else if (needFragmentWrapper) {
  childBlock = createVNodeCall(context, helper(FRAGMENT), keyProperty ? createObjectExpression([keyProperty]) : undefined,
    node.children, PatchFlags.STABLE_FRAGMENT, undefined, undefined, true, undefined, false)
} else {
  childBlock = (children[0] as PlainElementNode).codegenNode as VNodeCall
  if (isTemplate && keyProperty) injectProp(childBlock, keyProperty, context)
  const shouldUseBlock = !isStableFragment || childBlock.isBlockRequired === true
  if (childBlock.isBlock !== shouldUseBlock) {   // 角色切换,先撤销旧 helper
    if (childBlock.isBlock) {
      removeHelper(OPEN_BLOCK)
      removeHelper(getVNodeBlockHelper(context.inSSR, childBlock.isComponent))
    } else {
      removeHelper(getVNodeHelper(context.inSSR, childBlock.isComponent))
    }
  }
  childBlock.isBlock = shouldUseBlock
  if (childBlock.isBlock) {
    helper(OPEN_BLOCK); helper(getVNodeBlockHelper(context.inSSR, childBlock.isComponent))
  } else {
    helper(getVNodeHelper(context.inSSR, childBlock.isComponent))
    if (childBlock.needsPatch)
      childBlock.patchFlag = ((childBlock.patchFlag ?? 0) | PatchFlags.NEED_PATCH) as PatchFlags
  }
}

形态表:

形态 触发 childBlock
slot outlet <slot v-for> 或 <template v-for><slot/></template> 直接复用 renderSlot 调用;<template> 形式再注入 key 到第 5 参数
fragment wrapper 多 child 或单 child 非 ELEMENT 造 Fragment block(key 注入 props)
单元素 单 child 且 ELEMENT 复用子元素 codegenNode,按 shouldUseBlock 决定升级/降级 block

block 切换的三态逻辑(最容易读错的段落):

  • shouldUseBlock = !isStableFragment || childBlock.isBlockRequired === true。含义:只要 fragment 不稳定 (会重排),childBlock 必须是 block ------因为每个循环项要独立成为父 fragment 的一个 dynamicChildren 条目,这样重排时运行时能正确追踪。若 fragment 稳定(常量源),childBlock 可以是普通 vnode(更省)。
  • isBlockRequired 是 buildProps 里给"含自定义指令 / v-on above-update / 动态 key"的元素打的标记(transformElement.ts:231-233),要求"后续别把我降级成普通 vnode"(因为自定义指令的 beforeUpdate 必须早于 children 更新,需要 block 语义)。因此 childBlock.isBlockRequired === true 也强制 shouldUseBlock = true。
  • removeHelper 的配对 :childBlock.isBlock !== shouldUseBlock 时要"撤销旧角色、注册新角色"的 helper。从 block 变 vnode 时 removeHelper(OPEN_BLOCK) + removeHelper(getVNodeBlockHelper(...));从 vnode 变 block 时 removeHelper(getVNodeHelper(...))。若用了 Set,这里的 removeHelper(OPEN_BLOCK) 会误删别处仍需要的 openBlock------这正是 6.3.2 里"计数式 helper"的动机的最典型现场。
  • 降级成普通 vnode 时要补 NEED_PATCH :if (childBlock.needsPatch) childBlock.patchFlag |= NEED_PATCH。needsPatch 是 buildProps 算出的"这个 vnode 有 ref / vnode hook / runtime directive,即便没有其它 patchFlag 也需要被 patch"(transformElement.ts:768-773)。作为普通 vnode 它不会被父 block 自动追踪,必须显式打 NEED_PATCH 让父级在 diff 时搭理它。

6.6.5 createForLoopParams 与"补 _"算法

ts 复制代码
export function createForLoopParams({ value, key, index }: ForParseResult, memoArgs: ExpressionNode[] = []): ExpressionNode[] {
  return createParamsList([value, key, index, ...memoArgs])
}

function createParamsList(args: (ExpressionNode | undefined)[]): ExpressionNode[] {
  let i = args.length
  while (i--) {
    if (args[i]) break
  }
  return args.slice(0, i + 1).map((arg, i) => arg || createSimpleExpression(`_`.repeat(i + 1), false))
}

(vFor.ts:382-399。)用途:生成 (item, key, index) => ... 的函数参数列表。两个细节:

  1. 尾部截断 :从后往前找最后一个非空参数,slice(0, i+1) 丢掉尾部空参数。所以 v-for="item in list" 只生成 (item),不生成 (item, _, _)。
  2. 中间空洞补 _ :v-for="(item, , index) in list"(写了 index 但跳过 key)时,位置 1 是 undefined,用 _.repeat(2) = __ 占位(JS 允许参数名 _/__)。补 _ 而非报错:这是刻意的宽容------用户可能只想要 index。

(memoArgs 是 v-memo 时为 _cached 追加的参数,见 6.6.6。)

6.6.6 memo 分支:_cached / _cache / isMemoSame

当 v-for 与 v-memo 同用时(vFor.ts:218-252):

ts 复制代码
if (memo) {
  const loop = createFunctionExpression(
    createForLoopParams(forNode.parseResult, [createSimpleExpression(`_cached`)]))
  loop.body = createBlockStatement([
    createCompoundExpression([`const _memo = (`, memo.exp!, `)`]),
    createCompoundExpression([
      `if (_cached && _cached.el`,
      ...(keyExp ? [` && _cached.key === (`, keyExp, `)`] : []),
      ` && ${context.helperString(IS_MEMO_SAME)}(_cached, _memo)) return _cached`,
    ]),
    createCompoundExpression([`const _item = `, childBlock as any]),
    createSimpleExpression(`_item.memo = _memo`),
    createSimpleExpression(`return _item`),
  ])
  renderExp.arguments.push(loop as ForIteratorExpression,
    createSimpleExpression(`_cache`), createSimpleExpression(String(context.cached.length)))
  context.cached.push(null)
} else {
  renderExp.arguments.push(createFunctionExpression(
    createForLoopParams(forNode.parseResult), childBlock, true /* force newline */) as ForIteratorExpression)
}

这是 v-for 最烧脑的一段,逐行解释:

  • iterator 变成带 body 的函数 :(item, key, index, _cached) => { ... },_cached 是上次渲染的缓存 vnode。body 四条语句:① const _memo = (memo.exp)(求值依赖数组);② if (_cached && _cached.el && _cached.key === (keyExp) && isMemoSame(_cached, _memo)) return _cached(命中缓存则复用 ,el/key/isMemoSame 三条件缺一不可);③ const _item = childBlock;④ _item.memo = _memo; return _item。
  • renderList 的第 3、4 个参数 :createSimpleExpression('_cache') 与 String(context.cached.length)。这是 renderList 的签名 renderList(source, renderItem, cache, index)------第 3 个是缓存数组(render 函数的 _cache),第 4 个是该 v-for 在缓存数组里的起始槽位。
  • context.cached.push(null) :占位 。注意这里 push 的是 null,不是表达式------因为 v-for 的 memo 缓存不由 context.cache() 生成 _cache[n] 表达式,而是运行时按"元素个数"动态使用从 index 开始的连续槽位。push(null) 只是把 cached.length 推进一位,让后续 的 context.cache() 用到的索引不与本次保留的槽位冲突。这是 cached 数组里唯一会出现 null 的地方 (另一个是 transformMemo 也 push null,见 6.10)。

"为什么要重新推导 renderList 的签名" :renderList 在 runtime-core 里定义(vFor.ts 消费它,不定义它)。允许它携带 cache/index 参数的设计叫 "v-memo + v-for 的局部记忆化" :整个列表里每个 item 都能独立命中自己的上次 vnode,避免 v-memo 无效。代价是运行时 renderList 要多写一个分支。

6.6.7 key 与 ref_for:跨 v-for 的引用标记

buildProps 里的 pushRefVForMarker (transformElement.ts:429-438):

ts 复制代码
const pushRefVForMarker = () => {
  if (context.scopes.vFor > 0) {
    properties.push(createObjectProperty(
      createSimpleExpression('ref_for', true), createSimpleExpression('true')))
  }
}

触发点有三处:静态 ref 属性(transformElement.ts:513-515)、:ref(transformElement.ts:614-616)、v-bind 对象(transformElement.ts:668-669)。作用:给 vnode props 加一个 ref_for: true,让运行时知道"这个 ref 在 v-for 里",于是把 ref 值收集成数组 (useTemplateRef/setRef 的多元素语义)。context.scopes.vFor > 0 是必要条件------只有包在 v-for 内的 ref 才需要数组化。


6.7 vSlot 深度剖析:插槽对象、动态插槽与 slot outlet

6.7.1 两个"记账型" nodeTransform

vSlot 的结构处理不在 nodeTransforms 里,但有两个 nodeTransform 负责作用域记账 (vSlot.ts:48-98):

ts 复制代码
export const trackSlotScopes: NodeTransform = (node, context) => {
  if (node.type === NodeTypes.ELEMENT && (node.tagType === ElementTypes.COMPONENT || node.tagType === ElementTypes.TEMPLATE)) {
    const vSlot = findDir(node, 'slot')
    if (vSlot) {
      const slotProps = vSlot.exp
      if (!__BROWSER__ && context.prefixIdentifiers) {
        slotProps && context.addIdentifiers(slotProps)
      }
      context.scopes.vSlot++
      return () => {
        if (!__BROWSER__ && context.prefixIdentifiers) {
          slotProps && context.removeIdentifiers(slotProps)
        }
        context.scopes.vSlot--
      }
    }
  }
}
  • findDir(node, 'slot') (不传 allowEmpty)------只有非空 v-slot (v-slot="{ x }")才需要记账,因为只有它引入作用域变量(slotProps)。
  • slotProps 被 addIdentifiers:让 <slot v-slot="{ x }"> 的模板体里 x 不被前缀化成 _ctx.x。这是 v-slot 作用域能工作的关键 ------<template v-slot="{ item }">{{ item }}</template> 里 item 是函数参数。
  • scopes.vSlot++ 让 buildSlots 知道"当前在嵌套插槽里"(vSlot.ts:134)。
  • 退出时成对恢复。注释特别指出 :"the exit callback is executed before buildSlots() on the same node, so only nested slots see positive numbers"------即 trackSlotScopes 的退出回调会在 transformElement.postTransformElement 里的 buildSlots 之前 执行(因为 trackSlotScopes 在数组里排在 transformElement 之后,见 compile.ts:54,所以它的 exitFn 排在 transformElement 的 exitFn 之前 执行------逆序!)。于是 buildSlots 看到的 scopes.vSlot 是外层 的值,不会被自己 ++ 干扰。

trackVForSlotScopes(vSlot.ts:75-98)处理 <template v-for v-slot>:对 v-for 的 value/key/index 记账,让它们在插槽函数体里不被前缀化。

6.7.2 buildSlots 的调用点

buildSlots 不是独立的 nodeTransform,而是被 transformElement 在退出时调用(transformElement.ts:170-182):

ts 复制代码
const shouldBuildAsSlots = isComponent && vnodeTag !== TELEPORT && vnodeTag !== KEEP_ALIVE
if (shouldBuildAsSlots) {
  const { slots, hasDynamicSlots } = buildSlots(node, context)
  vnodeChildren = slots
  if (hasDynamicSlots) patchFlag |= PatchFlags.DYNAMIC_SLOTS
}

三个排除:isComponent(普通元素没有 slots)、非 Teleport(Teleport 有自己的 children 处理)、非 KeepAlive(KeepAlive 用原始 children,见 transformElement.ts:149-168)。生成的 slots 对象作为 vnode 的 children。

6.7.3 buildSlots 主流程(vSlot.ts:118-380)

ts 复制代码
export function buildSlots(node, context, buildSlotFn = buildClientSlotFn) {
  context.helper(WITH_CTX)
  const { children, loc } = node
  const slotsProperties: Property[] = []
  const dynamicSlots: (ConditionalExpression | CallExpression)[] = []

  let hasDynamicSlots = context.scopes.vSlot > 0 || context.scopes.vFor > 0
  if (!__BROWSER__ && !context.ssr && context.prefixIdentifiers) {
    hasDynamicSlots =
      node.props.some(prop => isVSlot(prop) && (hasScopeRef(prop.arg, context.identifiers) || hasScopeRef(prop.exp, context.identifiers))) ||
      children.some(child => hasScopeRef(child, context.identifiers))
  }
  ...

四段:

① hasDynamicSlots 判定:

  • 默认/浏览器构建 :scopes.vSlot > 0 || scopes.vFor > 0(在 v-slot/v-for 内部 → 动态)。
  • prefixIdentifiers 构建 :改用精确的 hasScopeRef 分析------只有当 v-slot 的 arg/exp 或 children 真的引用了作用域变量 时才算动态。这是一个"浏览器 vs Node 构建的精度差异" :Node 构建有 Babel 能做作用域分析,所以更精确,能少生成 DYNAMIC_SLOTS(少 force update);浏览器构建只能保守。

② 组件自身上的 v-slot (<Comp v-slot="{ x }">,vSlot.ts:151-163):

ts 复制代码
const onComponentSlot = findDir(node, 'slot', true)
if (onComponentSlot) {
  const { arg, exp } = onComponentSlot
  if (arg && !isStaticExp(arg)) hasDynamicSlots = true
  slotsProperties.push(createObjectProperty(arg || createSimpleExpression('default', true), buildSlotFn(exp, undefined, children, loc)))
}
  • arg 动态 (v-slot:[name])→ 强制 hasDynamicSlots;
  • 生成 { <name|default>: (props) => [...] }。

③ 遍历 children 找 <template v-slot> (vSlot.ts:173-310):逐个识别 <template v-slot:xxx>:

  • slotName 默认 'default';静态名做重复检查(seenSlotNames,vSlot.ts:293-307);
  • 条件插槽 :v-if → dynamicSlots.push(conditional(slotName, fn, index))(vSlot.ts:218-226);v-else/v-else-if → 挂到上一个 conditional 的 alternate(vSlot.ts:227-265);v-for → dynamicSlots.push(renderList(source, (...) => ({ name, fn })))(vSlot.ts:266-290);
  • 非 COMMENT 的非 slot 元素进 implicitDefaultChildren。

④ 隐式 default 槽 (vSlot.ts:312-348):没有 <template v-slot> 时整个 children 作为 default;有命名槽又有隐式 children 时,若已有命名 default 槽则报 X_V_SLOT_EXTRANEOUS_DEFAULT_SLOT_CHILDREN,否则补一个 default。

buildDynamicSlot (vSlot.ts:382-397)把 { name, fn, key } 组成对象------key 是 conditionalBranchIndex(与 v-if 的稳定 key 思路一致,区分动态插槽的不同分支)。

6.7.4 createSlots 与 slotFlag

最终输出(vSlot.ts:350-374):slotFlag 依 hasDynamicSlots ? DYNAMIC : hasForwardedSlots(node.children) ? FORWARDED : STABLE 取值,作为 _ 字段挂在 slots 对象上(SlotFlags 枚举:STABLE=1/DYNAMIC=2/FORWARDED=3)。运行时 normalizeSlots 据此决定是否跳过归一化/diff;FORWARDED 表示"插槽被透传"(hasForwardedSlots 递归检查子树是否有 <slot> outlet,vSlot.ts:399-423)。有动态插槽时整体包成 createSlots(静态slots, [动态槽...])(CREATE_SLOTS helper);每个 slot 函数由 WITH_CTX 的 withCtx() 包裹(FunctionExpression.isSlot = true,codegen.ts:940-943),使其在正确的响应式 owner 上下文执行。

6.7.5 slot outlet:transformSlotOutlet 与 renderSlot 参数

<slot> 走独立的 transformSlotOutlet(transformSlotOutlet.ts:18-53):

ts 复制代码
export const transformSlotOutlet: NodeTransform = (node, context) => {
  if (isSlotOutlet(node)) {
    const { children, loc } = node
    const { slotName, slotProps } = processSlotOutlet(node, context)
    const slotArgs: CallExpression['arguments'] = [
      context.prefixIdentifiers ? `_ctx.$slots` : `$slots`,
      slotName, '{}', 'undefined', 'true',
    ]
    let expectedLen = 2
    if (slotProps) { slotArgs[2] = slotProps; expectedLen = 3 }
    if (children.length) { slotArgs[3] = createFunctionExpression([], children, false, false, loc); expectedLen = 4 }
    if (context.scopeId && !context.slotted) { expectedLen = 5 }
    slotArgs.splice(expectedLen) // remove unused arguments
    node.codegenNode = createCallExpression(context.helper(RENDER_SLOT), slotArgs, loc)
  }
}

renderSlot($slots, name, props, fallback, noSlotted, branchKey) 的参数(ast.ts:491-519):

位置 内容 何时裁剪
0 $slots(或 _ctx.$slots) 从不裁
1 slotName(字符串或表达式) 从不裁
2 slotProps(buildProps 产出) 无 props → '{}'
3 fallback(createFunctionExpression([], children)) 无 children → 'undefined'
4 noSlotted('true',scopeId && !slotted 时保留) 见 vSlot.ts:42-45

expectedLen + splice(expectedLen) 是"按需裁掉尾部默认参数"的技巧 :先填默认占位,再按实际内容推进 expectedLen,最后 splice 把没用到的丢掉。这样生成的代码更短(renderSlot(_ctx.$slots,"a") 而不是 renderSlot(_ctx.$slots,"a",{},undefined,true))。这是典型的"体积账本"思维------每个字符都要算。

processSlotOutlet(transformSlotOutlet.ts:60-123)负责:取 name(<slot name="x"> → "x";:name="x" → 表达式;<slot :name> 简写 → camelize 后 processExpression),其余属性走 buildProps。slot outlet 上不允许指令 (X_V_SLOT_UNEXPECTED_DIRECTIVE_ON_SLOT_OUTLET,transformSlotOutlet.ts:109-115)。

6.7.6 生成代码对照

html 复制代码
<Comp>
  <template #header="{ title }"><h1>{{ title }}</h1></template>
  <template #footer v-if="show"><p>f</p></template>
  <span>implicit default</span>
</Comp>

生成的 slots(PROD 粗略):

js 复制代码
_createVNode(_component_Comp, null, {
  header: _withCtx(({ title }) => [_createElementVNode("h1", null, _toDisplayString(title), 1 /* TEXT */)]),
  default: _withCtx(() => [_createElementVNode("span", null, "implicit default")]),
  _: 1 /* STABLE */
})

footer 为动态槽时,则改由 _createSlots(slotsProperties, dynamicSlots) 生成,_ 标记为 2 /* DYNAMIC */,动态项形如 _ctx.show ? { name: "footer", fn: _withCtx(() => [...]), key: 0 } : undefined。

  • 静态命名槽进 slotsProperties;动态的进 dynamicSlots(createSlots 第 2 参数数组);
  • _withCtx 包裹每个 slot 函数(isSlot: true);
  • _ = 1(STABLE)或 2(DYNAMIC);
  • 有动态槽时 transformElement 会给组件 vnode 加 patchFlag |= DYNAMIC_SLOTS(transformElement.ts:180-182),运行时强制更新。

6.8 transformElement 逐段走读

transformElement(packages/compiler-core/src/transforms/transformElement.ts:73-235)是全套 transform 里最重的一个,它把"一个元素节点"变成 VNodeCall 代码生成节点。它的骨架非常反直觉------函数体几乎全是 return function postTransformElement() {...} (transformElement.ts:76):真正的逻辑全部在退出阶段执行。

6.8.1 为什么必须"退出阶段"才做

ts 复制代码
export const transformElement: NodeTransform = (node, context) => {
  // perform the work on exit, after all child expressions have been
  // processed and merged.
  return function postTransformElement() {
    node = context.currentNode!   // ← 重新取 currentNode,不是闭包里的入参
    ...

三个理由:

  1. 需要子节点的最终形态 。buildSlots 要靠 node.children 已经处理完的 slot 结构;patchFlag |= TEXT 要依靠子插值表达式已归一化;children 的 codegenNode 也要等子节点 transform 完成。
  2. node.children 可能被 v-if/v-for 替换 。进入阶段拿到的 node 可能已被 replaceNode 换掉,所以退出时必须用 context.currentNode(transformElement.ts:77)而不是闭包捕获的入参。
  3. patchFlag 的推算是"汇总"而非"增量" 。它必须等所有 directiveTransforms 跑完、analyzePatchFlag 全部登记之后才能定值。

进入判断(transformElement.ts:79-85)只看 ELEMENT 与 COMPONENT 两类 tagType:SLOT(<slot>)交给 transformSlotOutlet,TEMPLATE(纯 <template>)不需要生成 vnode(只有作 slot/结构指令容器时才存在)。

6.8.2 vnodeTag:resolveComponentType 的五级判定

isComponent = node.tagType === COMPONENT(transformElement.ts:88)。元素直接 "${tag}";组件走 resolveComponentType(transformElement.ts:237-330),判定顺序:

序 分支 产出 源码
1 <component :is> / is="vue:xxx" _resolveDynamicComponent(exp) 245-284
2 内置组件(Teleport/Transition/KeepAlive/Suspense...) 直接返回 tag 名(不 import,运行时自行处理) 286-293
3 setup 绑定(resolveSetupReference) $setup.X 或内联 X 295-310
4 自引用(文件名推导 selfName) _resolveComponent("X", true) 带 maybeSelfReference 312-330
5 兜底 _resolveComponent("X") 330

判定 1 的细节值得注意:is 属性只有三种情况才算"动态组件"------显式 <component>、compat 模式、或 is="vue:xxx" 前缀(transformElement.ts:274-283,<button is="vue:foo"> 会剥离 vue: 当组件)。内置组件不 import runtime 等价物 是关键优化:Transition 等在运行时是具名导出但由 _createVNode 直接识别字符串 tag。

6.8.3 buildProps:四类 prop 分流 + directiveTransforms 调用

buildProps(transformElement.ts:384-859)在 props.length > 0 时调用(transformElement.ts:121-145),返回 7 个字段。主循环 for (i) props(transformElement.ts:507)把 prop 分成四类:

  1. 静态 ATTRIBUTE (508-558):ref 特殊处理(inline 模式下若绑定是 SETUP_LET/REF/MAYBE_REF 则改写成 ref_key + 实际 ref 值,516-534);is 在 <component>/vue: 上跳过(536-548);其余直接 createObjectProperty(name, value)。
  2. v-bind/v-on 无参数 (!arg && (isVBind || isVOn),619-691):hasDynamicKeys = true,v-bind="obj" 进 mergeArgs(后续 mergeProps),v-on="obj" 包成 toHandlers(obj[, true])(674-679)。
  3. 带参数的内置指令 :走 context.directiveTransforms[name](699-714)------这是唯一调用指令 transform 的地方。
  4. 无 transform 的自定义指令 (715-724):只 push 进 runtimeDirectives,且强制 shouldUseBlock + isBlockRequired("自定义指令可能有 beforeUpdate,要保证它在子节点更新前触发")。

第 3 类的调用协议是 directiveTransform(prop, node, context) → { props, needRuntime }(transformElement.ts:702)。返回的 props 若 arg 是动态的(如 v-on:[key])走 pushMergeArg,否则直接并入 properties(704-708)。needRuntime 若为 Symbol,则记进 directiveImportMap(transformElement.ts:70,712),说明这个指令对应一个 runtime helper(如 v-show → _vShow),buildDirectiveArgs 会用 helperString 直接引入,省掉一次 resolveDirective (transformElement.ts:908-925)。

循环内还有两条 shouldUseBlock 触发(597-612)::key 动态绑定(#938,动态 key 必须进 block)、@vue:beforeUpdate 内联钩子。

6.8.4 patchFlag 推算链

所有落进 properties 的项都会被 !ssr && props.forEach(analyzePatchFlag)(transformElement.ts:703)分析。analyzePatchFlag(440-505)逐字段登记布尔量:

  • 静态 key → hasClassBinding / hasStyleBinding / dynamicPropNames.push(name);
  • cached 或常量值的 prop 直接 return (472-480)------这是 cacheStatic 与 v-on inline handler 缓存的收益:值不会变,不需要 patch;
  • 事件处理器按名字分派:onClick 被故意排除 (hydration 有 click 快路径,447-449),onUpdate:modelValue 排除(451),onVnodeXXX 保留为 hasVnodeHook(458-460);
  • 动态 key → hasDynamicKeys = true(502-504)。

汇总判定在 transformElement.ts:751-773:

flowchart TD A[hasDynamicKeys?] -->|是| F[FULL_PROPS] A -->|否| B[&#34;hasClassBinding && !isComponent → CLASS&#34;] B --> C[&#34;hasStyleBinding && !isComponent → STYLE&#34;] C --> D[&#34;dynamicPropNames.length → PROPS&#34;] D --> E[&#34;hasHydrationEventBinding → NEED_HYDRATION&#34;] E --> G[&#34;(patchFlag==0 || ==NEED_HYDRATION) && (hasRef||hasVnodeHook||directives) → NEED_PATCH&#34;]

关键点:CLASS/STYLE 只给元素、不给组件 (755-759 的 !isComponent)。组件上的 class/style 被塞进 dynamicPropNames(494-501),因为它们对组件而言是"普通 props"(要参与 mergeProps 与 props diff)。FULL_PROPS 与 CLASS/STYLE/PROPS 互斥 (有动态 key 就整表 diff)。NEED_HYDRATION 也会被标(v-bind.prop 修饰符,695-697)。

dynamicPropNames 最后被 stringifyDynamicPropNames(954-961)转成字面量数组字符串 ["a","b"],作为 VNodeCall 的 dynamicProps。

6.8.5 props 预归一化

!context.inSSR && propsExpression 时按表达式类型分流(transformElement.ts:776-848):

  • JS_OBJECT_EXPRESSION(无 v-bind 对象):扫 class/style 位置。有动态 key → 整体裹 _normalizeProps;否则对 class 裹 _normalizeClass、style 裹 _normalizeStyle(802-824)。
  • JS_CALL_EXPRESSION(已是 mergeProps):不动(834)。
  • 其它(单个 v-bind="obj"):裹 _normalizeProps(_guardReactiveProps(obj))(838-845)。

这三种归一化把"用户可能传入字符串/数组/对象"的 class/style/props 统一成运行时能吃的形式,且只在编译期确定无需归一化时省略调用。

6.8.6 children 处理与单文本优化

node.children.length > 0 时(transformElement.ts:148):

  • <KeepAlive> 特殊:强制 block + 强制 DYNAMIC_SLOTS(155-158),因为它用原始 children 而非 slot 函数,否则 block 优化会导致漏更新;
  • 组件(非 Teleport/KeepAlive)→ buildSlots(177-182);
  • 单子节点优化 (183-202):若唯一子是插值/复合表达式且 getConstantType === NOT_CONSTANT → patchFlag |= TEXT(190-195);若唯一子是纯 TEXT 或动态文本,则 vnodeChildren = child(直接传,201),否则传数组。这就是"_createElementVNode("div", null, _toDisplayString(x), 1 /* TEXT */) "的来源------单文本孩子不需要数组包裹,配合 TEXT flag 走最快的 el.textContent = ... 更新;
  • 多子节点 → vnodeChildren = node.children。

6.8.7 组装 VNodeCall

最后 createVNodeCall(context, vnodeTag, vnodeProps, vnodeChildren, patchFlag, vnodeDynamicProps, vnodeDirectives, shouldUseBlock, disableTracking=false, isComponent, loc)(transformElement.ts:213-225)。shouldUseBlock 决定用 createBlock/createElementBlock/createComponentVNode(..., true)(由 ast.ts:873-885 的 getVNodeHelper/getVNodeBlockHelper 选)。另有两处收尾:needsPatch 只在 patchFlag 为 0 或仅 NEED_HYDRATION 时保留(226-230,否则 patchFlag 本身已表达);isBlockRequired 透传(231-233)。

6.9 transformText:合并相邻文本与插值

transformText(packages/compiler-core/src/transforms/transformText.ts:18-118)同样在退出阶段 执行(27 行注释:"so that all expressions have already been processed"),作用于 ROOT / ELEMENT / FOR / IF_BRANCH 四种容器的 children。

6.9.1 合并算法

核心是双指针 + 原地 splice (transformText.ts:32-55):

ts 复制代码
for (let i = 0; i < children.length; i++) {
  if (!isText(children[i])) continue
  hasText = true
  for (let j = i + 1; j < children.length; j++) {
    const next = children[j]
    if (!isText(next)) { currentContainer = undefined; break }
    if (!currentContainer)
      currentContainer = children[i] = createCompoundExpression([children[i]], children[i].loc)
    currentContainer.children.push(` + `, next)  // 用 " + " 连接
    children.splice(j, 1); j--
  }
}

isText 只认 TEXT 与 INTERPOLATION(utils.ts:341-345)。首个文本被原地替换 成 CompoundExpression([child]),后续相邻文本以 " + " 形式 push 进去并 splice 删除。" + " 是 JavaScript 字符串拼接 ------所以 <div>a{{ b }}{{ c }}</div> 的 children 最终变成一个 CompoundExpression:["a", " + ", b, " + ", c]。

6.9.2 为什么能减少 vnode 数量

  • 合并前:3 个 child → 生成 3 个 vnode(1 个 text vnode + 2 个插值 text vnode)。
  • 合并后 :1 个 child → 生成 1 个 text vnode,其 children 是拼接表达式 → toDisplayString(a + b + c)(运行时 toDisplayString 的入参已是拼好的字符串,几乎零成本)。

收益账本 :vnode 数从 3 降到 1,diff 时 patchChildren 直接走"单文本 → setElementText"快路径;不合并则要 patch 三个文本子节点(且 fragment diff 需处理长度)。对列表/大表格这种 repeated 文本场景,这是数量级的 vnode 缩减。

6.9.3 单文本早退与 TEXT_CALL

若只有一个文本子 且是"普通元素/ROOT/FOR/IF_BRANCH",则跳过合并与包装 (transformText.ts:57-83),因为运行时对"元素单文本子"有 el.textContent 快路径(第三参数直接传字符串,无需 createTextVNode)。两个例外:#3756 自定义指令可能任意插入 DOM,必须避免运行时覆盖它(72-76);compat 模式下无特殊指令的 <template> 会渲染成 fragment,孩子必须转 vnode(80)。

若需要包装,则把每个文本 child 转成 createTextVNode(child[, PatchFlags.TEXT])(transformText.ts:87-115):单空格 ' ' 省略入参(93-95,createTextVNode() 默认就是空格);动态文本附 1 /* TEXT */ flag 以便在 block 内被 patch(97-105)。包装后 child 变成 NodeTypes.TEXT_CALL(106-114),codegen 时直接产出 _createTextVNode(...)。"提前把文本转成 vnode 调用"是为了绕开运行时的 children 归一化 ------否则运行时 normalizeChildren 每次都要判断字符串/数组/数字。

6.10 vOnce / vMemo:缓存语义与 _cache 槽位

两者都不生成 vnode,而是给 codegenNode 换壳 ------套一层 CacheExpression。

6.10.1 v-once:永不更新的子树

transformOnce(packages/compiler-core/src/transforms/vOnce.ts:8-28):

ts 复制代码
if (node.type === ELEMENT && findDir(node, 'once', true)) {
  if (seen.has(node) || context.inVOnce || context.inSSR) return
  seen.add(node); context.inVOnce = true
  context.helper(SET_BLOCK_TRACKING)
  return () => {
    context.inVOnce = false
    const cur = context.currentNode as ElementNode | IfNode | ForNode
    if (cur.codegenNode)
      cur.codegenNode = context.cache(cur.codegenNode, true, true /* isVNode, inVOnce */)
  }
}

四个设计点:

  1. 模块级 WeakSet 去重 (vOnce.ts:6,11):同一节点被多次访问(v-if 分支复制、v-for 循环)时只处理一次。
  2. context.inVOnce 门闩 (12,17):进入时置真,退出时(子节点全部处理完后)复位,期间子元素不再重复处理。
  3. context.helper(SET_BLOCK_TRACKING) (15):收集 setBlockTracking,让 codegen 为整个 v-once 子树包 _setBlockTracking(-1)------告诉运行时"这段子树不要收集 dynamicChildren",因为它是静态的、永不 patch。
  4. 退出阶段 context.cache(cur.codegenNode, true, true) (20-24):isVNode=true 表示缓存的是 vnode(而非表达式),codegen 出 _cache[n] || (_cache[n] = _createVNode(...))。

context.cache 的实现(transform.ts:303-312)是槽位分配器 :createCacheExpression(this.cached.length, exp, isVNode, inVOnce) 先取当前长度 当索引,再 cached.push。第一个缓存索引 0,第二个 1......_cache 数组在 render 函数签名里是 (_ctx, _cache) 第二参数。

6.10.2 v-memo:按依赖数组决定是否复用

transformMemo(packages/compiler-core/src/transforms/vMemo.ts:16-42)在退出阶段:

ts 复制代码
const codegenNode = node.codegenNode || (context.currentNode as PlainElementNode).codegenNode
if (codegenNode && codegenNode.type === VNODE_CALL) {
  if (node.tagType !== ElementTypes.COMPONENT) convertToBlock(codegenNode, context)
  node.codegenNode = createCallExpression(context.helper(WITH_MEMO), [
    dir.exp!, createFunctionExpression(undefined, codegenNode), `_cache`, String(context.cached.length),
  ])
  context.cached.push(null)   // 占位,索引与上面的 cached.length 对齐
}
  • 非组件的子树必须转 block (29-31):withMemo 依赖 dynamicChildren 做 diff 短路,普通节点没有 block 树,必须 convertToBlock;
  • 产出 withMemo(exp, () => vnode, _cache, index)(32-37);
  • 手动 context.cached.push(null) (39):这里不调用 context.cache(因为缓存对象是 withMemo 自己管的 vnode 复用,不是通用 CacheExpression),而是人为占掉一个槽位 ,让后续 context.cache 的索引不与 withMemo 的 index 冲突。String(context.cached.length) 传的就是这个即将被占用的索引。

6.10.3 与 Block / dynamicChildren 的交互

指令 helper 是否转 block 关键语义
v-once SET_BLOCK_TRACKING 否(但用 -1 关闭收集) 子树静态,永不复用 patch
v-memo WITH_MEMO 是(非组件) 依赖数组未变则直接复用旧 vnode

v-once 是"编译期一次性 ":生成 _setBlockTracking(-1) 包裹 + _cache 复用,运行时该子树根本不进 dynamicChildren。v-memo 是"运行时条件式复用 ":withMemo 比较依赖数组,相等就 return cached,跳过整棵子树的 patch;不等则递归 patch 并更新缓存。两者都靠 _cache 数组,但 v-once 的缓存是对"整块 vnode"的缓存,v-memo 的缓存是"带比较依据的缓存" ------这也是 vMemo 必须自己 push(null) 保持槽位一致的原因。

6.11 退出函数机制与执行顺序

6.11.1 exitFns 栈与后进先出

traverseNode(transform.ts:425-486)为每个节点 维护一个局部 exitFns: (() => void)[]:

ts 复制代码
const exitFns: (() => void)[] = []
if (context.options.nodeTransforms) {
  for (const transform of context.options.nodeTransforms) {
    const onExit = transform(node, context)   // 返回函数则登记
    if (onExit) exitFns.push(onExit)
  }
}
// ... 遍历 children ...
while (exitFns.length) exitFns.pop()!()       // ← LIFO
  • 进入 :按 nodeTransforms 数组正序 依次调用,每个 transform 若返回函数就被 push 进 exitFns。
  • 子节点 :traverseChildren/traverseNode 递归处理,此时 exitFns 尚未执行。
  • 退出 :while 循环 pop()------后注册的 transform 先退出(LIFO,栈语义)。

6.11.2 为什么必须是 LIFO

以 <div v-if :class="x"> 为例,transformIf 排在 transformElement 之前 (见 compile.ts:32-63 的顺序表):

text 复制代码
进入 div:  transformIf  → 返回 exit【processIf】  push
           transformElement → 返回 exit【postTransformElement】 push
          (children 遍历)
退出(逆序): pop→ postTransformElement   ... vnodeCall 已构建?
           pop→ processIf             ... 用分支/IfNode 包裹

再看实际效果:processIf 在退出时 replaceNode(div, ifNode)(vIf.ts:85-205),随后 transformElement 的退出回调再跑一次,但此时 context.currentNode 已是 IfNode,于是 transformElement 在 79-85 的 tagType 判断处直接 return(no-op)。真正的元素 codegen 由 createIfBranch → createCodegenNodeForBranch 触发其子节点再次进入/退出完成 (vIf.ts:207-238)。这说明顺序表不只是"优先级",还决定了 exitFn 栈里谁先执行。

一般规律:越靠后的 transform 越晚注册、越先退出 ------它总能看到前面 transform 处理并完善过的节点;而前面的 transform 在自己的 exitFn 里做"包裹层"(结构指令替换节点、v-once 换壳、transformText 合并),因此必须在内层(后面的 transform)完成后再执行。

6.11.3 patchFlag 在哪个阶段定值

patchFlag 不在进入阶段赋值 ,而是 transformElement 退出回调(postTransformElement,transformElement.ts:751-773 汇总、213-225 写入 VNodeCall)时定值。此时:

  • 该元素的所有子节点已经完成 transform(子先于父退出),子 vnode 的 patchFlag 也已确定;
  • 所有 directiveTransforms 已跑完,analyzePatchFlag 的登记已齐全;
  • 父节点尚未退出------所以父节点的 createVNodeCall 拿到的 vnodeChildren 里,子 vnodeCall 已带最终 patchFlag,可直接参与 codegen 的 block 树构建。

一句话:patchFlag 是"自底向上"逐节点在各自退出时定值,这也是"洋葱模型"能让每个节点在离开时"信息完备"的根本原因。

6.12 自定义 transform 实战

6.12.1 自定义 nodeTransform

需求:给所有普通元素(非组件)加 data-source="compiler"。

ts 复制代码
import { type NodeTransform, NodeTypes, ElementTypes } from '@vue/compiler-core'

const addDataAttr: NodeTransform = (node, context) => {
  if (node.type === NodeTypes.ELEMENT && node.tagType === ElementTypes.ELEMENT) {
    node.props.push({
      type: NodeTypes.ATTRIBUTE,
      name: 'data-source',
      nameLoc: node.loc,
      value: { type: NodeTypes.TEXT, content: 'compiler', loc: node.loc },
      loc: node.loc,
    })
  }
}

要点 :nodeTransform 可直接改 node.props/node.children,无需替换节点;若在 ELEMENT 上 push 的是普通 ATTRIBUTE,transformElement 的 buildProps 会自动把它并入静态 props 对象,最终生成 _createElementVNode("div", { "data-source": "compiler" }, ...)。

校验 (把自定义 transform 排在 transformElement 之前即可被 codegenNode 采纳):

ts 复制代码
import { baseParse, transform, transformElement } from '@vue/compiler-core'

const ast = baseParse(`<div>{{ msg }}</div>`)
transform(ast, { nodeTransforms: [addDataAttr, transformElement], hoistStatic: false })
// ast.children[0].codegenNode.args[1] 即 props 对象,含 "data-source": "compiler"

6.12.2 自定义 directiveTransform

需求:v-uppercase 把绑定值转成"编译期常量 prop"。

ts 复制代码
import { type DirectiveTransform, createObjectProperty, createSimpleExpression } from '@vue/compiler-core'

const vUppercase: DirectiveTransform = (dir, node, context) => ({
  props: [createObjectProperty(
    createSimpleExpression('uppercase', true),        // 静态 key
    dir.exp || createSimpleExpression('""', true),    // 值取绑定
  )],
  // needRuntime: 若指令还需运行时 hook,返回其 helper Symbol
})

transform(ast, { nodeTransforms: [transformElement], directiveTransforms: { uppercase: vUppercase } })

要点 :directiveTransforms 的 key 是指令名去掉 v- 且去掉参数 的部分(v-uppercase:foo → uppercase)。返回值 props 会被 buildProps 的 props.forEach(analyzePatchFlag)(transformElement.ts:703)分析------静态 key + 动态值 → dynamicPropNames.push("uppercase")、patchFlag |= PROPS;若想省掉 patch,可返回 JS_CACHE_EXPRESSION 或常量值,analyzePatchFlag 会 return 跳过(472-480)。若指令需要运行时 beforeUpdate 等钩子,返回 needRuntime: Symbol(...),buildProps 会把它记入 runtimeDirectives 并强制 block(715-724)。

6.12.3 注册到编译管线并验证产物

自定义 transform 经 compile 的 nodeTransforms/directiveTransforms 选项注入(compile.ts:32-63 即内置数组顺序):

ts 复制代码
const { code } = compile(tpl, {
  nodeTransforms: [addDataAttr, ...DOMNodeTransforms],
  directiveTransforms: { ...DOMDirectiveTransforms, uppercase: vUppercase },
})

数组顺序即执行顺序:nodeTransforms 正序入栈、退出逆序执行,故自定义项须排在 transformElement 之前。单测推荐三段式单独调用,直接断言产物字符串。

6.13 本章要点回顾

  1. 三段式 :nodeTransforms(节点级,可替换节点/改属性)+ directiveTransforms(指令 → props)+ transformHoist;3.5.44 已无 rootTransforms,根节点处理折进通用遍历与 createRootCodegen。
  2. 共享黑板 TransformContext:选项区(nodeTransforms/helpers/options)、状态区(currentNode/parent/parentStack/childIndex/scopes/cached/temps/identifiers)、方法区(helper/replaceNode/removeNode/hoist/cache/onError)。
  3. traverseNode 是洋葱模型 :进入正序调用 nodeTransforms 并收集 exitFns → 递归 children → 退出逆序 LIFO 执行。每个节点"离开时"信息最完备,这是 transformElement/transformText/vOnce/vMemo 全部把逻辑放退出阶段的根本原因。
  4. helper() 是引用计数式收集 (Map<symbol, number> 而非 Set),配套 removeHelper 支持逆向撤销(如 cacheStatic 降级 block、v-for block 切换);codegen 据 context.helpers 生成 import,未用的 helper 被 tree-shake。
  5. 结构指令必须替换当前节点 :createStructuralDirectiveTransform 用 props.splice(i, 1); i-- 摘除自身避免无限递归,并在退出阶段 replaceNode(node, created)。v-if/v-for 共用这套骨架。
  6. v-if :IfNode + IfBranchNode;processIf 用"兄弟累计 key"(#1587)判定 v-else-if/v-else;稳定分支附 key 与 patchFlag;生成 createCommentVNode("v-if", true) 占位。
  7. v-for :ForNode/ForParseResult;renderList + source/value/key/index;fragment flag 三态(STABLE_FRAGMENT/KEYED_FRAGMENT/UNKEYED_FRAGMENT);disableTracking 与 childBlock 三形态;与 v-if 的优先级仅由 transform 数组顺序决定(Vue 3 中 v-if 更高)。
  8. v-slot :两个记账型 nodeTransform(trackSlotScopes/trackVForSlotScopes)+ buildSlots;动态槽走 createSlots 第 2 参数数组并标 DYNAMIC_SLOTS;slot outlet 用 renderSlot 并 splice(expectedLen) 裁剪尾部默认参数。
  9. transformElement 退出阶段组装 VNodeCall:resolveComponentType 五级判定 → buildProps 四类分流 + 调 directiveTransforms → analyzePatchFlag 登记 → 751-773 推算 patchFlag → children(组件建 slots / 单文本优化 TEXT)→ props 预归一化 → createVNodeCall(shouldUseBlock)。
  10. transformText 把相邻文本/插值合并为一个 CompoundExpression(" + " 拼接),vnode 数从 N 降到 1;动态文本包 createTextVNode(...) 并附 TEXT flag。
  11. v-once/v-memo 都靠 _cache 槽位:前者 _setBlockTracking(-1) 关闭动态收集并整块缓存;后者 withMemo(exp, fn, _cache, index) 条件复用,且必须 convertToBlock 并手动 cached.push(null) 对齐槽位。
  12. patchFlag 自底向上在各自退出时定值,因此结构指令的包裹层总在内层 transform 完成之后。
  13. 扩展性 :nodeTransform 改节点结构、directiveTransform 产出 props,二者是自定义编译期优化的唯一入口,且顺序即优先级。
相关推荐
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 2 章 响应式内幕(一):`effect` 与依赖追踪机制
前端·vue.js·开源
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 09 章 编译器内幕(五):compiler-sfc的SFC解析与<script setup> 编译
前端·vue.js·开源
码银1 小时前
【web】基于HTML/CSS,JavaScript + Three.js做3D流程图
前端·css·html
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 17 章 运行时内幕(五):`runtime-dom`、自定义元素与内置组件
前端·vue.js·开源
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 13 章 运行时组件系统:实例、props、emit、slots 与生命周期
前端·vue.js·开源
Highcharts.js1 小时前
Highcharts React v5 正式发布:支持 ESM、更简洁的组件、更出色的开发体验
前端·react.js·前端框架·数据可视化·highcharts·前端可视化·esm
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 10 章 compiler-sfc(二)
前端·vue.js·开源
will_net1 小时前
Vue IIS部署 api 访问当前站点 代理跳转真实API 问题
前端·javascript·vue.js
weixin_449173651 小时前
react 的useEffect用法
前端·javascript·react.js