Vue 3 核心源码深度剖析系列(v3.5.44)-第 06 章 编译器内幕(二):transform 管线与结构指令
本章对应
vuejs/core3.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"),只能让你"认得生成的代码",无法在动机层面理解设计。本章补上动机层,每一处改写都回答三个问题:
- 数据结构上发生了什么?(节点被替换成什么形状)
- 算法步骤是怎样的?(哪一步在哪个回调里执行)
- 为什么必须这么做?(不做要付什么代价------运行时/体积账本)
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() 的后继步骤。看两个证据:
traverseNode的switch里显式带有case NodeTypes.ROOT:分支(transform.ts:475),把根节点的 children 交给traverseChildren。也就是说根节点本身也会被 nodeTransforms 逐个调用 (traverseNode第一步就是跑nodeTransforms,不区分节点类型)------任何 transform 只要在自己的函数体里写if (node.type === NodeTypes.ROOT)就能处理根。- 真正"只作用于根"的收尾逻辑(决定根返回单 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)]}` },
注意三点:
helper是引用计数式 的。Map<symbol, number>里 value 是次数。这解决了一个真实问题:同一个 helper 可能被多个节点需要,而某些优化(如getConstantType把 block 降级为普通 vnode,cacheStatic.ts:342-347)要"撤销一次 helper 请求"。若用Set,撤销会把别的节点也需要的 helper 一起删掉。helperString是helper的语法糖:既注册计数,又立即 返回_name字符串(helperNameMap查询)。vOn.ts:63的TO_HANDLER_KEY、vFor.ts:229的IS_MEMO_SAME等都用它。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
}
}
逐点拆解:
for用nodeTransforms.length作为即时上界------理论上 transform 可以在运行中改数组(没人这么做,但契约允许)。- 回调返回值 :
void | (() => void) | (() => void)[](类型见transform.ts:48-51)。返回函数 = "我要在这个节点退出 时再做点事";返回数组 = 多个退出回调。transformElement返回单个postTransformElement(transformElement.ts:76),createStructuralDirectiveTransform返回exitFns数组(transform.ts:517)。 if (!context.currentNode) return:某个 transform 把自己删了(比如vIf.processIf在else分支里context.removeNode(),vIf.ts:154),立刻中止遍历------因为"这个节点已经不存在了",后续 transform 与子节点遍历都无意义。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
}
四个要点:
- "叶子即副作用" :
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 各处,而非集中一处,这是有意为之:谁生成调用谁注册。 IF特殊 :IfNode的 children 不在node.children,而在node.branches(ast.ts:276-280),所以单独循环branches,每个 branch 再走IF_BRANCH分支(进而traverseChildren(branch))。这一层间接是v-if能"每个分支独立编译"的结构基础。- 不含
SLOT/TEMPLATE:注意ElementTypes.SLOT(<slot>outlet)与TEMPLATE仍然是NodeTypes.ELEMENT,会被case NodeTypes.ELEMENT捕获正常遍历;tagType只影响具体 transform 的行为,不影响遍历分派。switch分派的是node.type(语法结构),不是node.tagType(元素语义) ------这是两套正交的分类维度,容易混淆(tagType见 6.2.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)
}
}
三个隐藏设计:
onNodeRemoved的闭包捕获 :nodeRemoved闭包捕获的是本次traverseChildren调用 的i。当子节点 transform 调用removeNode删除自己或前面的兄弟时,onNodeRemoved()会把i--,于是for的i++恰好抵消,指针停在原位置(删除后该位置变成下一个元素)。这就避免了"删一个跳一个"。- 状态"压栈/出栈"的极简实现 :
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恰好正确(由祖父层设置)。这个"恰好正确"的窗口依赖调用顺序,是这套极简状态管理的隐含不变量,属于读源码时要特别小心的点。 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]()
}
}
三点:
context.currentNode = node重新赋值 :进入阶段末尾node可能已是"替换后的节点",但遍历子节点时context.currentNode被traverseChildren/递归改成了子节点。退出前必须把currentNode指回本节点 ,否则postTransformElement里node = context.currentNode!(transformElement.ts:77)会拿到错误的节点(比如最后一个子节点)。- 逆序(LIFO)执行 :进入顺序是 A → B → C ,退出是 C → B → A 。原因(6.11 详述):退出回调依赖"内层信息已就绪"。
transformElement排在nodeTransforms靠后,其退出回调位于本节点 exitFns 的末尾、逆序执行时第一个 跑;而 children 遍历发生在 exitFns 之前,故它跑时子节点的codegenNode/patchFlag 已就绪。 - 为什么用
while (i--)而不是for:i从length递减到0时执行,i为-1时循环条件-1为 falsy 退出。标准倒序写法。
6.2.7 一图看懂:进入/退出时序
用一段真实模板对照(以 <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
},
设计要点:
- 参数是
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`(见下)。
- 返回值是
name本身,方便链式写法:
ts
return createCallExpression(context.helper(RESOLVE_DYNAMIC_COMPONENT), [exp])
(transformElement.ts:270-272)------注册 + 把 symbol 用作 callee,一步到位。
-
计数不是布尔。为什么要计数?看两个真实逆向场景:
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)。 -
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 必须:
- 创建
IfNode { branches: [branch] }; context.replaceNode(ifNode)把div在父children里的槽位换成IfNode;- 把
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
}
}
}
步骤分解:
matches工厂 :v-if传入/^(?:if|else|else-if)$/(vIf.ts:44-46),v-for传入字符串'for'(vFor.ts:52-54)。用闭包把"名字匹配逻辑"固化,避免每次调用都判断isString。- 只处理
ELEMENT:其他类型直接返回undefined(不是exitFns)------注意return(无值)与return exitFns(可能是空数组)的区别:空数组[]是 truthy,会让traverseNode把它push(...[])(展开后什么都没加,无害);而undefined让traverseNode跳过 push。两者行为等价但语义不同。这里ELEMENT且无匹配指令 时返回空数组exitFns(因为return exitFns在函数体末尾),traverseNode会exitFns.push(...[])------照样无害。 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)------两者会抢同一个指令,导致结构混乱。这是"归属权先到先得"的仲裁。- 遍历 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不影响它。
- "structural directives are removed to avoid infinite recursion"------
- 收集
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">:
transformIf先把div换成IfNode(branch 的 children 里装着"还带 v-for 的 div");- 遍历到
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)
}
}
})
},
)
逐点:
- 正则
/^(?:if|else|else-if)$/:注意顺序无关紧要(正则是精确匹配三选一)。它同时匹配v-if、v-else、v-else-if(parser 已把v-else-if的name归一化为'else-if')。 processCodegen回调 是transformIf传给processIf的"后半段逻辑"。processIf负责结构改写 (建容器、找兄弟),processCodegen负责生成 codegenNode(在退出时)。- 兄弟累计 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 全局唯一。这是"局部信息不足、需要兄弟上下文"的典型例子。
- 为什么 key 必须唯一? 因为
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)
- 空表达式兜底 :
v-if没写表达式(或写了空白)→ 报X_V_IF_NO_EXPRESSION,并把dir.exp补成true(v-if等价于v-if="true",保证后续 codegen 不为空)。注意dir.name !== 'else':v-else合法地没有表达式,不能报错。 - 提前
processExpression:注释点明"---because vIf transform is applied before expression transform"。transformIf在数组里排在transformExpression之前 (compile.ts:38-51),所以transformExpression还没机会处理dir.exp;processIf只好自己处理(把ok变_ctx.ok)。这是"顺序即依赖"的直接体现 :既然transformIf抢在transformExpression前面消费并摘除了该指令,它就得自己负责表达式处理。 - 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)
- 无表达式 →
X_V_FOR_NO_EXPRESSION; forParseResult:parser 阶段(05 章)已把"item in list"解析成结构化结果并挂在dir.forParseResult上(ast.ts:207)。若解析失败,forParseResult为空 →X_V_FOR_MALFORMED_EXPRESSION。这说明v-for的表达式解析是 parser 的活儿,不是 transform 的------transform 只消费结果。这是"职责分离"的一个信号。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。- 建
ForNode并replaceNode(vFor.ts:286-297):children: isTemplateNode(node) ? node.children : [node]------与v-if的createIfBranch同样逻辑:<template v-for>展开 children,普通元素用[node]。 - 作用域记账 (
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,长度变化时截断/补挂) |
三个判定要点:
isStableFragment优先于 key :常量数组顺序恒不变,即使有 key 也无需 keyed diff,直接STABLE_FRAGMENT。这时patchFlag里KEYED_FRAGMENT反而不该出现------因为"key 比较"的代价白付。disableTracking: !isStableFragment:createVNodeCall的第 9 个参数,落到VNodeCall.disableTracking,最终打印成openBlock(true)(codegen.ts:856)。当 fragment 不稳定时,需要暂停 block 追踪 ------因为不稳定的 fragment 会重排子节点,而被追踪的dynamicChildren是按"生成顺序"收集的,重排后顺序会错乱。暂停追踪迫使父级走完整 diff(BAIL语义)。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-onabove-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) => ... 的函数参数列表。两个细节:
- 尾部截断 :从后往前找最后一个非空参数,
slice(0, i+1)丢掉尾部空参数。所以v-for="item in list"只生成(item),不生成(item, _, _)。 - 中间空洞补
_: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,不是闭包里的入参
...
三个理由:
- 需要子节点的最终形态 。
buildSlots要靠node.children已经处理完的 slot 结构;patchFlag |= TEXT要依靠子插值表达式已归一化;children 的codegenNode也要等子节点 transform 完成。 node.children可能被 v-if/v-for 替换 。进入阶段拿到的node可能已被replaceNode换掉,所以退出时必须用context.currentNode(transformElement.ts:77)而不是闭包捕获的入参。- 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 分成四类:
- 静态
ATTRIBUTE(508-558):ref特殊处理(inline模式下若绑定是SETUP_LET/REF/MAYBE_REF则改写成ref_key+ 实际 ref 值,516-534);is在<component>/vue:上跳过(536-548);其余直接createObjectProperty(name, value)。 v-bind/v-on无参数 (!arg && (isVBind || isVOn),619-691):hasDynamicKeys = true,v-bind="obj"进mergeArgs(后续mergeProps),v-on="obj"包成toHandlers(obj[, true])(674-679)。- 带参数的内置指令 :走
context.directiveTransforms[name](699-714)------这是唯一调用指令 transform 的地方。 - 无 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-oninline handler 缓存的收益:值不会变,不需要 patch; - 事件处理器按名字分派:
onClick被故意排除 (hydration 有 click 快路径,447-449),onUpdate:modelValue排除(451),onVnodeXXX保留为hasVnodeHook(458-460); - 动态 key →
hasDynamicKeys = true(502-504)。
汇总判定在 transformElement.ts:751-773:
关键点: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 */)"的来源------单文本孩子不需要数组包裹,配合TEXTflag 走最快的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 */)
}
}
四个设计点:
- 模块级
WeakSet去重 (vOnce.ts:6,11):同一节点被多次访问(v-if 分支复制、v-for 循环)时只处理一次。 context.inVOnce门闩 (12,17):进入时置真,退出时(子节点全部处理完后)复位,期间子元素不再重复处理。context.helper(SET_BLOCK_TRACKING)(15):收集setBlockTracking,让 codegen 为整个 v-once 子树包_setBlockTracking(-1)------告诉运行时"这段子树不要收集 dynamicChildren",因为它是静态的、永不 patch。- 退出阶段
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 本章要点回顾
- 三段式 :
nodeTransforms(节点级,可替换节点/改属性)+directiveTransforms(指令 → props)+transformHoist;3.5.44 已无rootTransforms,根节点处理折进通用遍历与createRootCodegen。 - 共享黑板
TransformContext:选项区(nodeTransforms/helpers/options)、状态区(currentNode/parent/parentStack/childIndex/scopes/cached/temps/identifiers)、方法区(helper/replaceNode/removeNode/hoist/cache/onError)。 traverseNode是洋葱模型 :进入正序调用 nodeTransforms 并收集 exitFns → 递归 children → 退出逆序 LIFO 执行。每个节点"离开时"信息最完备,这是transformElement/transformText/vOnce/vMemo全部把逻辑放退出阶段的根本原因。helper()是引用计数式收集 (Map<symbol, number>而非Set),配套removeHelper支持逆向撤销(如cacheStatic降级 block、v-for block 切换);codegen 据context.helpers生成 import,未用的 helper 被 tree-shake。- 结构指令必须替换当前节点 :
createStructuralDirectiveTransform用props.splice(i, 1); i--摘除自身避免无限递归,并在退出阶段replaceNode(node, created)。v-if/v-for 共用这套骨架。 v-if:IfNode+IfBranchNode;processIf用"兄弟累计 key"(#1587)判定v-else-if/v-else;稳定分支附key与patchFlag;生成createCommentVNode("v-if", true)占位。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 更高)。v-slot:两个记账型 nodeTransform(trackSlotScopes/trackVForSlotScopes)+buildSlots;动态槽走createSlots第 2 参数数组并标DYNAMIC_SLOTS;slot outlet 用renderSlot并splice(expectedLen)裁剪尾部默认参数。transformElement退出阶段组装 VNodeCall:resolveComponentType五级判定 →buildProps四类分流 + 调directiveTransforms→analyzePatchFlag登记 →751-773推算 patchFlag → children(组件建 slots / 单文本优化TEXT)→ props 预归一化 →createVNodeCall(shouldUseBlock)。transformText把相邻文本/插值合并为一个CompoundExpression(" + "拼接),vnode 数从 N 降到 1;动态文本包createTextVNode(...)并附TEXTflag。v-once/v-memo都靠_cache槽位:前者_setBlockTracking(-1)关闭动态收集并整块缓存;后者withMemo(exp, fn, _cache, index)条件复用,且必须convertToBlock并手动cached.push(null)对齐槽位。- patchFlag 自底向上在各自退出时定值,因此结构指令的包裹层总在内层 transform 完成之后。
- 扩展性 :
nodeTransform改节点结构、directiveTransform产出 props,二者是自定义编译期优化的唯一入口,且顺序即优先级。