Vue 3 核心源码深度剖析系列(v3.5.44)-第 07 章 编译器内幕(三):codegen、Block 树与 patchFlags
本章对应
vuejs/core3.5.44,主讲packages/compiler-core/src/codegen.ts、packages/shared/src/patchFlags.ts、packages/compiler-core/src/transforms/cacheStatic.ts、packages/compiler-core/src/transform.ts,并向上串联packages/compiler-dom/src/transforms/stringifyStatic.ts,向下串联packages/runtime-core/src/vnode.ts与packages/runtime-core/src/renderer.ts。第 05 章讲了"模板字符串如何变成 AST",第 06 章讲了"AST 如何被 transform 改写并挂上
codegenNode"。这一章回答第三个问题:这棵被改写过的 AST,究竟是怎样被序列化成一段可以new Function()执行的render函数源码的?而这段源码里那些看起来莫名奇妙的1 /* TEXT */、_openBlock()、_cache[0]——运行时到底拿它们做了什么?这一章是"编译期优化"与"运行时 diff"的交汇点。读完它,你就能把
patchFlags、Block 树、静态提升、事件缓存这四块拼图完整合上。
导语:编译器的"最后一百米",决定了运行时的一生
如果用一个比喻来概括 Vue 3 编译器的三个阶段的权力分配:parser 决定"能编译什么",transform 决定"编译得聪不聪明",codegen 决定"聪明能不能落袋为安"。
transform 阶段辛辛苦苦推算出的一堆优化信息——patchFlag、dynamicProps、isBlock、hoists、cached——本身只是一些挂在 AST 节点上的数字和标志位。它们既不能自动变成可执行代码,也不会自己生效。真正把这些优化"翻译"成运行时能读懂的调用约定的,正是 codegen.ts 这 1118 行代码。
而一旦这些约定被生成出来,它们就直接约束了运行时生命周期里的每一次更新:
- 一个
1 /* TEXT */的 patchFlag 会让patchElement(renderer.ts:928-982)跳过整套 props diff,只做一次hostSetElementText; - 一个
_openBlock() + _createBlock()的组合会让一个组件在更新时只遍历dynamicChildren(renderer.ts:899-908)而不是整棵子树; - 一个
_cache[0] || (_cache[0] = ...)会让一个内联箭头函数在组件的整个生命周期里保持"同一个引用身份",从而让子组件不会因为"新函数"而被迫重渲染。
换句话说:代码生成不是编译器的收尾工作,而是编译器的"立法"环节。 它把编译期分析出的优化结论,翻译成了一部运行时必须遵守的小宪法。本章的任务,就是逐条读这部宪法,并解释每一条"为什么这么写"、"边界在哪里"、"违反会怎样"。
本章的阅读路线:
- 7.1 先给全景:
generate()的骨架、上下文、两条前导路径、最终产物结构。 - 7.2 逐段拆解代码生成的"原语":缩进、
push的三段式、genExpression家族、genCacheExpression。 - 7.3 helper 导入:
helperNameMap与import { ... as _... }的生成,以及它和 tree-shaking 的关系。 - 7.4
genVNodeCall深度走读:VNodeCall如何变成一个带完整参数序列的createBlock调用。 - 7.5 Block 树机制:为什么需要
dynamicChildren,openBlock/setBlockTracking/createBlock的运行时契约,以及isBlockTreeEnabled的开关时机。 - 7.6
patchFlags全枚举逐位解读,附"位值→含义→运行时消费点"对照表与负值语义。 - 7.7 静态提升在 3.5 的重大变化:
hoistStatic.ts已不复存在,元素节点不再提升;剩下的静态能力由cacheStatic+ConstantTypes+stringifyStatic承担。 - 7.8
cacheStatic与Cache数组:cacheHandlers、genCacheExpression、v-once的联动。 - 7.9 动态 props 收集与 patchFlag 的配合。
- 7.10 一份真实模板的完整编译产物逐行解读。
- 7.11 调试与工具:
onError、template-explorer、开关对比。 - 7.12 要点回顾。
术语约定:本文所说的"块"(block)指运行时
VNode上一个标记了isBlock的节点,它携带dynamicChildren;"Block 树"指这些块通过dynamicChildren嵌套形成的优化树。二者都与 SFC 的<template>块无关,切勿混淆。
7.1 代码生成总览:从 generate() 到最终产物
7.1.1 generate() 的输入输出契约
代码生成的唯一公开入口是 generate(codegen.ts:283-406)。它的签名非常克制:
export function generate(
ast: RootNode,
options: CodegenOptions & {
onContextCreated?: (context: CodegenContext) => void
} = {},
): CodegenResult
输入是一个已经被 transform 改写完成的 RootNode,输出是 CodegenResult(codegen.ts:108-113):
export interface CodegenResult {
code: string // 生成的 render 函数(或整个模块)源码
preamble: string // setup() inline 模式下单独返回的前导 import 段
ast: RootNode // 原样回传,供上层继续使用
map?: RawSourceMap // 可选的 source map
}
这里有两处设计细节值得先立个桩:
generate是纯函数。它不修改传入的ast,所有中间状态都塞进CodegenContext。这与transform的"原地改写"形成鲜明对比(见第 05 章 5.1.2 的对照表)。纯函数的意义在于:你可以对同一个 AST 反复以不同选项生成代码(例如 SSR fallback、source map 开关),而不会互相污染。preamble只在内联模式下非空。当options.inline === true(compiler-sfc的<script setup>内联编译路径会用到)时,render函数不是独立模块,而是一段被塞进setup()里的箭头函数;此时前导的import语句必须单独返回,由上层拼接到文件顶部。否则preamble恒为''(codegen.ts:404)。
generate 的骨架可以拆成 6 步(行号对应 codegen.ts:283-406):
| 步 | 位置 | 动作 |
|---|---|---|
| ① | 289-306 | createCodegenContext 建上下文;读取选项快照(mode/prefixIdentifiers/ssr/scopeId 等) |
| ② | 311-318 | 决定前导上下文:内联模式用独立的子上下文,否则复用主上下文 |
| ③ | 314-318 | 生成前导:module 模式走 genModulePreamble,否则 genFunctionPreamble |
| ④ | 320-380 | 生成 render/ssrRender 函数骨架(签名、with 块、assets、temps) |
| ⑤ | 383-390 | return + genNode(ast.codegenNode) 生成 vnode 树表达式 |
| ⑥ | 392-398 | 收尾补 },返回结果 |
一个容易被忽略的点:ast.codegenNode 可能为空(codegen.ts:386-390)。createRootCodegen(transform.ts:358-404)在"根没有子节点"时什么都不做(transform.ts:401-403 的 else 分支),此时 generate 会直接 push('null'),产出一个 return null 的 render。空模板 "" 编译后就是这个结果。
7.1.2 createCodegenContext:上下文即状态机
CodegenContext(codegen.ts:122-139)是所有代码生成状态的家。它的字段可以分三类:
第一类:选项快照(从 CodegenOptions 展开):
export interface CodegenContext extends Omit<
Required<CodegenOptions>,
'bindingMetadata' | 'inline'
> {
source: string
code: string
line: number
column: number
offset: number
indentLevel: number
pure: boolean
map?: CodegenSourceMapGenerator
helper(key: symbol): string
push(code: string, newlineIndex?: number, node?: CodegenNode): void
indent(): void
deindent(withoutNewLine?: boolean): void
newline(): void
}
第二类:已生成产物与游标:code(累积的源码字符串)、line/column/offset(当前位置,仅为 source map 服务)、indentLevel(当前缩进层级)。
第三类:方法:helper()、push()、indent()、deindent()、newline(),以及可选的 map。
注意 Omit<Required<CodegenOptions>, 'bindingMetadata' | 'inline'>:它排除了 bindingMetadata 和 inline。原因是这两个选项只在 transform/generate 的分支判断里被读一次(见 codegen.ts:306、322-325 的 isSetupInlined 与 options.bindingMetadata),并不需要作为上下文的一部分被反复访问。这是一个典型的"按需最小化上下文"设计——上下文越小,拷贝/传递/调试成本越低。
createCodegenContext 的实现里,最值得注意的是 forwardRef 式的自引用:context.push 内部通过闭包引用 context 本身(codegen.ts:182-236),indent/deindent/newline 也共享外部的 newline(n) 闭包(codegen.ts:252-254)。这让方法可以访问 context.indentLevel、context.column 等实时状态,而不必每次都从参数传入。
还有一个只在服务端源映射才生效的分支(codegen.ts:272-278):
if (!__BROWSER__ && sourceMap) {
// lazy require source-map implementation, only in non-browser builds
context.map = new SourceMapGenerator() as unknown as CodegenSourceMapGenerator
context.map.setSourceContent(filename, context.source)
context.map._sources.add(filename)
}
SourceMapGenerator 是懒加载的——只有浏览器之外的构建且显式开启 sourceMap 时才 import。这是为了不让浏览器端的 runtime-only 构建把 source-map-js 打进产物。同理,push 里的整段 mapping 逻辑都被 if (!__BROWSER__ && context.map) 包住(codegen.ts:184),浏览器构建下这段代码会被 __BROWSER__ 常量折叠掉。
7.1.3 两条前导路径:genModulePreamble vs genFunctionPreamble
generate 在 mode 上做了一次分叉(codegen.ts:314-318):module 模式生成 ES 模块 import,function 模式生成 const { ... } = Vue 的解构赋值。二者的差异不只是"import 还是 const",而牵扯到作用域与变量名冲突。
module 分支:genModulePreamble(codegen.ts:470-535)
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`, NewlineType.End)
push(`\n// Binding optimization for webpack code-split\nconst ${helpers
.map(s => `_${helperNameMap[s]} = ${helperNameMap[s]}`)
.join(', ')}\n`, NewlineType.End)
} else {
push(`import { ${helpers
.map(s => `${helperNameMap[s]} as _${helperNameMap[s]}`)
.join(', ')} } from ${JSON.stringify(runtimeModuleName)}\n`, NewlineType.End)
}
}
它生成的是 import { createVNode as _createVNode, ... } from "vue"——把 helper 一律重命名成带下划线前缀的 _xxx,从而和用户变量名(组件里的 createVNode 等)隔离。这是 7.3 要展开的重点。
optimizeImports 分支(webpack code-split 优化)额外生成一份 const _xxx = xxx 的本地绑定。源码注释解释得很清楚(codegen.ts:487-492):webpack 在 code-split 场景下,对"直接调用 import 绑定"会包一层 Object(a.b) 或 (0, a.b),既有体积膨胀也可能有性能开销;先赋给一个常量变量再调用,可以把这部分开销"摊平成每组件约 50 字节的常量成本",而不是随模板规模线性增长。
function 分支:genFunctionPreamble(codegen.ts:408-468)
const VueBinding = !__BROWSER__ && ssr
? `require(${JSON.stringify(runtimeModuleName)})`
: runtimeGlobalName
const helpers = Array.from(ast.helpers)
if (helpers.length > 0) {
if (!__BROWSER__ && prefixIdentifiers) {
push(`const { ${helpers.map(aliasHelper).join(', ')} } = ${VueBinding}\n`, NewlineType.End)
} else {
// "with" mode.
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)
}
}
}
function 模式又有两条子路径:
prefixIdentifiers为 true:在文件顶部const { createVNode: _createVNode, ... } = Vue,解构一次即可。这正是template-explorer里function mode打开时你看到的样子。with模式(非 prefix,即运行时new Function编译):先把Vue存进_Vue避免后续with (this)里的属性名冲突;而 helper 的const解构则放进with块内部(见 7.1.4)。源码注释点破了动机(codegen.ts:423-425):with块内的每次变量访问都要走一次in检查;把 helper 声明塞进with内部,可以让它们在每次访问时命中局部作用域,绕开in检查开销。
但 with 内部声明带回一个问题:hoists(提升节点)是放在函数体外层的,访问不到 with 内部的 helper。所以 genFunctionPreamble 额外为"会被提升用到的 5 个 static helper"在函数顶部再生成一份 const { ... } = _Vue(codegen.ts:440-452)。这 5 个 helper 恰好就是能在提升产物里出现的创建类 helper:CREATE_VNODE / CREATE_ELEMENT_VNODE / CREATE_COMMENT / CREATE_TEXT / CREATE_STATIC。这个"白名单"是硬编码的,新增可在 hoist 中使用的 helper 时必须同步维护。
7.1.4 函数体骨架:签名、with 块、assets、temps、hoists
前导生成完之后,generate 开始拼函数体(codegen.ts:320-380)。按顺序拆解:
(a)函数名与参数列表(codegen.ts:320-329)
const functionName = ssr ? `ssrRender` : `render`
const args = ssr ? ['_ctx', '_push', '_parent', '_attrs'] : ['_ctx', '_cache']
if (!__BROWSER__ && options.bindingMetadata && !options.inline) {
args.push('$props', '$setup', '$data', '$options')
}
const signature = !__BROWSER__ && options.isTS
? args.map(arg => `${arg}: any`).join(',')
: args.join(', ')
- 客户端 render 参数恒为
(_ctx, _cache)——第二个参数_cache就是 7.8 里_cache[n]的来源,由运行时在组件实例上维护(每个组件实例一份renderCache数组)。 - SSR 是
(_ctx, _push, _parent, _attrs),与客户端完全不同,因为 SSR 不是"造 vnode"而是"推字符串"。 - 只有"有 bindingMetadata 且非 inline"时才追加
$props/$setup/$data/$options四个参数——这是非内联的<script setup>编译(SFC 把 setup 返回值通过_ctx暴露)所依赖的绑定优化参数。
(b)进入函数与可选 with 块(codegen.ts:331-350)
if (isSetupInlined) {
push(`(${signature}) => {`)
} else {
push(`function ${functionName}(${signature}) {`)
}
indent()
if (useWithBlock) {
push(`with (_ctx) {`)
indent()
if (hasHelpers) {
push(`const { ${helpers.map(aliasHelper).join(', ')} } = _Vue\n`, NewlineType.End)
newline()
}
}
useWithBlock = !prefixIdentifiers && mode !== 'module'(codegen.ts:304)。也就是说:只有"非 prefix 且非 module"才用 with。module 模式强制 prefix(compile.ts:83-84:prefixIdentifiers = !__BROWSER__ && (options.prefixIdentifiers === true || isModuleMode)),而 prefix 模式下所有表达式都被 transformExpression 改写成了 _ctx.xxx,不再需要 with。
(c)assets 解析语句(codegen.ts:353-369)
if (ast.components.length) {
genAssets(ast.components, 'component', context)
if (ast.directives.length || ast.temps > 0) newline()
}
genAssets(codegen.ts:537-565)为每个组件/指令生成 const _component_Foo = _resolveComponent("Foo")。这里有个精妙的细节:组件自引用会用 __self 后缀标记(codegen.ts:552-554):
const maybeSelfReference = id.endsWith('__self')
if (maybeSelfReference) { id = id.slice(0, -6) }
push(`const ${toValidAssetId(id, type)} = ${resolver}(${JSON.stringify(id)}${maybeSelfReference ? `, true` : ``})`)
resolveComponent("Foo", true) 的第二个参数告诉运行时:"这可能是组件自引用,如果组件名和文件名推断出的自己同名,直接返回自己而不是去查全局注册。" 这个 __self 标记是在 transformElement.resolveComponentType(transformElement.ts:312-324)里打上的。
(d)temps 声明(codegen.ts:371-380)
if (ast.temps > 0) {
push(`let `)
for (let i = 0; i < ast.temps; i++) {
push(`${i > 0 ? `, ` : ``}_temp${i}`)
}
}
temps 目前主要由 v-for + v-memo 等场景产生,用于生成共享的临时变量。
(e)hoists(codegen.ts:465 与 genHoists)
hoists 由 genHoists(codegen.ts:567-585)生成,注意它把 context.pure 置为 true:
function genHoists(hoists: (JSChildNode | null)[], context: CodegenContext) {
if (!hoists.length) return
context.pure = true
const { push, newline } = context
newline()
for (let i = 0; i < hoists.length; i++) {
const exp = hoists[i]
if (exp) {
push(`const _hoisted_${i + 1} = `)
genNode(exp, context)
newline()
}
}
context.pure = false
}
context.pure 是一个贯穿式开关:为 true 时,genInterpolation/genComment/genCallExpression/genVNodeCall 会在表达式前 push(PURE_ANNOTATION)(即 /*@__PURE__*/,codegen.ts:102)。这告诉打包器"这个调用没有副作用,若返回值未被使用可整段摇掉"。7.3 会详细讨论它和 tree-shaking 的关系。
7.1.5 输出产物结构:import、export、以及 SFC 层的 _export_sfc
把上面几步拼起来,一个 module 模式的编译产物长这样(compiler-sfc 实测,compileTemplate.ts:219-237 使用 hoistStatic: true, cacheHandlers: true, mode: 'module'):
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString,
openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = { class: "title" }
export function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createElementVNode("h1", null, _toDisplayString(_ctx.msg), 1 /* TEXT */)
]))
}
四段结构一目了然:
- import 段:所有用到的 runtime helper,统一以
xxx as _xxx别名导入(7.3)。 - hoist 段:
const _hoisted_1 = ...的静态常量(7.7)。 - 渲染函数:
export function render(_ctx, _cache) { return ... }。 - 表达式体:那串由
_openBlock()、_createElementBlock(...)、_toDisplayString(...)组成的嵌套调用。
关于 _export_sfc 的澄清:很多文章说"Vue SFC 编译产物末尾有 export default /*#__PURE__*/_export_sfc(_sfc_main, [['render', render]])"。这句话对,但这行不是 vuejs/core 生成的。截至 3.5.44,本仓库内不存在 _export_sfc 函数(全文检索 packages/ 无匹配),它由构建插件(@vitejs/plugin-vue / vue-loader)注入,用来把 render 挂到组件对象上、并附加 scoped id 等元信息。compiler-sfc 负责的是产出 render/ssrRender 与 preamble(compileTemplate.ts:263 返回 { code, ast, preamble, source, errors, tips, map }),以及 <script setup> 的 _sfc_main 组装逻辑在 compileScript.ts。所以本章只讨论 vuejs/core 真正负责的那几段。
关于 preamble:compileTemplate 返回的 preamble 就是内联模式下 generate 单独返回的 import 段(codegen.ts:404),插件会把它去掉重名后拼接进最终文件头部。
7.1.6 小结:generate 的三条不变量
读到这里,generate 可以总结成三条不变量:
- 顺序即语义:
import → hoists → function → return expr的顺序不能乱。hoist 必须在函数外生成(跨渲染复用),helper import 必须在最前(hoist 会引用它们)。 - 纯函数 + 上下文隔离:所有可变状态在
CodegenContext里;内联模式的 preamble 用独立子上下文生成(codegen.ts:311-313),以免和主代码的缩进/列号串味。 - 优化只以"表达式"和"标志位"两种形态落地:要么变成特殊的 helper 调用(
_openBlock、_cache),要么变成VNodeCall的一个参数(patchFlag、dynamicProps)。运行时不理解 AST,只理解这两种形态。
7.2 CodegenContext 与代码生成"原语"
codegen.ts 的后半部分(650-1118)是一套"原语(primitive)"函数库:genNode 是分发器,其余 genXxx 各管一类节点的打印。理解这套原语,是读懂后半章所有细节的前提。
7.2.1 indentLevel 与"缩进的真相"
缩进相关的三个方法(codegen.ts:237-254):
indent() { newline(++context.indentLevel) },
deindent(withoutNewLine = false) {
if (withoutNewLine) { --context.indentLevel }
else { newline(--context.indentLevel) }
},
newline() { newline(context.indentLevel) },
以及闭包里的:
function newline(n: number) {
context.push('\n' + ` `.repeat(n), NewlineType.Start)
}
三个关键事实:
- 缩进单位是 2 个空格(
' '.repeat(n)),不是 tab。 indent()不是"插入几个空格",而是"先把层级 +1,再换行到新层级"。换言之,缩进只在换行时体现;在同一行内indent()不会产生任何空格。这就是为什么生成的数组元素看起来是"换行 + 2 空格"。所以调用indent()后没有紧跟push是安全的(只是白白换了一行),但如果逻辑上期望"缩进但不换行"就会踩坑。indentLevel是一个整数计数器,而不是"当前行前缀"。它的值在deindent之前会一直保留,孤立地看codegen.ts里大量indent()/deindent()成对出现,靠的就是这个计数器平衡。任何一条return漏掉deindent都会让整段代码缩进"滑坡"。
indentLevel 同时被用于 source map 的列号推算(context.column = code.length - newlineIndex,codegen.ts:229)。
7.2.2 push(code, newlineIndex, node):三段式
push 是代码生成中调用最频繁的方法(codegen.ts:182-236)。它的签名是 push(code, newlineIndex = NewlineType.None, node?): void,内部按顺序做三件事:
第一段:追加源码(永远执行)
context.code += code
这一步无条件执行,push 的最核心职责就是"把字符串拼到 code 尾部"。
第二段:仅在"非浏览器 + 有 map"时更新游标与映射
if (!__BROWSER__ && context.map) {
if (node) {
let name
if (node.type === NodeTypes.SIMPLE_EXPRESSION && !node.isStatic) {
const content = node.content.replace(/^_ctx\./, '')
if (content !== node.content && isSimpleIdentifier(content)) {
name = content
}
}
if (node.loc.source) { addMapping(node.loc.start, name) }
}
...
}
node 参数的作用就是提供 source location:在生成这段代码之前记录一个 mapping 指向节点起点。这里还顺手做了一件 source map 特有的优化:如果节点是动态 SimpleExpression 且形如 _ctx.foo,就把 foo 作为 mapping 的 name 记下来(去掉了 _ctx. 前缀并校验是简单标识符)。这样调试器里 hover 变量能显示原始名字。
紧接着是位置推进。这里用了 NewlineType 枚举(codegen.ts:115-120)来避免每次都全量扫描字符串:
enum NewlineType {
Start = 0, // 换行在 index 0(如 '\n ')
End = -1, // 换行在末尾
None = -2, // 不含换行
Unknown = -3 // 未知,全量 iterate
}
推进逻辑(codegen.ts:197-231):
Unknown:调用advancePositionWithMutation(context, code)全量遍历,最慢但最安全。- 其余走快路径:
offset += code.length;None时只column += code.length(并在__TEST__下断言"不该有换行");Start/End时line++且column = code.length - newlineIndex。
据实测(codegen.ts:205-227 的测试断言),快路径对"无换行或恰好一个已知位置换行"的字符串成立;一旦实际内容与声明的 newlineIndex 不符,开发测试构建会直接抛错。这是一层自我校验:为了性能放弃全量扫描,就必须用断言保证不改语义。
第三段:节点结束时再记一个 mapping
if (node && node.loc !== locStub && node.loc.source) {
addMapping(node.loc.end)
}
addMapping(codegen.ts:256-270)直接操作 SourceMapGenerator 的私有属性 _names/_mappings,跳过该库的校验逻辑换取性能。注释(codegen.ts:257-259)明确说明这是有意为之。
易错点:
push的第二、三段只在非浏览器构建 + 开启 sourceMap 时生效。因此单元测试(__BROWSER__、__TEST__组合)里断言push的换行参数,必须清楚它本质上只在 source map 路径有意义——那些if (__TEST__ && ...)的断言只在测试构建生效。
7.2.3 genNode:一个"必须穷尽"的分发器
所有原语都要经过 genNode(codegen.ts:650-745)分发。它的入口先把字符串与 symbol 短路:
if (isString(node)) { context.push(node, NewlineType.Unknown); return }
if (isSymbol(node)) { context.push(context.helper(node)); return }
这两行解释了为什么整套原语能"混着字符串用":genNode 接受 CodegenNode | symbol | string,字符串直接当字面量输出,symbol 通过 context.helper 变成 _xxx(7.3)。接着是一张巨大的 switch,把 NodeTypes 映射到具体 genXxx:
NodeTypes | 处理函数 | 说明 |
|---|---|---|
ELEMENT/IF/FOR | 递归 genNode(node.codegenNode) | 三种容器都不直接生成,交给自身的 codegenNode |
TEXT | genText | JSON.stringify 内容 |
SIMPLE_EXPRESSION | genExpression | 静态则 stringify,动态则原样 |
INTERPOLATION | genInterpolation | 包一层 _toDisplayString(...) |
TEXT_CALL | 递归 codegenNode | v-text / 纯文本插值转成的调用 |
COMPOUND_EXPRESSION | genCompoundExpression | 逐 child 拼接 |
COMMENT | genComment | _createCommentVNode("...") |
VNODE_CALL | genVNodeCall | 核心,见 7.4 |
JS_CALL_EXPRESSION | genCallExpression | helper 调用 |
JS_OBJECT_EXPRESSION | genObjectExpression | props 对象 |
JS_ARRAY_EXPRESSION | genArrayExpression | children 数组 |
JS_FUNCTION_EXPRESSION | genFunctionExpression | 事件/slot/renderList 回调 |
JS_CONDITIONAL_EXPRESSION | genConditionalExpression | v-if 三元 |
JS_CACHE_EXPRESSION | genCacheExpression | _cache[n] 逻辑 |
JS_BLOCK_STATEMENT | genNodeList | SSR only |
JS_TEMPLATE_LITERAL / JS_IF_STATEMENT / JS_ASSIGNMENT_EXPRESSION / JS_SEQUENCE_EXPRESSION / JS_RETURN_STATEMENT | SSR only | |
IF_BRANCH | noop | 分支本身不直接生成 |
| default | __DEV__ 断言 + never 穷尽检查 | 类型安全兜底 |
最值得注意的是穷尽检查(codegen.ts:736-742):
default:
if (__DEV__) {
assert(false, `unhandled codegen node type: ${(node as any).type}`)
const exhaustiveCheck: never = node
return exhaustiveCheck
}
const exhaustiveCheck: never = node 是经典的 TypeScript 穷尽技巧:一旦未来 NodeTypes 加了新成员而这个 switch 忘了处理,node 的类型就不再是 never,编译期直接报错。它把"新增节点类型必须补 codegen"这一约定,从文档变成了编译器强制。
ELEMENT/IF/FOR 这三个容器的处理里还有一条开发断言(codegen.ts:663-668):
__DEV__ && assert(node.codegenNode != null,
`Codegen node is missing for element/if/for node. Apply appropriate transforms first.`)
它专门拦截"忘了跑 transform 就 generate"的错误——因为这三类节点的 codegenNode 完全由 transform 阶段(transformElement/vIf/vFor)填充。
7.2.4 genExpression 家族逐个走读
7.2.4.1 genText / genExpression(codegen.ts:747-761)
function genText(node, context) {
context.push(JSON.stringify(node.content), NewlineType.Unknown, node)
}
function genExpression(node, context) {
const { content, isStatic } = node
context.push(isStatic ? JSON.stringify(content) : content, NewlineType.Unknown, node)
}
二者都是"一个字符串"的打印,区别在于 isStatic:静态表达式 content: "1" 要变成字符串 "1"(JSON.stringify),动态表达式 content: "_ctx.msg" 则原样输出。
一句题外话:模板里
:foo="'a'"与foo="a"编译结果不同——前者是动态表达式(原样吐'a'),后者是静态 attribute 值(stringify 成"a")。静态值在transformElement里被标记为isStatic: true并直接进入 props 对象字面量。
7.2.4.2 genInterpolation(codegen.ts:763-769)
function genInterpolation(node, context) {
const { push, helper, pure } = context
if (pure) push(PURE_ANNOTATION)
push(`${helper(TO_DISPLAY_STRING)}(`)
genNode(node.content, context)
push(`)`)
}
{{ msg }} → _toDisplayString(_ctx.msg)。toDisplayString 处理 null/undefined → ''、对象 JSON.stringify 等边界,是模板插值的语义基石。pure 为 true(在 hoists 内)时会加 /*@__PURE__*/。
7.2.4.3 genCompoundExpression(codegen.ts:771-783)
function genCompoundExpression(node, context) {
for (let i = 0; i < node.children!.length; i++) {
const child = node.children![i]
if (isString(child)) { context.push(child, NewlineType.Unknown) }
else { genNode(child, context) }
}
}
CompoundExpression 是"字符串片段 + 子表达式"的混合体,常用于 :class="'a' + foo" 这类拼接。它直接把字符串 child 内联输出、把表达式 child 递归生成,不做任何包裹——所以拼接结果天然就是合法的 JS 表达式。这也是为什么 :class="[a, b]" 之类会生成一个 COMPOUND 而不会多一层括号。
7.2.4.4 genComment(codegen.ts:805-815)
function genComment(node, context) {
const { push, helper, pure } = context
if (pure) push(PURE_ANNOTATION)
push(`${helper(CREATE_COMMENT)}(${JSON.stringify(node.content)})`, NewlineType.Unknown, node)
}
注释节点生成 _createCommentVNode("...")。生产构建下注释会被 parser 直接丢弃(comments: false),所以它主要出现在开发构建。
7.2.4.5 genCallExpression(codegen.ts:889-898)
function genCallExpression(node, context) {
const { push, helper, pure } = context
const callee = isString(node.callee) ? node.callee : helper(node.callee)
if (pure) push(PURE_ANNOTATION)
push(callee + `(`, NewlineType.None, node)
genNodeList(node.arguments, context)
push(`)`)
}
callee 允许是字符串(如 "void 0" 之类的直接调用)或 symbol(helper)。这是所有 helper 调用(_renderList、_withDirectives、_mergeProps、_normalizeClass 等)的统一出口。
7.2.4.6 genObjectExpression(codegen.ts:900-928)——换行启发式
const multilines =
properties.length > 1 ||
((!__BROWSER__ || __DEV__) && properties.some(p => p.value.type !== NodeTypes.SIMPLE_EXPRESSION))
push(multilines ? `{` : `{ `)
multilines && indent()
for (...) { genExpressionAsPropertyKey(key); push(`: `); genNode(value); if (...) { push(`,`); newline() } }
multilines && deindent()
push(multilines ? `}` : ` }`)
它决定 props 对象是"单行 { key: value }"还是"多行":
properties.length > 1→ 多行;- 或(非浏览器或开发构建)存在非简单表达式值 → 多行。
用 (!__BROWSER__ || __DEV__) 是因为生产浏览器构建追求最小体积,此时再长的对象也尽力压到一行;而开发/SSR 构建优先可读性。
genExpressionAsPropertyKey(codegen.ts:785-803)处理三种 key:
if (node.type === NodeTypes.COMPOUND_EXPRESSION) { push(`[`); genCompoundExpression(node); push(`]`) }
else if (node.isStatic) {
const text = isSimpleIdentifier(node.content) ? node.content : JSON.stringify(node.content)
push(text, NewlineType.None, node)
} else { push(`[${node.content}]`, NewlineType.Unknown, node) }
- 动态 key(
:[foo]="bar")→[foo]: ...; - 静态且是简单标识符 → 不加引号(
class:); - 静态但非标识符(
data-x)→ 加引号("data-x":)。
7.2.4.7 genArrayExpression 与 genNodeListAsArray(codegen.ts:609-621、930-932)
function genNodeListAsArray(nodes, context) {
const multilines =
nodes.length > 3 ||
((!__BROWSER__ || __DEV__) && nodes.some(n => isArray(n) || !isText(n)))
context.push(`[`)
multilines && context.indent()
genNodeList(nodes, context, multilines)
multilines && context.deindent()
context.push(`]`)
}
同样的启发式:元素多于 3 个、或(非浏览器/开发)含数组/非文本节点 → 多行。注意 genNodeList 在 multilines 时会在逗号后 newline(),非多行时则是 , 分隔。
7.2.4.8 genFunctionExpression(codegen.ts:934-977)——slot 包装
function genFunctionExpression(node, context) {
const { push, indent, deindent } = context
const { params, returns, body, newline, isSlot } = node
if (isSlot) { push(`_${helperNameMap[WITH_CTX]}(`) } // _withCtx(
push(`(`, NewlineType.None, node)
if (isArray(params)) genNodeList(params, context)
else if (params) genNode(params, context)
push(`) => `)
if (newline || body) { push(`{`); indent() }
if (returns) {
if (newline) push(`return `)
if (isArray(returns)) genNodeListAsArray(returns, context)
else genNode(returns, context)
} else if (body) { genNode(body, context) }
if (newline || body) { deindent(); push(`}`) }
if (isSlot) {
if (__COMPAT__ && node.isNonScopedSlot) { push(`, undefined, true`) }
push(`)`)
}
}
关键是 isSlot 分支:slot 函数会被额外包一层 _withCtx(...),生成 _withCtx(() => [...])。withCtx 用来给 slot 内容绑定正确的组件实例(owner instance),避免在父/子组件间串上下文。另外 returns 是数组时会在箭头函数体里直接内联成 [...](省去显式 return 语句),这就是 _createElementVNode("div", null, [...]) 里数组能被当作 children 的原因。
7.2.4.9 genConditionalExpression(codegen.ts:979-1013)——缩进的不对称
v-if 的三元表达式由它生成。注意它对 test 的括号守卫:
if (test.type === NodeTypes.SIMPLE_EXPRESSION) {
const needsParens = !isSimpleIdentifier(test.content)
needsParens && push(`(`)
genExpression(test, context)
needsParens && push(`)`)
} else { push(`(`); genNode(test, context); push(`)`) }
静态文本 isSimpleIdentifier(test.content) 才不加括号;a && b 这类复合表达式会被括起来,避免 ? : 优先级问题。
它对 alternate 的处理也值得一提:
const isNested = alternate.type === NodeTypes.JS_CONDITIONAL_EXPRESSION
if (!isNested) context.indentLevel++
genNode(alternate, context)
if (!isNested) context.indentLevel--
嵌套三元不加缩进(else if 链会层层嵌套 JS_CONDITIONAL_EXPRESSION),只有"终点分支"才缩进,避免生成一段阶梯状的白空格。
7.2.5 genCacheExpression 拆解(codegen.ts:1015-1044)
这是本章最"烧脑"的原语之一,因为三种模式(普通缓存、行内 tracking 暂停、数组展开)叠在一起:
function genCacheExpression(node, context) {
const { push, helper, indent, deindent, newline } = context
const { needPauseTracking, needArraySpread } = node
if (needArraySpread) { push(`[...(`) }
push(`_cache[${node.index}] || (`)
if (needPauseTracking) {
indent()
push(`${helper(SET_BLOCK_TRACKING)}(-1`)
if (node.inVOnce) push(`, true`)
push(`),`)
newline()
push(`(`)
}
push(`_cache[${node.index}] = `)
genNode(node.value, context)
if (needPauseTracking) {
push(`).cacheIndex = ${node.index},`)
newline()
push(`${helper(SET_BLOCK_TRACKING)}(1),`)
newline()
push(`_cache[${node.index}]`)
deindent()
}
push(`)`)
if (needArraySpread) { push(`)]`) }
}
分三种情形理解:
情形 A:最简缓存(needPauseTracking=false, needArraySpread=false)——例如事件缓存:
_cache[0] || (_cache[0] = $event => (_ctx.foo($event)))
情形 B:暂停 tracking(needPauseTracking=true)——用于 v-once(inVOnce=true)或需要"缓存一棵含动态子节点的子树"时:
_cache[1] || (
_setBlockTracking(-1, true),
(_cache[1] = _createElementVNode(...)).cacheIndex = 1,
_setBlockTracking(1),
_cache[1]
)
setBlockTracking(-1, true) 让 isBlockTreeEnabled 减 1(进入"不收集动态子节点"状态),第二参数 true 仅在 inVOnce 时带上,用于标记当前块 hasOnce(vnode.ts:302-309)。为什么要暂停?因为被缓存/一次性的子树不应该把自己内部的动态节点塞进父块的 dynamicChildren——这样父块在更新时就不会去遍历这棵"永不变"的子树。.cacheIndex = ${index} 把缓存下标记在 vnode 上,供 HMR/热替换定位缓存槽。
情形 C:数组展开(needArraySpread=true):包一层 [...()],把缓存里的 children 数组复制一份再用。这是 3.5 新增的(cacheStatic.ts:219-228 的注释引用 #13221):缓存数组若被直接复用,mountChildren 会把真实 DOM 引用绑进 vnode,导致旧 DOM 无法被 GC;用扩展运算符避免原地突变、也避免内存泄漏。
注意
_setBlockTracking的调用在这里是逗号表达式串联((a, b, c)),因为缓存变量要在一个表达式内完成"减 tracking → 求值 → 恢复 tracking → 返回缓存值"的原子序列。
7.2.6 小结:原语的设计哲学
这一节可以总结出四条"原语哲学":
genNode是唯一入口:任何节点/字符串/symbol 都走它,保证类型分布集中且穷尽可查。pure全局开关:只有 hoist 段把它置 true,从而让"可能无副作用"的调用(插值、注释、vnode 创建)带上/*@__PURE__*/,服务 tree-shaking。- 多行 vs 单行由启发式决定:长度阈值 +
(!__BROWSER__ || __DEV__),在"可读性"与"体积"之间按构建目标取舍。 - 复杂语义靠参数叠加:
genCacheExpression用一个函数覆盖三种语义,靠needPauseTracking/needArraySpread/inVOnce三个布尔组合开关。这种"显式组合"比拆成三个函数更省调用层,但注释必须到位,否则极难维护——这也是源码里注释密度最高的地方。
7.3 helper 导入生成:helperNameMap 与 tree-shaking 的契约
7.3.1 从 symbol 到名字:helperNameMap
transform 阶段往 ast.helpers(Set<symbol>)里塞的是 runtime helper 的 Symbol,不是字符串。比如 TO_DISPLAY_STRING(runtimeHelpers.ts:44-46):
export const TO_DISPLAY_STRING: unique symbol = Symbol(__DEV__ ? `toDisplayString` : ``)
生产构建里这个 symbol 的 description 是空串(__DEV__ ? name : ''),只有开发构建才带名字。这样做的好处:生产构建的 symbol 更轻量,且无法被猜出/依赖其描述。
真正把 symbol 解析成运行时名字的,是 helperNameMap(runtimeHelpers.ts:86-126):
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`,
...
[TO_DISPLAY_STRING]: `toDisplayString`,
[SET_BLOCK_TRACKING]: `setBlockTracking`,
...
}
这张表是编译器与运行时的"契约文档":左列是编译器产出的 symbol,右列必须与 @vue/runtime-core(以及 runtime-dom、server-renderer)导出的真实函数名逐一对应。如果编译器加了一个 helper 而运行时忘了导出,编译产物就会 import 到一个 undefined。
helperNameMap 通过 registerRuntimeHelpers(runtimeHelpers.ts:128-131)对外暴露注册入口:
export function registerRuntimeHelpers(helpers: Record<symbol, string>): void {
Object.getOwnPropertySymbols(helpers).forEach(s => { helperNameMap[s] = helpers[s] })
}
这行代码的意义:平台包(compiler-dom、compiler-ssr)可以注册自己的 helper,把 symbol 映射到各自运行时(如 @vue/runtime-dom)的函数名,而不必修改 compiler-core。compiler-ssr 就是这样给 SSR 专用 helper(如 ssrRenderComponent)注册名字的。这也是"compiler-core 与 compiler-dom 解耦"的关键机制之一。
7.3.2 别名导入:为什么一定是 xxx as _xxx
genModulePreamble 生成 import 时的核心等式(codegen.ts:505-512):
push(
`import { ${helpers
.map(s => `${helperNameMap[s]} as _${helperNameMap[s]}`)
.join(', ')} } from ${JSON.stringify(runtimeModuleName)}\n`,
NewlineType.End,
)
得到的是:
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString } from "vue"
为什么非要改名?因为 Vue 的模板/表达式里的变量名完全可能与 helper 同名。用户完全可能写 {{ createVNode }} 或 @click="openBlock",transformExpression 会把它改写成 _ctx.createVNode(加了 _ctx. 前缀),这没问题;但如果用户是在 <script setup> 里定义了一个叫 toDisplayString 的本地变量、并且模板直接引用了它呢?在内联模式下,$setup.toDisplayString 会被直接当表达式用,甚至就是一个裸标识符 toDisplayString。此时如果 import 也叫 toDisplayString,就会**遮蔽(shadow)**用户变量,产生难以察觉的语义错误。
统一加 _ 前缀,等于给所有 helper 划一块"保留命名空间",从根本上杜绝这种冲突。_ 前缀也因此成为 Vue 编译产物的招牌风格:_ctx、_cache、_createElementVNode、_hoisted_1 全部共享这一约定。
context.helper() 的实现(codegen.ts:179-181)同样如此:
helper(key) { return `_${helperNameMap[key]}` }
它把 symbol 一步变成带下划线的运行时名——这是 codegen 阶段所有 symbol 经过 genNode(codegen.ts:655-657)时的解析路径。
7.3.3 optimizeImports:webpack code-split 的补丁
前面 7.1.3 提到 optimizeImports 会生成两份声明。把两份连起来看(codegen.ts:487-504):
import { createElementVNode, toDisplayString } from "vue"
// Binding optimization for webpack code-split
const _createElementVNode = createElementVNode, _toDisplayString = toDisplayString
源码注释(codegen.ts:487-492)给出理由:webpack 在 code-split 时,对"把 import 绑定当函数直接调用"会插入 Object(a.b) 或 (0, a.b) 的中间层,带来体积与性能开销。先物化成常量变量,就绕开了这层包装。代价是每个组件固定 ~50 字节,但换来的却是"不随模板增大而膨胀"的稳定开销。
需要注意的是:这是给 webpack 场景的专门优化,Vite(基于 esbuild/rollup)默认不需要,因此 @vitejs/plugin-vue 默认不开 optimizeImports。
7.3.4 tree-shaking:/*@__PURE__*/ 与"按需引入"
Vue 3 生成代码的体积开销能被控制住,靠的是两条机制共同作用:
机制一:按需 import。ast.helpers 只包含这个模板真正用到的 helper。一个没有插值的纯静态组件不会 import toDisplayString;一个没有 v-for 的组件不会 import renderList。这是"局部编译、局部引入",天然可摇。
机制二:/*@__PURE__*/ 注解。当 context.pure 为 true(只在 hoists 段,codegen.ts:571-584),生成的调用前会加纯函数注解:
const PURE_ANNOTATION = `/*@__PURE__*/`
例如提升的静态 vnode 会变成:
const _hoisted_1 = /*@__PURE__*/ _createElementVNode("div", { class: "static" })
打包器(rollup/esbuild)看到这个注解,就知道"即使未使用返回值,只要参数无副作用就可以整条删除"。因为 _hoisted_1 只有在 render 里被引用时才有意义,若整个组件被摇掉,这条 hoist 也能一并消失。
为什么只有 hoist 段加 pure? 因为 hoist 段位于函数体之外、在模块顶层执行——打包器的摇树只会处理顶层作用域。函数体内的 _createElementVNode(...) 是 render 执行时才跑的,摇树工具无从判断,也没必要(它必然被 return 使用)。这个"只给顶层加 pure"的策略,既拿到了收益又避免了误摇。
7.3.5 小结
| 关注点 | 实现 | 收益/契约 |
|---|---|---|
| symbol→名字 | helperNameMap | 编译器与运行时的唯一契约表 |
| 命名冲突 | xxx as _xxx | helper 独占 _ 前缀命名空间 |
| webpack code-split | optimizeImports 物化常量 | 避免 Object(a.b) 包装开销 |
| 体积 | 按需 import + hoist 段 /*@__PURE__*/ | 未用代码可被摇掉 |
7.4 genVNodeCall 深度:一个 VNodeCall 如何变成 createBlock(...)
genVNodeCall(codegen.ts:817-878)是整个 codegen 里信息密度最高的一段。它把 transform 阶段精心攒下的 10 个字段,翻译成一个带完整参数序列的运行时调用。
7.4.1 VNodeCall 的字段全貌
先把节点结构摆出来(ast.ts:318-340):
export interface VNodeCall extends Node {
type: NodeTypes.VNODE_CALL
tag: string | symbol | CallExpression
props: PropsExpression | undefined
children:
| TemplateChildNode[]
| TemplateTextChildNode
| SlotsExpression
| ForRenderListExpression
| SimpleExpressionNode
| CacheExpression
| undefined
patchFlag: PatchFlags | undefined
dynamicProps: string | SimpleExpressionNode | undefined
directives: DirectiveArguments | undefined
needsPatch?: boolean
isBlockRequired?: boolean
isBlock: boolean
disableTracking: boolean
isComponent: boolean
}
逐字段说明它的由来与去向:
| 字段 | 类型 | 来源(transform) | 去向(codegen) |
|---|---|---|---|
tag | string | symbol | CallExpression | transformElement 决定 "div" 或组件符号/resolveDynamicComponent(...) | 调用第 1 参 |
props | PropsExpression | buildProps 产出的对象/mergeProps 调用 | 第 2 参 |
children | 多种 | 单文本子直接内联,多子为数组,组件为 slots | 第 3 参 |
patchFlag | number | buildProps 的位或运算结果 | 第 4 参 |
dynamicProps | string | SimpleExpression | stringifyDynamicPropNames | 第 5 参 |
directives | DirectiveArguments | buildDirectiveArgs | 包在 withDirectives(...) 第 2 参 |
isBlock | boolean | 是否需成为块 | 决定用 createBlock 还是 createVNode |
disableTracking | boolean | v-for fragment 才 true | openBlock(true) 的参数 |
isComponent | boolean | tagType 是否为 COMPONENT | 决定 block/vnode helper 是 "Element" 版还是组件版 |
needsPatch / isBlockRequired | boolean | 供 vFor/vIf 在结构变换时二次决策 | 见 7.5.6 |
7.4.2 完整走读:genVNodeCall
function genVNodeCall(node, context) {
const { push, helper, pure } = context
const { tag, props, children, patchFlag, dynamicProps,
directives, isBlock, disableTracking, isComponent } = node
// ① dev 下把 patchFlag 变成 "1 /* TEXT */" 这样的带注释字面量
let patchFlagString
if (patchFlag) {
if (__DEV__) {
if (patchFlag < 0) {
patchFlagString = patchFlag + ` /* ${PatchFlagNames[patchFlag]} */`
} else {
const flagNames = Object.keys(PatchFlagNames)
.map(Number).filter(n => n > 0 && patchFlag & n)
.map(n => PatchFlagNames[n as PatchFlags]).join(`, `)
patchFlagString = patchFlag + ` /* ${flagNames} */`
}
} else {
patchFlagString = String(patchFlag)
}
}
// ② withDirectives 外壳
if (directives) { push(helper(WITH_DIRECTIVES) + `(`) }
// ③ openBlock 外壳
if (isBlock) { push(`(${helper(OPEN_BLOCK)}(${disableTracking ? `true` : ``}), `) }
// ④ 创建调用本身
if (pure) { push(PURE_ANNOTATION) }
const callHelper = isBlock
? getVNodeBlockHelper(context.inSSR, isComponent)
: getVNodeHelper(context.inSSR, isComponent)
push(helper(callHelper) + `(`, NewlineType.None, node)
genNodeList(
genNullableArgs([tag, props, children, patchFlagString, dynamicProps]), context,
)
push(`)`)
if (isBlock) { push(`)`) }
if (directives) { push(`, `); genNode(directives, context); push(`)`) }
}
逐段解释:
① patchFlag 的 dev 注解(codegen.ts:832-850)
生产构建走 else 分支,只输出数字(String(patchFlag))。开发构建做两件事:
- 负值(特殊 flag)直接
-1 /* CACHED */、-2 /* BAIL */,从PatchFlagNames取名字; - 正值(位标志)遍历
PatchFlagNames,用patchFlag & n筛出所有命中位,拼成1 /* TEXT */或19 /* TEXT, NEED_PATCH */这种。
Object.keys(PatchFlagNames).map(Number) 这行是把对象的字符串键转回数字。由于位标志枚举的键是数字,这个 map 是合法的。这段 dev 注解的存在,直接服务于代码审查与调试:你在 template-explorer 里能一眼看出每个节点被标了什么。
一个反直觉的细节:dev 下表达式里带块注释
/* ... */后仍然是被当作数字字面量的后缀注释——_createElementVNode("h1", null, [...], 1 /* TEXT */)在 JS 里完全合法,注释会被引擎当成空白忽略。生产构建把这些注释全删掉,既减体积也不影响JSON.stringify结果。
② withDirectives 外壳(codegen.ts:852-854、873-877)
当节点带运行时指令(自定义 v-foo 或带 needRuntime 的内置指令)时,整个调用被 _withDirectives(...) 包起来:
_withDirectives(_createElementVNode("input", ...), [[_directive_focus]])
指令以数组形式作为第二参数:每个元素是 [dir, exp, arg, modifiers](buildDirectiveArgs,transformElement.ts:903-952)。注意带指令的 vnode 通常 isBlock,因为自定义指令的 beforeUpdate 必须在子节点更新前调用(transformElement.ts:718-723 强制 isBlockRequired)。
③ openBlock 外壳(codegen.ts:855-857、870-872)
isBlock 为 true 时,在创建调用外面套一层 (_openBlock(), ...):
(_openBlock(), _createElementBlock("div", ...))
disableTracking 为 true 时传 true:
(_openBlock(true), _createElementBlock(Fragment, ..., _renderList(...)))
这个 true 只在 v-for 的 fragment 上出现(vFor.ts:119:!isStableFragment)。它告诉运行时"这个块不要收集 dynamicChildren"——因为 v-for 的每一项都可能变化,收集到的那套 dynamicChildren 顺序/数量不稳定,收集了反而会导致更新遗漏。7.5.4 会详读这条契约。
④ 创建调用 helper 的选择(codegen.ts:861-864)
const callHelper = isBlock
? getVNodeBlockHelper(context.inSSR, isComponent)
: getVNodeHelper(context.inSSR, isComponent)
两个查表函数(ast.ts:873-885):
export function getVNodeHelper(ssr, isComponent) {
return ssr || isComponent ? CREATE_VNODE : CREATE_ELEMENT_VNODE
}
export function getVNodeBlockHelper(ssr, isComponent) {
return ssr || isComponent ? CREATE_BLOCK : CREATE_ELEMENT_BLOCK
}
生成一张真值表:
| 场景 | isBlock | isComponent | inSSR | helper |
|---|---|---|---|---|
| 普通元素 | false | false | false | createElementVNode |
| 普通元素块 | true | false | false | createElementBlock |
| 组件 | false | true | false | createVNode |
| 组件块 | true | true | false | createBlock |
| SSR 任意 | * | * | true | createVNode / createBlock |
为什么要区分 createElementVNode vs createVNode?因为元素与组件的创建路径在运行时不同:createElementVNode(vnode.ts 里为 createBaseVNode 系列)会走"元素专属的 props 归一化(如 class/style 快速归一)"与"子节点 normalize"路径,而组件要走组件专属逻辑。在 SSR 模式下则不区分,统一用 createVNode。
⑤ 参数序列与 genNullableArgs(codegen.ts:865-868、880-886)
function genNullableArgs(args: any[]): CallExpression['arguments'] {
let i = args.length
while (i--) { if (args[i] != null) break }
return args.slice(0, i + 1).map(arg => arg || `null`)
}
这是"从后往前砍掉尾部 null"的关键函数:
- 找到最后一个非空参数的位置
i; - 只保留
0..i,并把其中的null/undefined补成字面量"null"。
举例:[tag="div", props=null, children=null, patchFlag=undefined, dynamicProps=undefined] → 砍到 [tag, props] → _createElementVNode("div", null)。注意这里 props=null 虽然为空但后面还有非空?不,props 是 null 而它前面只有 tag,所以保留到 tag;实际上 [tag, props] 中 props 为 null 会被 .map(arg => arg || "null") 补成 "null"。于是 "div", null。
再看带 flag 的例子:[tag, props, children, 1, undefined] → 最后非空是 index 3(patchFlag=1)→ 保留 [tag, props, children, 1] → 生成 _createElementVNode("h1", null, [...], 1)。尾部 dynamicProps 被自然地省掉,因为它是最后一个参数,运行时提供了默认值 undefined。
这正是"参数位置契约"的精髓:运行时函数的形参顺序固定为 (tag, props, children, patchFlag, dynamicProps, shapeFlag),编译器通过"砍尾"策略只在必要时传后面几个,既保证语义正确,又让生成的代码尽量短。patchFlag 必须在 dynamicProps 之前是硬约束——否则砍尾会砍错。
陷阱:
genNullableArgs只会砍尾部连续的 null。如果中间有 null、后面又有值(例如children为空但patchFlag非空),中间的 null 会被补成"null"而不是省掉。这在语义上是必须的,因为不能省略中间参数。
7.4.3 tag、props、children 三个参数的生成细节
tag:genNode 遇到 string 会直接 push(node, NewlineType.Unknown);遇到 symbol(如 FRAGMENT)会变成 _Fragment;遇到 CallExpression(如 _resolveDynamicComponent("x"))走 genCallExpression。三种形态统一在一个参数位上,靠的就是 genNode 的多态分发。
props:可能是 JS_OBJECT_EXPRESSION(直接量)、JS_CALL_EXPRESSION(_mergeProps(...) / _normalizeProps(...)),或 SimpleExpressionNode(如提升后的 _hoisted_1,见 7.7)。三者都走 genNode。
children:这是最复杂的一个位:
- 单文本子 → 直接
genNode一个节点(如_toDisplayString(_ctx.msg)); - 多子 →
genArrayExpression→genNodeListAsArray; - 组件 →
SlotsObjectExpression({ default: _withCtx(() => [...]) }); - v-for →
ForRenderListExpression(_renderList(list, item => ...),ForCodegenNode.children,ast.ts:578-585); - 提升后 →
SimpleExpressionNode(_hoisted_1); - v-once 缓存后 →
CacheExpression(_cache[n] || ...)。
这就是 children 类型联合表那么长的原因(ast.ts:322-329)。
7.4.4 一个合成示例:三种 callHelper 的同屏对照
给定模板:
<div class="a" :id="x" @click="onClick">
{{ msg }}
<span v-if="ok">yes</span>
</div>
在最外层 <div> 上会生成(简化):
(_openBlock(),
_withDirectives ...不适用,
_createElementBlock("div", { class: "a", id: _ctx.x, onClick: _ctx.onClick }, [
_createElementVNode("span", null, "yes"), // 内层静态内容被处理成片段
...
], 9 /* TEXT, PROPS */, ["id", "onClick"]))
这里 _createElementBlock 的 tag 是字符串 "div"、props 是对象、children 是数组、patchFlag 是 TEXT(1) | PROPS(8) = 9、dynamicProps 是 ["id","onClick"]。dynamicProps 与 PROPS 位是绑定的——见 7.9。
7.4.5 小结
genVNodeCall 的设计可以概括为:
- 三套外壳按需包裹:
withDirectives(openBlock(), createBlock(...))的俄罗斯套娃顺序固定(指令在最外、块在中间、创建在内)。 - helper 由两张真值表选定:
isBlock × isComponent × inSSR决定用哪个创建函数。 - 参数砍尾:
genNullableArgs让生成代码只声明必要参数,依赖运行时默认值。 - dev/prod 双态:dev 给 patchFlag 加人类可读注解,prod 只留数字。
7.5 Block 树(Block Tree)机制:Vue 3 diff 优化的核心
7.5.1 从"为什么要 Block"讲起
先看一个没有 Block 优化时会发生什么。考虑模板:
<div>
<span>静态标题</span>
<p>{{ count }}</p>
<i>另一段静态文字</i>
</div>
count 变化时,理想情况是"只更新那个 <p> 的文字"。但传统 vdom 的 diff 必须从根 div 开始,逐层递归比较 children:它会检查 span 有没有变、p 有没有变、i 有没有变——即使后两者是纯静态的、永远不可能变。模板里有 N 个静态节点,每次更新就有 N 次多余的 isSameVNodeType 比较。
这在"小模板"里无所谓,但在"几百个节点的大表格"里是灾难。Vue 2 的做法是"整棵静态子树提取(static tree hoisting)":把一个完全静态的子树编译成一个常量 vnode,更新时直接跳过。但 Vue 2 的问题是粒度太粗——只要子树里有一个动态节点,整棵子树就没法提升,动态节点的父链仍然要全程 diff。
Vue 3 引入 Block 树,目标是把"跳过"的粒度做细:不再要求"整棵子树静态",而是把"结构稳定"这件事本身提取出来。
核心洞察(源码里写得很到位,见 vnode.ts:247-251):
// Since v-if and v-for are the two possible ways node structure can dynamically
// change, once we consider v-if branches and each v-for fragment a block, we
// can divide a template into nested blocks, and within each block the node
// structure would be stable. This allows us to skip most children diffing
// and only worry about the dynamic nodes (indicated by patch flags).
翻译:模板里唯一能让"节点结构"发生变化的,只有 v-if 和 v-for。 只要把每个 v-if 分支、每个 v-for fragment 各自当作一个"块"(block),那么在每个块的内部,节点结构就是稳定的——不会有节点插入/删除/换位,只会有节点属性/文字的改变。
于是 diff 的粒度可以重构为:块内部只遍历"动态子节点"(dynamicChildren),块与块之间才需要真正的结构 diff。 这就是 Block 树。
7.5.2 Block 的运行时定义与 dynamicChildren
一个 block 本质就是一个普通 VNode,额外带 dynamicChildren 数组。看 VNode 字段定义(vnode.ts:219):
dynamicChildren: (VNode[] & { hasOnce?: boolean }) | null
它有两个特殊之处:
- 类型是
VNode[]的交叉类型,多挂了一个可选hasOnce标记(用于 v-once 相关的卸载安全,见 7.5.5)。 - 它只收集"带 patchFlag 的动态节点",不收集静态节点。
用一个 div 举例:
// 编译产物
(_openBlock(), _createElementBlock("div", null, [
_createElementVNode("span", null, "静态标题"), // 无 patchFlag,不收集
_createElementVNode("p", null, _toDisplayString(_ctx.count), 1 /* TEXT */), // 收集
_createElementVNode("i", null, "另一段静态文字") // 无 patchFlag,不收集
]))
运行时会得到:
div.dynamicChildren = [ <p> vnode ] // 只有 p 在,span/i 不在
于是 count 变化时,renderer.ts 的 patchBlockChildren(renderer.ts:993-1037)只遍历 [<p>] 这一个元素,<span>/<i> 完全不参与比较。
关键前提:之所以能"跳过 span/i",是因为块内结构稳定——span/i 的源码位置固定,更新时它们在父 children 数组中的 index 不变,所以运行时不必确认"它们还在不在",直接按 index 对齐 dynamicChildren 即可(patchBlockChildren 的第 i 个 old 对第 i 个 new,见 renderer.ts:1002-1035)。
一旦结构可能变(v-if/v-for),就必须"开一个新的块"来隔离不确定性,让不确定的部分自己去做结构 diff,而不影响外层块"结构稳定"的假设。这是理解 Block 边界的钥匙。
7.5.3 运行时三件套:openBlock / createBlock / setBlockTracking
运行时维护两个模块级变量(vnode.ts:252-253):
export const blockStack: VNode['dynamicChildren'][] = []
export let currentBlock: VNode['dynamicChildren'] = null
blockStack:块的栈。每进入一个块 push 一个"待收集数组",退出时 pop。currentBlock:当前正在收集动态子节点的那个数组(栈顶)。
openBlock(disableTracking = false)(vnode.ts:271-273):
export function openBlock(disableTracking = false): void {
blockStack.push((currentBlock = disableTracking ? null : []))
}
它做两件事:把 currentBlock 指向一个新的空数组(或 null,如果 disableTracking),并压栈。disableTracking 为 true 时 currentBlock = null,意味着接下来创建的动态节点不会被收集到任何块——这正是 v-for fragment 需要的。
closeBlock()(vnode.ts:275-278):
export function closeBlock(): void {
blockStack.pop()
currentBlock = blockStack[blockStack.length - 1] || null
}
弹出后把 currentBlock 恢复成上一层。
createBlock / createElementBlock(vnode.ts:328-373):
export function createElementBlock(type, props?, children?, patchFlag?, dynamicProps?, shapeFlag?): VNode {
return setupBlock(
createBaseVNode(type, props, children, patchFlag, dynamicProps, shapeFlag, true /* isBlock */),
)
}
export function createBlock(type, props?, children?, patchFlag?, dynamicProps?): VNode {
return setupBlock(
createVNode(type, props, children, patchFlag, dynamicProps, true /* isBlock: prevent a block from tracking itself */),
)
}
两者都调 setupBlock(vnode.ts:311-323):
function setupBlock(vnode: VNode) {
// save current block children on the block vnode
vnode.dynamicChildren =
isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null
// close block
closeBlock()
// a block is always going to be patched, so track it as a child of its parent block
if (isBlockTreeEnabled > 0 && currentBlock) {
currentBlock.push(vnode)
}
return vnode
}
setupBlock 是整套机制的中枢,做三件事:
- 把当前收集到的
dynamicChildren挂到本块上(currentBlock || EMPTY_ARR)。注意|| EMPTY_ARR——如果没有动态子节点,赋一个共享的空数组常量而不是每次[],省一次分配。 closeBlock()弹出当前块。- 把本块自己 push 进"外层块"(此时
currentBlock已是外层)。这解释了"块总是会被父块追踪"——块本身也是一个动态节点,因为它的dynamicChildren内容可能变化,父块必须记得在更新时访问它。
7.5.4 disableTracking 与 v-for 的契约
前面看到,v-for fragment 生成 (_openBlock(true), _createElementBlock(Fragment, ...))。disableTracking=true 导致 currentBlock = null,于是这个 fragment 内部创建的所有动态节点都不会被收集。为什么?
因为 v-for 的 fragment 是"自己会做结构 diff 的块"。它内部的每一项数量会随列表长度变化,如果把这些项塞进 dynamicChildren,那这个数组的长度/顺序就会随列表变化而变化。而 patchBlockChildren(renderer.ts:1002)是按位置对齐的——它假定 oldChildren[i] 与 newChildren[i] 一一对应。一旦顺序/数量变了,按位置对齐就会错乱,更新到错误的节点。
所以 v-for fragment 的策略是:放弃 dynamicChildren 优化,改走完整 diff。 它通过 patchChildren → patchKeyedChildren(有 key 时)或 patchUnkeyedChildren 来正确处理结构变化。相关判定在 renderer.ts:881-888:
(dynamicChildren &&
(!n1.dynamicChildren ||
n1.dynamicChildren.length !== dynamicChildren.length))
? (patchFlag = 0, optimized = false, dynamicChildren = null) : ...
Fragment 的 patch 也有它自己的分支(renderer.ts:1091-1180),其中 STABLE_FRAGMENT(renderer.ts:1133-1145)会尝试用 dynamicChildren 快速对齐;而 keyed/unkeyed fragment 走 keyed/unkeyed diff。
从编译侧看,v-for 的 fragment flag 由 vFor.ts:101-108 决定:
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
- 源是常量(如
v-for="i in 3"或v-for="x in ['a','b']")→STABLE_FRAGMENT:数量与顺序稳定,可用快速路径。此时disableTracking为 false(vFor.ts:119:!isStableFragment→false)。 - 有 key →
KEYED_FRAGMENT,走 keyed diff。 - 无 key →
UNKEYED_FRAGMENT,走 unkeyed diff。
三者与 disableTracking 的对应关系:
| 场景 | fragmentFlag | disableTracking | 运行时 diff 路径 |
|---|---|---|---|
| 常量源 | STABLE_FRAGMENT | false(会收集) | renderer.ts:1133 快速对齐 |
| 有 key | KEYED_FRAGMENT | true | patchKeyedChildren(renderer.ts:1690) |
| 无 key | UNKEYED_FRAGMENT | true | patchUnkeyedChildren(renderer.ts:1705) |
易错点:
disableTracking的语义常被误解成"这个块不做优化"。准确说法是"这个块自己不收集 dynamicChildren"。它仍然是一个 block(isBlock=true),仍然会被父块当作动态节点追踪;它只是放弃了"块内按位置对齐"这条捷径,因为它的内部结构本身就不稳定。
7.5.5 isBlockTreeEnabled 与 v-once 的暂停开关
除了"结构指令",还有一类场景需要暂停收集:被缓存的子树。看 isBlockTreeEnabled(vnode.ts:280-284):
// Whether we should be tracking dynamic child nodes inside a block.
// Only tracks when this value is > 0
// We are not using a simple boolean because this value may need to be
// incremented/decremented by nested usage of v-once (see below)
export let isBlockTreeEnabled = 1
为什么是"整数计数器"而不是布尔值? 注释给了答案:因为 v-once 可以嵌套。一个 v-once 内部又有一个 v-once,暂停 tracking 的操作会叠两层,若用布尔值,内层恢复时就会错误地把外层的暂停也恢复掉。用"加一减一"的整数,嵌套就天然正确(>0 才收集)。
setBlockTracking(vnode.ts:302-309):
export function setBlockTracking(value: number, inVOnce = false): void {
isBlockTreeEnabled += value
if (value < 0 && currentBlock && inVOnce) {
// mark current block so it doesn't take fast path and skip possible
// nested components during unmount
currentBlock.hasOnce = true
}
}
setBlockTracking(-1)让收集能力减一;setBlockTracking(1)加回来。inVOnce为 true 时(只有 v-once 会传),若当前块存在,给它打上hasOnce标记。
hasOnce 的用途在卸载路径(renderer.ts:2183-2193、2248-2261、413-421):
// renderer.ts:413-421 附近
if (
dynamicChildren &&
n1.dynamicChildren &&
n1.dynamicChildren.hasOnce
) {
// ...
if (n2.dynamicChildren === (EMPTY_ARR as any)) {
n2.dynamicChildren = []
}
n2.dynamicChildren.hasOnce = true
}
以及卸载判断(renderer.ts:2192-2193):
patchFlag === PatchFlags.BAIL ||
(dynamicChildren && dynamicChildren.hasOnce)
含义:若某个块的 dynamicChildren 里含有"被 v-once 缓存过、跳过了收集的子树",那么在卸载时不能走"只遍历 dynamicChildren"的快路径——因为那块被跳过的子树里可能包含需要卸载的嵌套组件,快路径会漏掉它们。hasOnce 就是给这种块打上"此块不能走快路径"的标记,让它退回完整遍历。
这条逻辑与编译器生成的 _setBlockTracking(-1, true)(见 7.2.5 情形 B)配对:编译器传 true 的第二参数,运行时据此打 hasOnce。
7.5.6 Block 的边界:谁决定"这里要开一个块"
编译器决定一个节点是否 isBlock,主要规则如下(分散在多处):
| 触发条件 | 源码位置 | 说明 |
|---|---|---|
| 根节点是单个元素 | transform.ts:358-377(createRootCodegen) | convertToBlock(codegenNode, context) 把它变成块 |
| 根有多个子节点 | transform.ts:378-400 | 造一个 FRAGMENT 块,patchFlag = STABLE_FRAGMENT |
| v-if 每个分支 | vIf.ts:240-303(createChildrenCodegenNode) | 分支内容 convertToBlock;需包裹时造 FRAGMENT 块(patchFlag = STABLE_FRAGMENT) |
| v-for fragment | vFor.ts:110-122 | isBlock=true,disableTracking=!isStableFragment |
| v-for 单个子元素 | vFor.ts:181-215 | 若 !isStableFragment 或 isBlockRequired,把子元素"切成"块 |
| 动态组件/Teleport/Suspense | transformElement.ts:108-118(shouldUseBlock) | 这些可能动态替换,需块隔离 |
| svg/foreignObject/math | transformElement.ts:113-118 | 强制块,保证内部更新的 isSVG 标志正确(#639, #643) |
| 带自定义指令且有子节点 | transformElement.ts:718-723 | 强制块,保证指令 beforeUpdate 先于子更新 |
内联 vnode:beforeUpdate 且有子节点 | transformElement.ts:603-612 | 同上,isBlockRequired=true |
v-bind:key 动态 | transformElement.ts:597-600 | shouldUseBlock = true |
| KeepAlive | transformElement.ts:148-168 | 强制块 + DYNAMIC_SLOTS,避免子节点被父块收集 |
这些规则背后是同一条原则:只要一个节点的"子结构或身份"可能发生结构性变化,或者它必须在子节点之前执行副作用,就必须让它自成一"块"。
7.5.7 isBlockRequired 与 needsPatch:块决策的二次协商
在 transformElement 里,一个节点是否成块是先判断、后可能被 vFor/vIf 改写的。这里引入了两个游标字段(ast.ts:333-336):
/** Whether this vnode must be patched if a later transform makes it non-block. */
needsPatch?: boolean
/** Whether a later transform must preserve this vnode as a block. */
isBlockRequired?: boolean
场景:<div v-for="..." v-on:vue:beforeUpdate="...">。单独看 <div>,它因为内联 beforeUpdate 需求而 isBlockRequired = true;但 vFor 在收尾时会把子元素从 block 降级为普通 vnode(因为它可能 isStableFragment)——如果 isBlockRequired,就不能降级。看 vFor.ts:189-215:
const shouldUseBlock = !isStableFragment || childBlock.isBlockRequired === true
if (childBlock.isBlock !== shouldUseBlock) {
if (childBlock.isBlock) {
// switch from block to vnode
removeHelper(OPEN_BLOCK)
removeHelper(getVNodeBlockHelper(context.inSSR, childBlock.isComponent))
} else {
// switch from vnode to block
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
}
}
}
两处精妙:
removeHelper/helper成对翻转:从块降级为 vnode 时,要把OPEN_BLOCK和 block helper 从context.helpers里"减一次引用"(因为不再需要),同时把普通 vnode helper 加进去。removeHelper(transform.ts:212-222)用的是引用计数——只有计数归零才从helpers集合删掉。这保证了"同一个 helper 被多处需要时不会被误删"。needsPatch的补丁:如果一个节点本可以是块但被降级成了普通 vnode,它就不再会被父块的dynamicChildren追踪。此时如果它本身需要 patch(比如带 ref / vnode hook,transformElement.ts:226-230给它打了needsPatch),就必须补一个NEED_PATCH位,让父块的普通 diff 也能注意到它。needsPatch字段就是为这个"降级时补 flag"准备的。
needsPatch 的赋值在 transformElement.ts:226-233:
needsPatch = needsPatch && (patchFlag === 0 || patchFlag === PatchFlags.NEED_HYDRATION)
if (needsPatch) { vnodeCall.needsPatch = true }
if (isBlockRequired) { vnodeCall.isBlockRequired = true }
7.5.8 Block 树结构图(mermaid)
下面这张图展示一个典型模板的块树与 dynamicChildren 归属:
graph TD
R["render() 返回值<br/>openBlock() → createElementBlock('div')<br/><b>Block #0</b><br/>dynamicChildren = [h1, B1]"]
R --> H1["createElementVNode('h1')<br/>patchFlag=1 TEXT<br/>被 Block #0 收集"]
R --> B1["openBlock() → createElementBlock(Fragment)<br/><b>Block #1 (v-if)</b><br/>dynamicChildren = [p]"]
B1 --> P["createElementVNode('p')<br/>patchFlag=1 TEXT<br/>被 Block #1 收集"]
R --> S["createElementVNode('span') '静态'<br/>无 patchFlag,<b>不被任何块收集</b>"]
R --> B2["openBlock(true) → createElementBlock(Fragment)<br/><b>Block #2 (v-for)</b><br/>dynamicChildren = null (disableTracking)"]
B2 --> L["renderList(list, item =><br/> openBlock() → createElementBlock('li')<br/> Block #2.1<br/> dynamicChildren = [text] )"]
读图要点:
- Block #0 的
dynamicChildren只含 h1 和 Block #1,不含静态 span、也不含整个 v-for 块(Block #2 本身是动态的,会进父块的 dynamicChildren;但由于它disableTracking,其内部 li 不会进 #0)。 - 每个 v-if 分支自成一块(Block #1),因为分支切换会改变结构。
- v-for 的 fragment 块(Block #2)内部,每个 li 也可能是自己的块(Block #2.1),这取决于
isStableFragment。 - 静态 span 谁都不收集——它是"结构稳定的装饰",更新时完全跳过。
更新时的遍历顺序:patchBlockChildren 遍历 #0 的 dynamicChildren:先更新 h1(TEXT 快路径),再对 Block #1 递归 diff(它内部只更新 p 的文字),最后对 Block #2 走 keyed/unkeyed diff。静态 span 从未被访问。
7.5.9 性能账本:Block 到底省了什么
用一个"100 行表格、每行有 1 个动态单元格"的模板粗算:
- 无 Block:每次更新从 table 根开始遍历约 100 × 每行节点数 ≈ 上千次
isSameVNodeType比较 + 上千次patchElement的属性判断。 - 有 Block:
dynamicChildren只含那 1 个动态单元格(或其所在的行块),比较次数 ≈ 常数级。
源码注释(vnode.ts:247-251)里的措辞是 "skip most children diffing"。论文式地说:Block 把 diff 从 O(节点总数) 降到 O(动态节点数 + 结构变化点数)。 静态节点数越多,收益越大。
代价:
- 运行时多两个模块级变量 +
openBlock/closeBlock的 push/pop +setupBlock的一次数组赋值。小模板下略有开销。 - 正确性复杂度:一旦"结构稳定"的前提被破坏(用户手写 render 克隆 vnode、非编译器生成的 slot 等),必须能"退回完整 diff"。这就是
BAIL、hasOnce、dynamicChildren.length不一致检测存在的原因——优化越激进,回退机制就必须越严密。
7.5.10 小结
| 概念 | 作用 | 关键源码 |
|---|---|---|
| Block | 一个带 dynamicChildren 的 VNode | vnode.ts:219 |
openBlock | 开块 + 指向新的收集数组 | vnode.ts:271-273 |
createBlock/setupBlock | 挂 dynamicChildren + 关块 + 把自己塞进父块 | vnode.ts:311-373 |
disableTracking | v-for fragment 放弃收集,改走完整 diff | vfor.ts:119 |
isBlockTreeEnabled | 整数计数器支持 v-once 嵌套暂停 | vnode.ts:284 |
setBlockTracking | 暂停/恢复收集 + 打 hasOnce | vnode.ts:302-309 |
patchBlockChildren | 按位置对齐遍历 dynamicChildren | renderer.ts:993-1037 |
| 回退(BAIL/hasOnce/长度不符) | 破坏前提时退回完整 diff | renderer.ts:406-421、881-888 |
7.6 patchFlags 全枚举逐位解读
7.6.1 宏观:patchFlag 是什么、怎么用
源码开头(patchFlags.ts:1-18)有一段总纲注释:
/**
* Patch flags are optimization hints generated by the compiler.
* when a block with dynamicChildren is encountered during diff, the algorithm
* enters "optimized mode". In this mode, we know that the vdom is produced by
* a render function generated by the compiler, so the algorithm only needs to
* handle updates explicitly marked by these patch flags.
*
* Patch flags can be combined using the | bitwise operator and can be checked
* using the & operator, e.g.
* const flag = TEXT | CLASS
* if (flag & TEXT) { ... }
*/
三点要义:
- patchFlag 是"优化提示",不是"渲染指令"。它只告诉运行时"这个节点可能需要更新哪些方面",不保证真的变了——运行时仍然要做实际比较(比如
oldProps.class !== newProps.class)。 - 它是位掩码(bitmask):多个 flag 用
|组合,用&判定。所以"一个节点只有一种动态"这个前提并不成立——它可以同时TEXT | PROPS。 - 它只在"optimized mode"生效。这个模式由
dynamicChildren的存在激活(renderer.ts:393:optimized = ... !!n2.dynamicChildren)。
一个必须澄清的术语错误:网上不少文章写 HYDRATE_EVENTS。本版本里根本没有这个枚举,正确名字是 NEED_HYDRATION(1 << 5 = 32)(patchFlags.ts:59-64)。它表示"需要 hydration(水合),但不一定要 patch"的事件监听/bind 场景。本章一律用 NEED_HYDRATION。
7.6.2 全枚举逐位对照表
| 名称 | 位 | 值 | 含义 | 运行时消费点 |
|---|---|---|---|---|
TEXT | 1<<0 | 1 | 元素有动态 textContent(children 快路径) | renderer.ts:974-978(n1.children !== n2.children 时 hostSetElementText) |
CLASS | 1<<1 | 2 | 动态 class 绑定 | renderer.ts:939-943 |
STYLE | 1<<2 | 4 | 动态 style | renderer.ts:947-949 |
PROPS | 1<<3 | 8 | 有非 class/style 的动态 props;同时带 dynamicProps 列表 | renderer.ts:957-969(只遍历 dynamicProps) |
FULL_PROPS | 1<<4 | 16 | props 含动态 key(v-bind="obj"/:[k]="v"),必须全量 diff | renderer.ts:933-935(patchProps 全量) |
NEED_HYDRATION | 1<<5 | 32 | 需要 hydration 的事件/bind(并非一定 patch) | hydration 路径;renderer.ts:2579 特判其视为"无 patch" |
STABLE_FRAGMENT | 1<<6 | 64 | 子节点顺序不变的 fragment | renderer.ts:1133-1145、2257 |
KEYED_FRAGMENT | 1<<7 | 128 | 有 key 或部分 key 的 fragment | renderer.ts:1690-1704(patchKeyedChildren) |
UNKEYED_FRAGMENT | 1<<8 | 256 | 无 key 的 fragment | renderer.ts:1705-1718(patchUnkeyedChildren) |
NEED_PATCH | 1<<9 | 512 | 只需非 props 的 patch(ref/指令/onVnodeXXX),仅用于让父块追踪 | 仅作为"被收集"的标记;实际 patch 逻辑在 patch 主流程 |
DYNAMIC_SLOTS | 1<<10 | 1024 | 组件有动态插槽(动态插槽名或引用 v-for 值) | 组件 patch 路径强制更新 |
DEV_ROOT_FRAGMENT | 1<<11 | 2048 | 根级注释导致的多余 fragment(仅 dev) | renderer.ts:793-794、1096 |
CACHED | 特殊 | -1 | 缓存的静态 vnode;hydration 可跳过整棵子树 | renderer.ts:2589(text 克隆)、hydration |
BAIL | 特殊 | -2 | 让 diff 退出 optimized mode,强制完整 diff | renderer.ts:406-408、2192 |
注意
PatchFlagNames(patchFlags.ts:129-144)里同时包含正负值,genVNodeCall正是靠它生成 dev 注解:负值直接查PatchFlagNames[patchFlag],正值用patchFlag & n逐个筛。
7.6.3 逐位深入:正值标志
TEXT(1)
最常用的 flag。触发条件在 transformElement.ts:187-199:
const hasDynamicTextChild =
type === NodeTypes.INTERPOLATION || type === NodeTypes.COMPOUND_EXPRESSION
if (hasDynamicTextChild && getConstantType(child, context) === ConstantTypes.NOT_CONSTANT) {
patchFlag |= PatchFlags.TEXT
}
注意两个必要条件:children 是插值/复合表达式,且 getConstantType 判定为非常量(NOT_CONSTANT)。后者是防止 {{ 'a' + 'b' }} 这类"看起来是插值、其实是常量"的伪动态被误标。常量插值会被 cacheStatic 提升/缓存,根本不需要 TEXT。
消费侧(renderer.ts:974-978):
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
这里有个陷阱:TEXT 只处理"children 是单一文本"的情况。它比较的是 n1.children !== n2.children——即直接比较"整个 children 字段"。所以只有当 children 是字符串/toDisplayString(...) 结果时才成立。这就是为什么 transformElement 对单文本子会"直接内联"(vnodeChildren = child,transformElement.ts:198-202):把 children 变成单一表达式,让 TEXT 快路径可用。
CLASS(2)与 STYLE(4)
触发在 buildProps 的 flag 分析段(transformElement.ts:754-767):
if (hasDynamicKeys) {
patchFlag |= PatchFlags.FULL_PROPS
} else {
if (hasClassBinding && !isComponent) { patchFlag |= PatchFlags.CLASS }
if (hasStyleBinding && !isComponent) { patchFlag |= PatchFlags.STYLE }
if (dynamicPropNames.length) { patchFlag |= PatchFlags.PROPS }
if (hasHydrationEventBinding) { patchFlag |= PatchFlags.NEED_HYDRATION }
}
hasClassBinding/hasStyleBinding 在 analyzePatchFlag(transformElement.ts:440-505)里设置:
if (name === 'class') { hasClassBinding = true }
else if (name === 'style') { hasStyleBinding = true }
else if (name !== 'ref' && name !== 'key' && !dynamicPropNames.includes(name)) {
dynamicPropNames.push(name)
}
注意 !isComponent 的限制:组件的 class/style 会被当作普通动态 props(因为组件会把它们 merge 到 fallthrough attrs,处理更复杂),所以组件用的是 PROPS 而非 CLASS/STYLE。源码在 analyzePatchFlag 里有对应处理(transformElement.ts:494-501):
if (isComponent && (name === 'class' || name === 'style') && !dynamicPropNames.includes(name)) {
dynamicPropNames.push(name)
}
消费侧非常直接(renderer.ts:939-949):只做一次 hostPatchProp。
Vue 2 里
class/style也走特殊 fast path(updateClass/updateStyle),Vue 3 把这条 fast path 收敛进patchFlag体系,由编译期决定是否走,而不是运行时靠"看 prop 名是不是 class"来分支。
PROPS(8)与 dynamicProps 的绑定
这是 7.9 的主题,这里先说消费侧(renderer.ts:957-969):
if (patchFlag & PatchFlags.PROPS) {
const propsToUpdate = n2.dynamicProps! // 一定有值,见下
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i]
const prev = oldProps[key]
const next = newProps[key]
if (next !== prev || key === 'value') {
hostPatchProp(el, key, prev, next, namespace, parentComponent)
}
}
}
关键约束:patchFlag & PROPS 为真时,n2.dynamicProps 必须非空——注释写得很明确(renderer.ts:958:"if the flag is present then dynamicProps must be non-null")。这条约束由编译侧保证:PROPS 只在 dynamicPropNames.length 非空时设置。两边的契约必须同时成立,任何一边破坏都会 undefined.forEach 崩溃。
这里还有个经典的 Vue 细节:key === 'value' 时即使 next === prev 也强制 patch(#1471)。原因是 <input :value="x"> 场景下,用户输入会让 DOM 的 .value 与 vnode 记录的 prev 不同步;仅比较 vnode 值会漏掉"DOM 已被用户改过"的情况,所以对 value 特判强制 patch。
FULL_PROPS(16)
与 CLASS/STYLE/PROPS 互斥(源码 patchFlags.ts:52-57 明确写了 "mutually exclusive")。触发条件是 hasDynamicKeys:
// buildProps 内
if (!arg && (isVBind || isVOn)) {
hasDynamicKeys = true
...
}
// 以及 :[key]="value"
} else { hasDynamicKeys = true } // analyzePatchFlag 的非静态 key 分支
hasDynamicKeys 来源于两种模板写法:v-bind="obj"(无参 v-bind,展开对象)和 :[dynamicKey]="val"(动态参数)。一旦有动态 key,新的 key 可能出现、旧的 key 可能消失,而"按 dynamicProps 列表逐个 patch"的优化无法处理"删除旧 key"这个动作,所以必须退回全量 diff。消费侧(renderer.ts:933-935)直接 patchProps(el, oldProps, newProps, ...)。
还有一个运行时补充规则(renderer.ts:859-861):
let { patchFlag, dynamicChildren, dirs } = n2
// #1426 take the old vnode's patch flag into account since user may clone a
// compiler-generated vnode, which de-opts to FULL_PROPS
patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
用户的旧 vnode 若带 FULL_PROPS,新的也要强制 FULL_PROPS——这是为了防止"用户手写 render 克隆了旧 vnode"时优化信息不一致。这又是一条"优化前的安全兜底"。
NEED_HYDRATION(32)
触发条件(analyzePatchFlag,transformElement.ts:440-456):
const isEventHandler = isOn(name)
if (
isEventHandler &&
(!isComponent || isDynamicComponent) &&
name.toLowerCase() !== 'onclick' && // click 有 hydration 专属快路径
name !== 'onUpdate:modelValue' && // v-model 需要
!isReservedProp(name) // onVnodeXXX 钩子排除
) {
hasHydrationEventBinding = true
}
它标记"这个节点的事件/绑定在 hydration 时需要被挂载(而非在 patch 时更新)"。这解释了它的名字——hydration 需要,patch 不一定需要。消费侧有两处特判:
renderer.ts:768-770(buildProps里):needsPatch的计算把NEED_HYDRATION视作"已 patch"。const needsPatch = (patchFlag === 0 || patchFlag === PatchFlags.NEED_HYDRATION) && (hasRef || hasVnodeHook || runtimeDirectives.length > 0)renderer.ts:2579(traverseStaticChildren):if (c2.patchFlag <= 0 || c2.patchFlag === PatchFlags.NEED_HYDRATION) { c2 = ch2[i] = cloneIfMounted(ch2[i]) c2.el = c1.el }<= 0覆盖了CACHED(-1)/BAIL(-2) 或"无 flag",加上NEED_HYDRATION——这几类节点的 DOM 元素可以"静态继承",不需要重建。
三种 FRAGMENT(64 / 128 / 256)
STABLE_FRAGMENT:子顺序不变。由transform.ts生成多子根 fragment 时设(transform.ts:380)、v-if 包裹 fragment 时设(vIf.ts:266)、v-for 常量源时设(vFor.ts:104)。运行时(renderer.ts:1133-1145)可以在dynamicChildren齐备时快速对齐,甚至直接patchBlockChildren。KEYED_FRAGMENT:patchKeyedChildren,走"最长递增子序列(LIS)"最小化移动(详见第 12 章)。UNKEYED_FRAGMENT:patchUnkeyedChildren,按位置逐个 patch,多退少补。
三者的共同点是:描述的是"fragment 内部结构如何变化",因此它们只出现在 v-if/v-for/根多子这些"结构可能变"的地方。 普通元素不会带这三种 flag。
NEED_PATCH(512)
源码注释(patchFlags.ts:81-87)说得极清楚:
/**
* Indicates an element that only needs non-props patching, e.g. ref or
* directives (onVnodeXXX hooks). since every patched vnode checks for refs
* and onVnodeXXX hooks, it simply marks the vnode so that a parent block
* will track it.
*/
NEED_PATCH = 1 << 9,
它不代表要做 props 更新,唯一作用是"让 vnode 带上一个 flag,从而被父块的 dynamicChildren 收集"。因为运行时每次 patch 一个 vnode 都会检查 ref 与 onVnodeXXX 钩子(renderer.ts 的 patch 主体),所以只要这个节点进了 dynamicChildren,它就会被访问到,ref/hook 自然生效。触发在 buildProps(transformElement.ts:768-773):
const needsPatch =
(patchFlag === 0 || patchFlag === PatchFlags.NEED_HYDRATION) &&
(hasRef || hasVnodeHook || runtimeDirectives.length > 0)
if (!shouldUseBlock && needsPatch) { patchFlag |= PatchFlags.NEED_PATCH }
注意 !shouldUseBlock:如果它本身成块(会被父块追踪),就不需要 NEED_PATCH 了。
DYNAMIC_SLOTS(1024)
组件动态插槽。触发在 transformElement.ts:178-182(buildSlots 返回 hasDynamicSlots)和 KeepAlive 的强制分支(transformElement.ts:158):
const { slots, hasDynamicSlots } = buildSlots(node, context)
vnodeChildren = slots
if (hasDynamicSlots) { patchFlag |= PatchFlags.DYNAMIC_SLOTS }
"动态插槽"指插槽名是动态的(<slot :name="x">),或默认插槽内容引用了 v-for 迭代变量(导致 slot 函数内容每轮不同)。带此 flag 的组件会强制更新(不能因为 props 相等就跳过),因为插槽函数本身可能携带新值。
DEV_ROOT_FRAGMENT(2048)
仅在开发构建存在。它描述"因为模板根级有注释,导致产物多出一个 fragment"这种情况。触发在 transform.ts:383-388:
if (__DEV__ && children.filter(c => c.type !== NodeTypes.COMMENT).length === 1) {
patchFlag |= PatchFlags.DEV_ROOT_FRAGMENT
}
源码注释(patchFlags.ts:96-100)说明它是 dev-only,因为生产构建注释会被 strip,多出的 fragment 不会存在。运行时在 renderer.ts:793-794、1096 消费它。它是"开发期多出结构,生产期消失"的典型例子,也提醒我们:dev 与 prod 的编译产物在结构上并不完全等价。
7.6.4 特殊 flag:负值的语义
源码明确划出特区(patchFlags.ts:104-123):
/**
* SPECIAL FLAGS -------------------------------------------------------------
* Special flags are negative integers. They are never matched against using
* bitwise operators (bitwise matching should only happen in branches where
* patchFlag > 0), and are mutually exclusive. When checking for a special
* flag, simply check patchFlag === FLAG.
*/
CACHED = -1,
BAIL = -2,
两条铁律:
- 负值不参与位运算。"位匹配只在
patchFlag > 0的分支里做"——这就是为什么patchElement里是if (patchFlag > 0) { ...位判定... }(renderer.ts:928),而负值走别的分支。 - 负值互斥,判定用
patchFlag === FLAG。因为负数不能按位组合。
CACHED = -1:标记"这是一个被缓存/提升的静态 vnode"。来源有二:
cacheStatic对纯静态元素设:(child.codegenNode as VNodeCall).patchFlag = PatchFlags.CACHED(cacheStatic.ts:83);cacheStatic对 TEXT_CALL 追加 CACHED 参数(cacheStatic.ts:119-122)。
源码注释(patchFlags.ts:111-115)点明它还是 hydration 的提示:"skip the entire sub tree since static content never needs to be updated"。运行时消费在 renderer.ts:2589(text 节点克隆)以及 hydration 跳过逻辑。
BAIL = -2:强制"退出 optimized mode"。注释(patchFlags.ts:116-123)给出场景:renderSlot() 在遇到非编译器生成的插槽(用户手写 render)时,或用户手动 cloneVNode 时。此时 vdom 不再保证"结构稳定",必须退回完整 diff。
消费在 renderer.ts:406-408 与 2192:
if (n2.patchFlag === PatchFlags.BAIL) {
optimized = false
n2.dynamicChildren = null
}
以及卸载判断(renderer.ts:2192-2193):patchFlag === PatchFlags.BAIL || (dynamicChildren && dynamicChildren.hasOnce) → 走完整遍历。
7.6.5 patchFlag 的"重算"与合并
patchFlag 是逐步累加出来的,而非一次算好。以 buildProps 为例,它经历了:
- 处理
v-bind.prop→|= NEED_HYDRATION(transformElement.ts:695-697); - 处理动态 key 的
v-bind:key→shouldUseBlock = true(597-600,注意不是 flag); analyzePatchFlag累积hasClassBinding/hasStyleBinding/dynamicPropNames/hasDynamicKeys/hasHydrationEventBinding;- 末尾统一
|= FULL_PROPS/CLASS/STYLE/PROPS/NEED_HYDRATION(752-767); needsPatch再决定是否|= NEED_PATCH(768-773)。
transformElement 之上的层还会继续加:KeepAlive 加 DYNAMIC_SLOTS、transformElement 加 TEXT 与 DYNAMIC_SLOTS。所以读 patchFlag 时不要假设"一个节点一种动态"——它是多轮位或的结果。
7.6.6 一个"推算 patchFlag"的练习
模板:
<div :class="{ active: isActive }" :style="{ color }" :id="id" ref="el">
{{ text }}
</div>
逐步推算:
:class是动态 →hasClassBinding = true;:style是动态 →hasStyleBinding = true;:id="id"进dynamicPropNames;ref="el"→hasRef = true(且不进 dynamicProps,见analyzePatchFlag的name !== 'ref'排除);- children 是动态插值 →
TEXT; - 无动态 key → 不走
FULL_PROPS。
如果这个 div 不是块,则补 NEED_PATCH(因为有 ref)。最终 flag = TEXT(1) | CLASS(2) | STYLE(4) | PROPS(8) | NEED_PATCH(512) = 527,dynamicProps = ["id"]。
若该 div 是根节点(会被 convertToBlock 成块),则 shouldUseBlock 让它跳过 NEED_PATCH,flag = 1|2|4|8 = 15,dynamicProps = ["id"]。
这个练习同时暴露一个易错点:ref 不进 dynamicProps。因为 ref 的更新走的是专门的 ref 逻辑(在 patch 主体里),不通过 props patch 循环。
7.6.7 patchFlag 与 Vue 2 优化的对比
| 维度 | Vue 2 | Vue 3 |
|---|---|---|
| 粒度 | 静态子树整体提取,动态节点靠 updateClass/updateStyle/updateListeners 运行时判类型 | 编译期逐节点打 patchFlag,运行期按位分支 |
| class/style | 运行时函数 updateClass 内部比较 | 编译期 CLASS/STYLE 位,运行时一次 hostPatchProp |
| 事件 | 每次 updateListeners 遍历新旧监听器 diff | 事件缓存(cacheHandlers)+ NEED_HYDRATION |
| 结构变化 | patchVnode 里 children 结构判断 | KEYED/UNKEYED/STABLE_FRAGMENT + Block 树 |
核心差异是**"优化决策前移"**:Vue 2 把很多判断留到运行时("这个 prop 是不是 class?"),Vue 3 在编译期就把答案编码进一个整数。代价是 patchFlag 需要编译器强一致,好处是运行时分支极少。
7.6.8 小结
- patchFlag 是一个多轮位或累积的整数;正值按位组合、负值互斥。
- 只读的位判定必须放在
patchFlag > 0分支内(renderer.ts:928)。 PROPS与dynamicProps是强绑定的成对契约。FULL_PROPS与CLASS/STYLE/PROPS互斥。NEED_PATCH的唯一职责是"让父块追踪我"。CACHED/BAIL是"退出优化的两种出口"。
7.7 静态"提升"在 3.5 的真实形态:hoistStatic.ts 已消失
7.7.1 一个必须纠正的认知:3.5 不再"提升元素节点"
如果你读过大量 3.x 早期(3.0~3.4)的文章,会记得那几个"牌面优化":_hoisted_1 = createVNode("div", { class: "static" })、_hoisted_2 = ...,以及 hoistStatic 选项。但在 3.5.44 中,packages/compiler-core/src/transforms/hoistStatic.ts 这个文件已经不存在了(本仓库 packages/compiler-core/src 目录下无 hoistStatic.ts;Glob 搜索 *hoist* 无结果)。
证据有两条:
- 运行时的弃用注释(
runtimeHelpers.ts:69-73):注释白纸黑字:"3.5+ 我们不再提升元素节点"。/** * @deprecated no longer needed in 3.5+ because we no longer hoist element nodes * but kept for backwards compat */ export const PUSH_SCOPE_ID: unique symbol = Symbol(__DEV__ ? `pushScopeId` : ``) /** * @deprecated kept for backwards compat */ export const POP_SCOPE_ID: unique symbol = Symbol(__DEV__ ? `popScopeId` : ``) - 替代者
cacheStatic.ts:transform.ts:337-339里hoistStatic选项现在调用的是cacheStatic:if (options.hoistStatic) { cacheStatic(root, context) }
所以本章不描述一个不存在的 hoistStatic.ts,而是解释 3.5 实际的静态优化体系:cacheStatic + ConstantTypes + context.hoist + stringifyStatic。这比复述旧文章更有价值——它同时解释了"为什么要删"。
7.7.2 为什么"提升元素"这条路在 3.5 被放弃
早期 hoistStatic 提升的是元素 vnode:把一个纯静态的 <div class="foo">bar</div> 编译成模块顶层的常量 vnode,render 里直接复用:
const _hoisted_1 = /*@__PURE__*/ createVNode("div", { class: "foo" }, "bar")
export function render() { return _hoisted_1 }
看起来很美:跨渲染复用同一个 vnode,省去每次 new 一个 vnode 的分配。但它有两个硬伤:
- vnode 不是不可变的。它携带
el(DOM 引用)、component(子组件实例引用)、ref等运行时状态。一旦同一个 vnode 被复用,DOM 引用就可能被上一个挂载周期"污染",在卸载/重挂/嵌套组件场景下产生难以排查的 bug(这也是后来hasOnce、cacheIndex、数组扩展运算符这些补丁的来源,见cacheStatic.ts:219-228引用的 #6978/#7138/#7114/#13221)。 - 块树让"提升元素"的收益变低了。有了
dynamicChildren,一个静态节点本来就已经在更新时被跳过了(它不进dynamicChildren)。提升它省下的只是"一次 vnode 分配",而代价是"跨渲染共享可变对象"的风险。
因此 3.5 的策略转向:不再把元素 vnode 提升到模块顶层,而是只在"确实能安全复用"的场景使用 CacheExpression(_cache[n]),并且配合 setBlockTracking 精确控制 block 收集行为。这就是 cacheStatic.ts 的使命。
7.7.3 cacheStatic 的主循环:只缓存"够格"的节点
cacheStatic(root, context)(cacheStatic.ts:40-49)先做一件事:
export function cacheStatic(root: RootNode, context: TransformContext): void {
walk(
root,
undefined,
context,
// Root node is unfortunately non-hoistable due to potential parent
// fallthrough attributes.
!!getSingleElementRoot(root),
)
}
第四个参数 doNotHoistNode 对单元素根设为 true——注释解释:"根节点不可缓存,因为它可能接收父组件的 fallthrough attrs(属性透传)"。也就是说,<div> 作为组件根,外部可能给它加 class/id,它的 props 并非真正静态,不能当缓存铁板钉钉。
walk(cacheStatic.ts:62-251)遍历 children,对每个 ElementTypes.ELEMENT 或 TEXT_CALL 判断是否"够格缓存":
if (child.type === NodeTypes.ELEMENT && child.tagType === ElementTypes.ELEMENT) {
const constantType = doNotHoistNode ? ConstantTypes.NOT_CONSTANT : getConstantType(child, context)
if (constantType > ConstantTypes.NOT_CONSTANT) {
if (constantType >= ConstantTypes.CAN_CACHE) {
;(child.codegenNode as VNodeCall).patchFlag = PatchFlags.CACHED
toCache.push(child)
continue
}
} else {
// 节点含动态子,但 props 可能可提升
...
}
}
这里出现了 ConstantTypes 分级(ast.ts:210-220):
export enum ConstantTypes {
NOT_CONSTANT = 0,
CAN_SKIP_PATCH, // 可跳过 patch
CAN_CACHE, // 可缓存
CAN_STRINGIFY, // 可字符串化(最高)
}
源码注释点明层级关系(ast.ts:210-214):"Static types have several levels. Higher levels implies lower levels."——可 stringify 的必然可 cache,可 cache 的必然可 skip patch。这是一个由强到弱的偏序。
判定函数 getConstantType(cacheStatic.ts:253-392)是整套静态分析的心脏,逻辑:
ELEMENT:先查constantCache(WeakMap,transform.ts:121)避免重复;若是isBlock(且非 svg/foreignObject/math)→ 直接NOT_CONSTANT(块意味着内有动态);若patchFlag === undefined才可能静态,然后递归检查 props 与 children 的常量类型,取最弱(最小值);对isBlock之外的元素,还会把它的isBlock降级、移除OPEN_BLOCK/block helper(cacheStatic.ts:332-348)。TEXT/COMMENT:CAN_STRINGIFY。IF/FOR/IF_BRANCH:NOT_CONSTANT(结构节点永远不静态)。INTERPOLATION/TEXT_CALL:递归其content。SIMPLE_EXPRESSION:返回node.constType(由createSimpleExpression/transformExpression设置)。COMPOUND_EXPRESSION:逐个 child 递归取最弱。JS_CACHE_EXPRESSION:CAN_CACHE。
这就是 isStaticNode 的真实形态:不是旧文章里那个 isStaticNode(node) 独立函数,而是 getConstantType 返回 >= CAN_CACHE。判定入口就是这一行(cacheStatic.ts:81-86)。
7.7.4 hoisted 数组与 _hoisted_n 常量
虽然"元素提升"没了,context.hoist 机制仍在(transform.ts:291-302):
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
},
它做三件事:
- 把表达式塞进
context.hoists数组; - 造一个名为
_hoisted_${length}的SimpleExpressionNode作为"引用"; - 在这个引用上挂
hoisted = exp(回指原表达式,供stringifyStatic等后续处理定位)。
genHoists 会把这些表达式生成为 const _hoisted_1 = ...(codegen.ts:567-585)。当前调用 context.hoist 的地方不在 cacheStatic 的主缓存路径,而在"props/dynamicProps 可提升"那条分支里(cacheStatic.ts:100-107):
const props = getNodeProps(child)
if (props) { codegenNode.props = context.hoist(props) }
if (codegenNode.dynamicProps) {
codegenNode.dynamicProps = context.hoist(codegenNode.dynamicProps)
}
即:当节点本身含动态子(不能整体缓存),但它的 props 对象是纯静态时,把这个 props 对象提升。例如:
<div class="wrap" :id="x">
{{ msg }}
</div>
props 对象里既有静态 class 又有动态 :id,通常不满足;但像 class="a" style="color:red"(编译成静态对象字面量)这种就满足 getGeneratedPropsConstantType >= CAN_CACHE(cacheStatic.ts:421-458),会被 hoist 成 _hoisted_1 = { class: "a", style: { color: "red" } },render 里复用这个对象引用。
dynamicProps 数组同理:["id", "foo"] 是每次渲染都一样的字符串数组,提升成常量可以省一次数组分配。
注意源码里
dynamicProps类型是string | SimpleExpressionNode(ast.ts:331)。它既可能是编译期就已渲染好的字符串字面量(stringifyDynamicPropNames直接拼出的["id", "foo"],transformElement.ts:954-961),也可能是被context.hoist替换成的_hoisted_n引用(cacheStatic.ts:105-107)。后者只有当数组内容确实常量时才发生。
7.7.5 静态字符串提升:stringifyStatic
元素提升虽然没了,但静态内容"字符串化"这一更激进的优化仍然保留,在 compiler-dom 的 stringifyStatic(stringifyStatic.ts:77-173)。它挂在 transformHoist 选项上(compiler-dom/src/index.ts:62:transformHoist: __BROWSER__ ? null : stringifyStatic),只在 Node 端(构建时)启用。
它的目标是把连续的一批纯静态节点合并成一个 _createStaticVNode("<html>...") 调用:
const _hoisted_1 = /*@__PURE__*/ _createStaticVNode(`<div class="a"><span>1</span></div>...`, 3)
第二个参数 3 是"这一个静态 vnode 代表了多少个 DOM 节点",供 hydration 正确认领节点数(stringifyStatic.ts:105-108)。
启用阈值(stringifyStatic.ts:41-44):
export enum StringifyThresholds {
ELEMENT_WITH_BINDING_COUNT = 5,
NODE_COUNT = 20,
}
即:一个 chunk 里节点数 ≥ 20 或带 binding 的元素数 ≥ 5 才值得字符串化(stringifyCurrentChunk,stringifyStatic.ts:96-99)。因为 createStaticVNode 走的是 innerHTML 路径——只有"足够大"的静态块,innerHTML 的一次性解析开销才划算。
它会规避几类不安全场景(analyzeNode,stringifyStatic.ts:213-294):
v-once节点不字符串化(findDir(node, 'once'));caption/thead/tr/th/tbody/...这些表格结构标签不字符串化(isNonStringifiable,stringifyStatic.ts:202-204)——因为通过innerHTML插入<tr>在部分浏览器里会被丢弃/提升,与命令式插入结果不一致;<option :value="1">这类不能安全字符串化(stringifyStatic.ts:266-274);- 含非已知 attr / 非常量 bind 的节点直接 bail。
transformHoist 的调用点在 cacheStatic.ts:248-250:
if (toCache.length && context.transformHoist) {
context.transformHoist(children, context, node)
}
也就是说:只有当前父节点确实有一定数量的缓存候选子节点时,才把这个 children 列表交给平台层的 stringifyStatic 去尝试合并。这是 core 与 platform 的分工:core 负责"哪些够格缓存",platform 负责"怎么把够格的合并成最优产物"。
7.7.6 性能账本:3.5 的静态优化到底省了什么
把 3.5 的静态优化能力列一张账:
| 优化 | 载体 | 省的什么 | 触发条件 |
|---|---|---|---|
静态节点不进 dynamicChildren | Block 树 | 更新时遍历次数 | 无 patchFlag 的节点天然不收集 |
| 整体缓存静态元素 | CacheExpression (_cache[n]) | vnode 分配 + 子树 diff | getConstantType >= CAN_CACHE |
| 其 props / dynamicProps 提升 | _hoisted_n | 对象/数组分配 | props 常量、节点含动态 |
| 静态内容字符串化 | _createStaticVNode | 大量 vnode 分配 | stringifyStatic 阈值 + 安全 |
TEXT_CALL 加 CACHED | patchFlag=-1 | hydration/克隆开销 | 文本调用完全静态 |
对比 3.0~3.4 的"元素提升到顶层",3.5 的账本更保守但更安全:放弃了"跨渲染共享元素 vnode"这条收益,换成"按需缓存 + 字符串化"两条更可控的路径。
7.7.7 小结
- 3.5+ 不再提升元素节点(
runtimeHelpers.ts:69-73明确弃用);hoistStatic.ts文件已不存在。 - 静态判定统一收敛到
getConstantType(cacheStatic.ts:253-392)与ConstantTypes偏序(ast.ts:215-220)。 context.hoist仍用于提升纯静态 props 对象 / dynamicProps 数组(cacheStatic.ts:100-107)。- 静态内容字符串化由
compiler-dom的stringifyStatic承担,仅在 Node 端启用(index.ts:62),阈值见stringifyStatic.ts:41-44。
7.8 cacheStatic 与 Cache 数组:v-once 与内联事件缓存
7.8.1 context.cache 与 context.cached 数组
缓存的最小原语是 context.cache(transform.ts:303-313):
cache(exp, isVNode = false, inVOnce = false) {
const cacheExp = createCacheExpression(
context.cached.length, exp, isVNode, inVOnce,
)
context.cached.push(cacheExp)
return cacheExp
},
createCacheExpression(ast.ts:790-805)产出一个 CacheExpression:
export interface CacheExpression extends Node {
type: NodeTypes.JS_CACHE_EXPRESSION
index: number
value: JSChildNode
needPauseTracking: boolean
inVOnce: boolean
needArraySpread: boolean
}
index 就是 _cache[n] 里的 n,等于"这是第几个缓存"(用 context.cached.length 作为下标,从 0 开始)。运行时为每个组件实例维护一个 renderCache 数组(在 renderer.ts 传入 _cache 参数),编译期的这个 n 就是数组下标。由于每个组件的 render 是独立的,_cache[n] 的下标在编译期固定——这是"编译期与运行时共享一个约定"的又一例。
7.8.2 v-once 如何生成缓存
transformOnce(vOnce.ts:8-27)非常短:
const seen = new WeakSet()
export const transformOnce: NodeTransform = (node, context) => {
if (node.type === NodeTypes.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 /* isVNode */, true /* inVOnce */)
}
}
}
}
要点:
seenWeakSet +inVOnce防重入:嵌套的 v-once 只处理最外层的(内层inVOnce=true直接 return)。context.helper(SET_BLOCK_TRACKING):因为缓存需要暂停 block tracking,得引入setBlockTrackinghelper。- 在 exit 阶段把
codegenNode包成缓存:context.cache(codegenNode, true, true)。isVNode=true与inVOnce=true会让genCacheExpression走 7.2.5 的"情形 B",生成_setBlockTracking(-1, true)序列。
生成产物:
const _hoisted_1 = ... // 可能
export function render(_ctx, _cache) {
return (_cache[0] || (
_setBlockTracking(-1, true),
(_cache[0] = _createElementVNode("div", null, "只渲染一次")).cacheIndex = 0,
_setBlockTracking(1),
_cache[0]
))
}
v-once 的语义就是"整个子树只在首次渲染时创建,之后永远复用同一个 vnode"——通过 _cache[0] 实现。
7.8.3 cacheHandlers:内联事件处理器的缓存
这是 Cache 数组最常用的场景。看 transformOn 的缓存判定(vOn.ts:81-114):
let shouldCache: boolean = context.cacheHandlers && !exp && !context.inVOnce
if (exp) {
const isMemberExp = isMemberExpression(exp, context)
const isInlineStatement = !(isMemberExp || isFnExpression(exp, context))
...
if (!__BROWSER__ && context.prefixIdentifiers) {
isInlineStatement && context.addIdentifiers(`$event`)
exp = dir.exp = processExpression(exp, context, false, hasMultipleStatements)
isInlineStatement && context.removeIdentifiers(`$event`)
shouldCache =
context.cacheHandlers &&
!context.inVOnce &&
!(exp.type === NodeTypes.SIMPLE_EXPRESSION && exp.constType > 0) &&
!(isMemberExp && node.tagType === ElementTypes.COMPONENT) &&
!hasScopeRef(exp, context.identifiers)
if (shouldCache && isMemberExp) {
if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
exp.content = `${exp.content} && ${exp.content}(...args)`
} else {
exp.children = [...exp.children, ` && `, ...exp.children, `(...args)`]
}
}
}
}
shouldCache 的五个否决条件都值得理解:
context.cacheHandlers必须开启(且prefixIdentifiers必须为 true,compile.ts:85-87强制);!context.inVOnce——v-once 内部不做事件缓存(反正整个子树只渲染一次,缓存没意义);!(SIMPLE_EXPRESSION && constType > 0)——运行时常量不缓存(<script setup>里defineProps之类的编译期已优化);!(isMemberExp && isComponent)——传给组件的成员表达式 handler 不缓存(#1541 的坑:<transition>依赖回调的length判断过渡结束,包裹箭头函数会破坏 arity);!hasScopeRef(exp, context.identifiers)——引用了闭包变量(v-for / v-slot 迭代变量)的 handler 不缓存,否则会拿到过期的值。
还有一个精妙的 "member expression 转调用" 处理(vOn.ts:119-125):当 shouldCache && isMemberExp 时,把 foo 改写成 foo && foo(...args),外层再包成箭头函数。这样做的目的是"既不缓存函数引用、又避免每轮重新 patch"——因为每次调用时都通过闭包重新取 foo 的最新值,但 handler 的引用身份不变。
缓存动作在最后(vOn.ts:169-174):
if (shouldCache) {
ret.props[0].value = context.cache(ret.props[0].value)
}
产物形如:
{ onClick: _cache[0] || (_cache[0] = $event => (_ctx.count++)) }
为什么事件缓存这么重要? 因为"内联箭头函数"每次渲染都是新对象。当它作为 prop 传给子组件时,子组件会认为"handler 变了",触发不必要的更新。缓存后引用身份稳定,子组件不受无意义的更新干扰。这在 <Transition>、<KeepAlive>、列表渲染里是实打实的优化。
7.8.4 cacheStatic 里的缓存分支与 cachedAsArray
回到 cacheStatic.walk(cacheStatic.ts:156-215)。当父节点的所有 children 都可缓存时,会走一个特殊优化:
let cachedAsArray = false
if (toCache.length === children.length && node.type === NodeTypes.ELEMENT) {
if (node.tagType === ElementTypes.ELEMENT && ... isArray(node.codegenNode.children)) {
node.codegenNode.children = getCacheExpression(createArrayExpression(node.codegenNode.children))
cachedAsArray = true
} else if (node.tagType === ElementTypes.COMPONENT && ...默认插槽...) {
const slot = getSlotNode(node.codegenNode, 'default')
if (slot) { slot.returns = getCacheExpression(...); cachedAsArray = true }
} else if (node.tagType === ElementTypes.TEMPLATE && ...具名插槽...) {
...
}
}
if (!cachedAsArray) {
for (const child of toCache) {
child.codegenNode = context.cache(child.codegenNode!)
}
}
逻辑:如果所有子节点都可缓存,就不要一个个缓存,而是把整个 children 数组缓存一次(省 n 个 _cache[n] 槽位与 n 次判定)。组件默认插槽、具名插槽亦同(缓存 slot.returns 数组)。
getCacheExpression(cacheStatic.ts:217-230)里有一行关键:
exp.needArraySpread = true
配合注释(cacheStatic.ts:219-228):缓存数组在 v-for 里会在首项挂载时被突变(#6978/#7138/#7114);更重要的是 #13221 的内存泄漏——缓存的 vnode 被替换成克隆时会绑定真实 DOM,卸载后 DOM 引用仍被缓存数组持有、阻止 GC。[...( )] 数组展开复制一份,避免原地突变,从源头堵住泄漏。这正是 7.2.5 情形 C 的编译侧来源。
7.8.5 小结
| 机制 | 触发 | 产物 | 关键源码 | ||
|---|---|---|---|---|---|
v-once | v-once 指令 | `_cache[n] | (_setBlockTracking(-1,true), ..., _cache[n])` | vOnce.ts、genCacheExpression | |
| 事件缓存 | cacheHandlers + 五个否决条件 | `_cache[n] | (_cache[n] = $event => ...)` | vOn.ts:81-174 | |
| 全子树缓存 | 所有 child 可缓存 | children 整体缓存为数组 + [...()] | cacheStatic.ts:156-230 | ||
| 静态 props 提升 | props 常量 | _hoisted_n | cacheStatic.ts:100-107 |
7.9 动态 props 收集与 patchFlag 的配合
7.9.1 为什么需要 dynamicProps:一个 flag 不够
patchFlag 的 PROPS(8) 位只回答"这个元素的 props 里有动态值",但没回答是哪些 key。运行时若不知道 key,就只能退化成 patchProps 的全量 diff——把 old/new 两个 props 对象的每个键都比一遍,这正是 Vue 2 的做法。为了把"广度"也砍掉,编译器在生成 patchFlag 的同时,并行生成一个字符串数组 dynamicProps,精确列出所有动态 prop 名。
产物形如:
_createElementVNode("div", { id: _ctx.id, title: _ctx.title, class: "static" }, null, 8 /* PROPS */, ["id", "title"])
第 5 参数 ["id", "title"] 就是 dynamicProps。注意 class: "static" 是静态值,不进名单。
7.9.2 收集规则:analyzePatchFlag 逐条
buildProps(transformElement.ts:384-853)遍历 props 时,对每个"静态 key"的 property 调用 analyzePatchFlag(transformElement.ts:440-504)。核心判定:
const analyzePatchFlag = ({ key, value }: Property) => {
if (isStaticExp(key)) {
const name = key.content
if (isEventHandler && isReservedProp(name)) hasVnodeHook = true
if (name === 'ref') hasRef = true
if (value.type === NodeTypes.JS_CACHE_EXPRESSION ||
((value.type === NodeTypes.SIMPLE_EXPRESSION ||
value.type === NodeTypes.COMPOUND_EXPRESSION) &&
getConstantType(value, context) > 0)) {
return // 跳过:cached handler 或常量值
}
if (name === 'class') hasClassBinding = true
else if (name === 'style') hasStyleBinding = true
else if (name !== 'ref' && name !== 'key' && !dynamicPropNames.includes(name)) {
dynamicPropNames.push(name)
}
} else {
hasDynamicKeys = true // 动态 key,如 :[foo]="bar"
}
}
逐条读:
- 只有静态 key 才可能进名单。
:[foo]="bar"走else分支只置hasDynamicKeys——key 本身不确定,无法预生成名单,于是升级为FULL_PROPS全量 diff(transformElement.ts:752-753)。 ref/key被显式排除(transformElement.ts:487-489)。它们不进dynamicProps,因为运行时有专门通道:ref由patchElement的 dirs 分支调用setRef,key只在 vnode 创建时用一次、不参与 patch。塞进名单只会增加无意义的比较。class/style单独走位:置hasClassBinding/hasStyleBinding,最终对应CLASS(2)/STYLE(4) 位,而非PROPS。- **保留名(
onVnodeXXX、onUpdate:modelValue、onclick)**不进名单,相关规则见 7.6 的NEED_HYDRATION。 - 已缓存/常量的 value 直接
return:这正是cacheHandlers与dynamicProps联动的关键——一旦事件处理器被_cache[n]缓存,其值不再变化,编译器便不再把它计入动态 props。所以@click="count++"的按钮常常patchFlag = 0:既不进名单,也不打NEED_HYDRATION(onclick豁免)。
7.9.3 组件的特例:class/style 也要进名单
元素上 class/style 走 CLASS/STYLE 位;组件上不一样(transformElement.ts:495-501):
if (isComponent && (name === 'class' || name === 'style') &&
!dynamicPropNames.includes(name)) {
dynamicPropNames.push(name)
}
原因:组件的 class/style 会被合并(fallthrough attrs)后透传到子组件根元素,父组件无法用"直接挂到 DOM 元素"的捷径处理,必须作为普通 prop 走 updateProps。这是"元素 vs 组件"两套 patch 通道的分野。
7.9.4 名单的字符串化:stringifyDynamicPropNames
名单最终要成为 vnode 的第 5 个参数,所以被序列化成源码字符串(transformElement.ts:954-961):
function stringifyDynamicPropNames(props: string[]): string {
let propsNamesString = `[`
for (let i = 0, l = props.length; i < l; i++) {
propsNamesString += JSON.stringify(props[i])
if (i < l - 1) propsNamesString += ', '
}
return propsNamesString + `]`
}
用 JSON.stringify 保证 prop 名(可能含连字符、:、@)被正确转义成合法字符串字面量。产物是编译期常量数组,运行时直接遍历,无需 Object.keys。
7.9.5 PROPS 与 dynamicProps 的强绑定契约
两者必须同生共死,运行时源码里有明确断言(renderer.ts:957-959):
if (patchFlag & PatchFlags.PROPS) {
// if the flag is present then dynamicProps must be non-null
const propsToUpdate = n2.dynamicProps!
对应编译器侧约束:dynamicPropNames.length 非空才置 PROPS 位(transformElement.ts:761-763);反之 vnodeDynamicProps 仅在名单非空时才生成(transformElement.ts:208-211)。任一方缺位,运行时会读到 undefined.length 而崩溃或漏更新。 这是 patchFlag 与附加参数之间唯一一处"必须成对"的地方(FULL_PROPS 则不需要名单,因为它本身就是全量 diff)。
7.9.6 运行时的两类消费方
同一份 dynamicProps,在两条 patch 通道里被消费:
- 元素:
patchElement(renderer.ts:957-968)只遍历名单,逐个hostPatchProp,并带if (next !== prev || key === 'value')短路——只有真变了、或表单value(必须强制同步)才落 DOM。 - 组件:
updateProps(componentProps.ts:269-278)同样只遍历名单设置新 props,并特判"跳过 emit 监听器"(isEmitListener),避免把事件更新交给普通 props 通道。
7.9.7 小结
| 关注点 | 规则 | 源码 |
|---|---|---|
| 谁进名单 | 仅静态 key 的普通 prop | transformElement.ts:440-504 |
| 谁被排除 | ref、key、缓存值、常量值、保留名 | 487-489、472-480 |
| 谁走专位 | class/style → CLASS/STYLE(元素);组件则进名单 | 482-501 |
| 动态 key | 直接升 FULL_PROPS,名单作废 | 752-753 |
| 字符串化 | JSON.stringify 逐项拼接 | 954-961 |
| 绑定契约 | PROPS 位 ⇔ 名单非空 | 761-763 / renderer.ts:957-959 |
7.10 真实模板的完整编译产物逐行解读
理论讲完,落到一次真实的编译。这一节把一个覆盖了插值、v-for、事件、组件、动态 prop 的模板完整编译一遍,然后逐行拆解。
7.10.1 待编译模板
<div :id="id">
<p>hello {{ msg }}</p>
<ul>
<li v-for="item in items" :key="item.id" :class="{ active: item.active }">{{ item.text }}</li>
</ul>
<button @click="count++">inc</button>
<Child :foo="foo" />
</div>
编译选项与 compileTemplate 对齐(compileTemplate.ts:219-231):mode:'module'、prefixIdentifiers:true、hoistStatic:true、cacheHandlers:true。本例没有任何静态子树,hoistStatic 只影响 props 常量,故产物中不会出现 _hoisted_*。
7.10.2 产物全文
import { createElementVNode as _createElementVNode, openBlock as _openBlock,
createElementBlock as _createElementBlock, toDisplayString as _toDisplayString,
renderList as _renderList, Fragment as _Fragment, normalizeClass as _normalizeClass,
resolveComponent as _resolveComponent, createVNode as _createVNode } from "vue"
export function render(_ctx, _cache) {
const _component_Child = _resolveComponent("Child")
return (_openBlock(), _createElementBlock("div", { id: _ctx.id }, [
_createElementVNode("p", null, "hello " + _toDisplayString(_ctx.msg), 1 /* TEXT */),
_createElementVNode("ul", null, [
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.items, (item) => {
return (_openBlock(), _createElementBlock("li", {
key: item.id,
class: _normalizeClass({ active: item.active })
}, _toDisplayString(item.text), 3 /* TEXT, CLASS */))
}), 128 /* KEYED_FRAGMENT */))
]),
_createElementVNode("button", { onClick: _cache[0] || (_cache[0] = $event => (_ctx.count++)) }, "inc"),
_createVNode(_component_Child, { foo: _ctx.foo }, null, 8 /* PROPS */, ["foo"])
], 8 /* PROPS */, ["id"]))
}
import的顺序由 helper 首次注册顺序决定(即context.helper(...)的调用次序),此处为便于阅读做了折行,实际是单行。
7.10.3 逐行解读
import段:每个 helper 都是x as _x别名导入(7.3)。_createElementVNode与_createElementBlock同时出现,说明模板里既有"非块元素"(p/ul/button)也有"块元素"(根div、每个li)。const _component_Child = _resolveComponent("Child"):写在 render 函数体内、return之前。_resolveComponent内部依赖currentRenderingInstance做缓存(把解析结果挂到实例上),所以不会每帧重新解析。它属于"函数前导"而非"模块前导"(见 7.1.2 的对比)。return (_openBlock(), _createElementBlock("div", ...)):根元素div被createRootCodegen(transform.ts:358-404)升级为 block。它必须开块——因为它的 children 含动态节点,需要dynamicChildren来收集。{ id: _ctx.id }+8 /* PROPS */, ["id"]:div 只有:id一个动态 prop,无class/ref,故 patchFlag 精确等于PROPS;名单["id"]是编译期常量(7.9.4)。_createElementVNode("p", null, "hello " + _toDisplayString(_ctx.msg), 1 /* TEXT */):p只有一个插值子节点,transformElement.ts:187-195判定"动态文本子节点"并置TEXT;单子节点被直接内联成 children(不套数组),所以 children 是"hello " + _toDisplayString(...)这个复合表达式(COMPOUND_EXPRESSION,由transformText合成)。TEXT位的语义(7.6.3):patch 时直接hostSetElementText,不做 children diff。ul的null+[...]:ul自身 patchFlag 为 0(无动态 props、无动态文本),故第二参数是null,且没有第四个 flag 参数——genVNodeCall在patchFlag=0时由genNullableArgs(7.4.3)裁掉尾部参数,只留 3 个参数。但它仍用数组 children,因为唯一子节点是 v-for 生成的 Fragment(不是文本)。(_openBlock(true), _createElementBlock(_Fragment, ...)):v-for 的产物。openBlock(true)即disableTracking(7.5.4):fragment 自身不收集dynamicChildren,改走完整 children diff。第 4 参数128 /* KEYED_FRAGMENT */说明循环项带 key(vFor.ts:101-108)。_renderList(_ctx.items, (item) => { return (_openBlock(), _createElementBlock("li", ...)) }):renderList是 helper,把数组/对象/数字/字符串统一成可迭代回调(runtime-core/src/helpers/renderList.ts)。回调体内每项都是_openBlock()+_createElementBlock("li", ...)——每个 li 都是一个 block(vFor.ts:189-205把childBlock.isBlock置为!isStableFragment,本例为 true)。为什么每项要独立成块?因为列表项的结构稳定,但每项自身的动态节点(这里是class与文本)需要能被独立、按位置地 patch。li的 props{ key: item.id, class: _normalizeClass({ active: item.active }) }::key的值进key字段;:class的对象值被包上normalizeClass(transformElement.ts:798-820的预归一化)。注意key虽写在 props 对象里,却不进dynamicProps(7.9.2 排除规则),所以没有PROPS位。3 /* TEXT, CLASS */:TEXT(1) | CLASS(2) = 3。文本动态 →TEXT;class绑定动态 →CLASS。运行时patchElement(renderer.ts:939-943、974-977)分别执行hostPatchProp(el,'class',...)与hostSetElementText。这里没有PROPS——class 的 key 编译期已知,走专用通道比走名字数组更快。button行:_createElementVNode("button", { onClick: _cache[0] || (_cache[0] = $event => (_ctx.count++)) }, "inc")。cacheHandlers把内联箭头函数缓存进_cache[0](7.8.3),值类型成为JS_CACHE_EXPRESSION,analyzePatchFlag提前return——于是名单为空、hasHydrationEventBinding因onclick豁免也为假,patchFlag 为 0,第 4 参数被裁掉。一行代码省掉了每次渲染的函数分配与每次 patch 的 props 比较。_createVNode(_component_Child, { foo: _ctx.foo }, null, 8 /* PROPS */, ["foo"]):组件用createVNode(非 block,因为它不是结构分支点),PROPS位 + 名单["foo"]交给组件更新通道updateProps(7.9.6)。组件被创建的瞬间,createBaseVNode会因shapeFlag & COMPONENT把它 push 进所在 block(vnode.ts:522-537)——即使 patchFlag 为 0 的组件也必须被追踪,否则更新时无法保留/卸载其组件实例。- 外层 div 的第 4/5 参数
8 /* PROPS */, ["id"]收尾,与第 4 点呼应。
7.10.4 运行时的 dynamicChildren 实际内容
按 vnode.ts:522-537 的收集规则(patchFlag>0 或 shapeFlag & COMPONENT,且非 NEED_HYDRATION、非块自身),div 这一次的 dynamicChildren 为:
div.dynamicChildren = [
p, // patchFlag 1 → TEXT
fragment, // v-for block,被父块追踪(7.5.3 第 3 步)
Child // shapeFlag & COMPONENT → 强制追踪
]
ul 与 button(patchFlag 0)不在其中——更新时这两棵子树被整段跳过。patchBlockChildren(renderer.ts:993-1037)会按位置对 p、fragment、Child 做针对性 patch;fragment 再对内部做完整 children diff(renderer.ts:1690 的 patchKeyedChildren)。
对比"无所作为"的基线:若不优化,div 树的每次更新都要 diff 5 个子节点及其整棵子树;现在只需 patch 3 个动态节点 + 一次 keyed diff。这正是 7.5 说的"把 diff 的广度从整棵树砍到动态节点集合"。
7.10.5 对照:改开关后产物的变化
同一模板把 cacheHandlers:false 后再编译,button 行变成:
_createElementVNode("button", { onClick: $event => (_ctx.count++) }, "inc")
只少了 _cache[0];而 dynamicProps 名单依然为空、patchFlag 依然为 0——因为 onclick 豁免与"内联语句 handler"两条规则都不依赖缓存。真正受 cacheHandlers 影响的,是函数引用身份(影响子组件是否需要更新),而非本元素的 patchFlag。这一点常被误记,值得强调。
至于 hoistStatic:在 3.5 它由 cacheStatic 承担(7.7),本例无静态子树,故开关不影响产物。
7.10.6 小结
| 生成片段 | 决策来源 | 关键源码 |
|---|---|---|
根 div 用 createElementBlock | createRootCodegen | transform.ts:358-404 |
p 的 TEXT + 内联 children | 单动态文本子节点 | transformElement.ts:187-202 |
ul 无 flag、参数被裁 | genNullableArgs 尾部裁剪 | codegen.ts:880-886 |
v-for fragment openBlock(true) | disableTracking = !isStableFragment | vFor.ts:110-122 |
每項 li 成块 | childBlock.isBlock 决策 | vFor.ts:189-215 |
li 的 3 /* TEXT, CLASS */ | 文本 + class 绑定 | transformElement.ts:751-767 |
button 无 flag | cached handler 提前 return | transformElement.ts:472-480 |
组件 8 /* PROPS */, ["foo"] | 组件动态 prop 名单 | transformElement.ts:761-763 |
7.11 调试与工具
7.11.1 Template Explorer 与 compile() 等价物
https://template-explorer.vuejs.org 内核就是 @vue/compiler-dom 的 compile(),右上角可切换 hoistStatic、cacheHandlers、prefixIdentifiers、ssr——7.10.5 的对照可在那里直接复现。CLI 侧等价写法:
import { compile } from '@vue/compiler-dom'
const { code } = compile(template, {
mode: 'module', prefixIdentifiers: true, hoistStatic: true, cacheHandlers: true,
})
7.11.2 onError/onWarn 与 Source Map
baseCompile(compile.ts:71-90)把所有诊断交给 options.onError;compileTemplate 把 onError 收进 errors、onWarn 收进 warnings(compileTemplate.ts:235-236),并用 generateCodeFrame 打印带下划线的源码片段(compileTemplate.ts:251-261)。sourceMap:true 时 generate() 逐段记录映射(codegen.ts:283-406),再由 compileTemplate 的 mapLines 合并(compileTemplate.ts:266-317)——这是浏览器断点能直接落在 .vue 模板行的原因。
7.11.3 读产物的三个抓手
- 看 helper 与
_hoisted_*→ 定位静态优化边界(7.3/7.7)。 - 看
openBlock/createXxxBlock→ 定位 Block 边界(7.5)。 - 看 patchFlag 数字注释与第 5 参数 → 定位运行时 diff 的粒度(7.4/7.6/7.9)。
7.12 本章要点回顾
围绕"把运行时要做的判断,尽量提前到编译期算完"这一主线:
generate()是纯函数:吃一棵已 transform 的 AST,吐{ code, preamble, ast, map }(7.1)。- 产物靠原语拼装:
push/indent/newline+genNode穷尽分发;genNullableArgs砍掉尾部null(7.2/7.4)。 - helper 走别名导入:
_createElementBlock等x as _x;optimizeImports支持 webpack code-split,PURE_ANNOTATION保证 tree-shaking(7.3)。 - Block 树把 diff 的广度收敛到动态节点:
openBlock/createElementBlock+dynamicChildren是运行时结构,编译期的isBlock决定在哪开块(7.5)。 patchFlag是"要 patch 什么"的位图:静态节点flag=0整段跳过;TEXT/CLASS/STYLE/PROPS各对应一条专用通道(7.6)。dynamicProps是PROPS的配套名单,二者必须成对(7.9)。- 3.5 的静态优化不再提升元素:
hoistStatic.ts已移除,改由cacheStatic缓存 vnode/handler、提升简单常量;stringifyStatic(compiler-dom)走innerHTML批量固化(7.7/7.8)。 Cache数组承载 v-once 与 handler 缓存,cacheHandlers的五个否决条件尤其容易被忽略(7.8)。
一句话收束:模板编译器的一切努力,都是把"运行时无法预知"变成"编译期已知"——能省掉的分支、比较与分配,一个都不留给运行时。