Vue 3 核心源码深度剖析系列(v3.5.44)-第 07 章 编译器内幕(三):`codegen`、`Block` 树与 `patchFlags`

0 阅读23分钟

Vue 3 核心源码深度剖析系列(v3.5.44)-第 07 章 编译器内幕(三):codegen、Block 树与 patchFlags

本章对应 vuejs/core 3.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
}

这里有两处设计细节值得先立个桩:

  1. generate 是纯函数。它不修改传入的 ast,所有中间状态都塞进 CodegenContext。这与 transform 的"原地改写"形成鲜明对比(见第 05 章 5.1.2 的对照表)。纯函数的意义在于:你可以对同一个 AST 反复以不同选项生成代码(例如 SSR fallback、source map 开关),而不会互相污染。
  2. preamble 只在内联模式下非空。当 options.inline === true(compiler-sfc 的 <script setup> 内联编译路径会用到)时,render 函数不是独立模块,而是一段被塞进 setup() 里的箭头函数;此时前导的 import 语句必须单独返回,由上层拼接到文件顶部。否则 preamble 恒为 ''(codegen.ts:404)。

generate 的骨架可以拆成 6 步(行号对应 codegen.ts:283-406):

步位置动作
①289-306createCodegenContext 建上下文;读取选项快照(mode/prefixIdentifiers/ssr/scopeId 等)
②311-318决定前导上下文:内联模式用独立的子上下文,否则复用主上下文
③314-318生成前导:module 模式走 genModulePreamble,否则 genFunctionPreamble
④320-380生成 render/ssrRender 函数骨架(签名、with 块、assets、temps)
⑤383-390return + 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 */)
  ]))
}

四段结构一目了然:

  1. import 段:所有用到的 runtime helper,统一以 xxx as _xxx 别名导入(7.3)。
  2. hoist 段:const _hoisted_1 = ... 的静态常量(7.7)。
  3. 渲染函数:export function render(_ctx, _cache) { return ... }。
  4. 表达式体:那串由 _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 可以总结成三条不变量:

  1. 顺序即语义:import → hoists → function → return expr 的顺序不能乱。hoist 必须在函数外生成(跨渲染复用),helper import 必须在最前(hoist 会引用它们)。
  2. 纯函数 + 上下文隔离:所有可变状态在 CodegenContext 里;内联模式的 preamble 用独立子上下文生成(codegen.ts:311-313),以免和主代码的缩进/列号串味。
  3. 优化只以"表达式"和"标志位"两种形态落地:要么变成特殊的 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)
}

三个关键事实:

  1. 缩进单位是 2 个空格(' '.repeat(n)),不是 tab。
  2. indent() 不是"插入几个空格",而是"先把层级 +1,再换行到新层级"。换言之,缩进只在换行时体现;在同一行内 indent() 不会产生任何空格。这就是为什么生成的数组元素看起来是"换行 + 2 空格"。所以调用 indent() 后没有紧跟 push 是安全的(只是白白换了一行),但如果逻辑上期望"缩进但不换行"就会踩坑。
  3. 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
TEXTgenTextJSON.stringify 内容
SIMPLE_EXPRESSIONgenExpression静态则 stringify,动态则原样
INTERPOLATIONgenInterpolation包一层 _toDisplayString(...)
TEXT_CALL递归 codegenNodev-text / 纯文本插值转成的调用
COMPOUND_EXPRESSIONgenCompoundExpression逐 child 拼接
COMMENTgenComment_createCommentVNode("...")
VNODE_CALLgenVNodeCall核心,见 7.4
JS_CALL_EXPRESSIONgenCallExpressionhelper 调用
JS_OBJECT_EXPRESSIONgenObjectExpressionprops 对象
JS_ARRAY_EXPRESSIONgenArrayExpressionchildren 数组
JS_FUNCTION_EXPRESSIONgenFunctionExpression事件/slot/renderList 回调
JS_CONDITIONAL_EXPRESSIONgenConditionalExpressionv-if 三元
JS_CACHE_EXPRESSIONgenCacheExpression_cache[n] 逻辑
JS_BLOCK_STATEMENTgenNodeListSSR only
JS_TEMPLATE_LITERAL / JS_IF_STATEMENT / JS_ASSIGNMENT_EXPRESSION / JS_SEQUENCE_EXPRESSION / JS_RETURN_STATEMENTSSR only
IF_BRANCHnoop分支本身不直接生成
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 小结:原语的设计哲学

这一节可以总结出四条"原语哲学":

  1. genNode 是唯一入口:任何节点/字符串/symbol 都走它,保证类型分布集中且穷尽可查。
  2. pure 全局开关:只有 hoist 段把它置 true,从而让"可能无副作用"的调用(插值、注释、vnode 创建)带上 /*@__PURE__*/,服务 tree-shaking。
  3. 多行 vs 单行由启发式决定:长度阈值 + (!__BROWSER__ || __DEV__),在"可读性"与"体积"之间按构建目标取舍。
  4. 复杂语义靠参数叠加: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 _xxxhelper 独占 _ 前缀命名空间
webpack code-splitoptimizeImports 物化常量避免 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)
tagstring | symbol | CallExpressiontransformElement 决定 "div" 或组件符号/resolveDynamicComponent(...)调用第 1 参
propsPropsExpressionbuildProps 产出的对象/mergeProps 调用第 2 参
children多种单文本子直接内联,多子为数组,组件为 slots第 3 参
patchFlagnumberbuildProps 的位或运算结果第 4 参
dynamicPropsstring | SimpleExpressionstringifyDynamicPropNames第 5 参
directivesDirectiveArgumentsbuildDirectiveArgs包在 withDirectives(...) 第 2 参
isBlockboolean是否需成为块决定用 createBlock 还是 createVNode
disableTrackingbooleanv-for fragment 才 trueopenBlock(true) 的参数
isComponentbooleantagType 是否为 COMPONENT决定 block/vnode helper 是 "Element" 版还是组件版
needsPatch / isBlockRequiredboolean供 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
}

生成一张真值表:

场景isBlockisComponentinSSRhelper
普通元素falsefalsefalsecreateElementVNode
普通元素块truefalsefalsecreateElementBlock
组件falsetruefalsecreateVNode
组件块truetruefalsecreateBlock
SSR 任意**truecreateVNode / 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 的设计可以概括为:

  1. 三套外壳按需包裹:withDirectives(openBlock(), createBlock(...)) 的俄罗斯套娃顺序固定(指令在最外、块在中间、创建在内)。
  2. helper 由两张真值表选定:isBlock × isComponent × inSSR 决定用哪个创建函数。
  3. 参数砍尾:genNullableArgs 让生成代码只声明必要参数,依赖运行时默认值。
  4. 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

它有两个特殊之处:

  1. 类型是 VNode[] 的交叉类型,多挂了一个可选 hasOnce 标记(用于 v-once 相关的卸载安全,见 7.5.5)。
  2. 它只收集"带 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 是整套机制的中枢,做三件事:

  1. 把当前收集到的 dynamicChildren 挂到本块上(currentBlock || EMPTY_ARR)。注意 || EMPTY_ARR——如果没有动态子节点,赋一个共享的空数组常量而不是每次 [],省一次分配。
  2. closeBlock() 弹出当前块。
  3. 把本块自己 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 的对应关系:

场景fragmentFlagdisableTracking运行时 diff 路径
常量源STABLE_FRAGMENTfalse(会收集)renderer.ts:1133 快速对齐
有 keyKEYED_FRAGMENTtruepatchKeyedChildren(renderer.ts:1690)
无 keyUNKEYED_FRAGMENTtruepatchUnkeyedChildren(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 fragmentvFor.ts:110-122isBlock=true,disableTracking=!isStableFragment
v-for 单个子元素vFor.ts:181-215若 !isStableFragment 或 isBlockRequired,把子元素"切成"块
动态组件/Teleport/SuspensetransformElement.ts:108-118(shouldUseBlock)这些可能动态替换,需块隔离
svg/foreignObject/mathtransformElement.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-600shouldUseBlock = true
KeepAlivetransformElement.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
    }
  }
}

两处精妙:

  1. removeHelper/helper 成对翻转:从块降级为 vnode 时,要把 OPEN_BLOCK 和 block helper 从 context.helpers 里"减一次引用"(因为不再需要),同时把普通 vnode helper 加进去。removeHelper(transform.ts:212-222)用的是引用计数——只有计数归零才从 helpers 集合删掉。这保证了"同一个 helper 被多处需要时不会被误删"。
  2. 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[&#34;render() 返回值<br/>openBlock() → createElementBlock('div')<br/><b>Block #0</b><br/>dynamicChildren = [h1, B1]&#34;]
  R --> H1[&#34;createElementVNode('h1')<br/>patchFlag=1 TEXT<br/>被 Block #0 收集&#34;]
  R --> B1[&#34;openBlock() → createElementBlock(Fragment)<br/><b>Block #1 (v-if)</b><br/>dynamicChildren = [p]&#34;]
  B1 --> P[&#34;createElementVNode('p')<br/>patchFlag=1 TEXT<br/>被 Block #1 收集&#34;]
  R --> S[&#34;createElementVNode('span') '静态'<br/>无 patchFlag,<b>不被任何块收集</b>&#34;]
  R --> B2[&#34;openBlock(true) → createElementBlock(Fragment)<br/><b>Block #2 (v-for)</b><br/>dynamicChildren = null (disableTracking)&#34;]
  B2 --> L[&#34;renderList(list, item =><br/>&nbsp;&nbsp;openBlock() → createElementBlock('li')<br/>&nbsp;&nbsp;Block #2.1<br/>&nbsp;&nbsp;dynamicChildren = [text] )&#34;]

读图要点:

  • 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(动态节点数 + 结构变化点数)。 静态节点数越多,收益越大。

代价:

  1. 运行时多两个模块级变量 + openBlock/closeBlock 的 push/pop + setupBlock 的一次数组赋值。小模板下略有开销。
  2. 正确性复杂度:一旦"结构稳定"的前提被破坏(用户手写 render 克隆 vnode、非编译器生成的 slot 等),必须能"退回完整 diff"。这就是 BAIL、hasOnce、dynamicChildren.length 不一致检测存在的原因——优化越激进,回退机制就必须越严密。

7.5.10 小结

概念作用关键源码
Block一个带 dynamicChildren 的 VNodevnode.ts:219
openBlock开块 + 指向新的收集数组vnode.ts:271-273
createBlock/setupBlock挂 dynamicChildren + 关块 + 把自己塞进父块vnode.ts:311-373
disableTrackingv-for fragment 放弃收集,改走完整 diffvfor.ts:119
isBlockTreeEnabled整数计数器支持 v-once 嵌套暂停vnode.ts:284
setBlockTracking暂停/恢复收集 + 打 hasOncevnode.ts:302-309
patchBlockChildren按位置对齐遍历 dynamicChildrenrenderer.ts:993-1037
回退(BAIL/hasOnce/长度不符)破坏前提时退回完整 diffrenderer.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) { ... }
 */

三点要义:

  1. patchFlag 是"优化提示",不是"渲染指令"。它只告诉运行时"这个节点可能需要更新哪些方面",不保证真的变了——运行时仍然要做实际比较(比如 oldProps.class !== newProps.class)。
  2. 它是位掩码(bitmask):多个 flag 用 | 组合,用 & 判定。所以"一个节点只有一种动态"这个前提并不成立——它可以同时 TEXT | PROPS。
  3. 它只在"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 全枚举逐位对照表

名称位值含义运行时消费点
TEXT1<<01元素有动态 textContent(children 快路径)renderer.ts:974-978(n1.children !== n2.children 时 hostSetElementText)
CLASS1<<12动态 class 绑定renderer.ts:939-943
STYLE1<<24动态 stylerenderer.ts:947-949
PROPS1<<38有非 class/style 的动态 props;同时带 dynamicProps 列表renderer.ts:957-969(只遍历 dynamicProps)
FULL_PROPS1<<416props 含动态 key(v-bind="obj"/:[k]="v"),必须全量 diffrenderer.ts:933-935(patchProps 全量)
NEED_HYDRATION1<<532需要 hydration 的事件/bind(并非一定 patch)hydration 路径;renderer.ts:2579 特判其视为"无 patch"
STABLE_FRAGMENT1<<664子节点顺序不变的 fragmentrenderer.ts:1133-1145、2257
KEYED_FRAGMENT1<<7128有 key 或部分 key 的 fragmentrenderer.ts:1690-1704(patchKeyedChildren)
UNKEYED_FRAGMENT1<<8256无 key 的 fragmentrenderer.ts:1705-1718(patchUnkeyedChildren)
NEED_PATCH1<<9512只需非 props 的 patch(ref/指令/onVnodeXXX),仅用于让父块追踪仅作为"被收集"的标记;实际 patch 逻辑在 patch 主流程
DYNAMIC_SLOTS1<<101024组件有动态插槽(动态插槽名或引用 v-for 值)组件 patch 路径强制更新
DEV_ROOT_FRAGMENT1<<112048根级注释导致的多余 fragment(仅 dev)renderer.ts:793-794、1096
CACHED特殊-1缓存的静态 vnode;hydration 可跳过整棵子树renderer.ts:2589(text 克隆)、hydration
BAIL特殊-2让 diff 退出 optimized mode,强制完整 diffrenderer.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 不一定需要。消费侧有两处特判:

  1. renderer.ts:768-770(buildProps 里):needsPatch 的计算把 NEED_HYDRATION 视作"已 patch"。
    const needsPatch =
      (patchFlag === 0 || patchFlag === PatchFlags.NEED_HYDRATION) &&
      (hasRef || hasVnodeHook || runtimeDirectives.length > 0)
    
  2. 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,

两条铁律:

  1. 负值不参与位运算。"位匹配只在 patchFlag > 0 的分支里做"——这就是为什么 patchElement 里是 if (patchFlag > 0) { ...位判定... }(renderer.ts:928),而负值走别的分支。
  2. 负值互斥,判定用 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 为例,它经历了:

  1. 处理 v-bind.prop → |= NEED_HYDRATION(transformElement.ts:695-697);
  2. 处理动态 key 的 v-bind:key → shouldUseBlock = true(597-600,注意不是 flag);
  3. analyzePatchFlag 累积 hasClassBinding/hasStyleBinding/dynamicPropNames/hasDynamicKeys/hasHydrationEventBinding;
  4. 末尾统一 |= FULL_PROPS/CLASS/STYLE/PROPS/NEED_HYDRATION(752-767);
  5. 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>

逐步推算:

  1. :class 是动态 → hasClassBinding = true;
  2. :style 是动态 → hasStyleBinding = true;
  3. :id="id" 进 dynamicPropNames;
  4. ref="el" → hasRef = true(且不进 dynamicProps,见 analyzePatchFlag 的 name !== 'ref' 排除);
  5. children 是动态插值 → TEXT;
  6. 无动态 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 2Vue 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* 无结果)。

证据有两条:

  1. 运行时的弃用注释(runtimeHelpers.ts:69-73):
    /**
     * @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` : ``)
    
    注释白纸黑字:"3.5+ 我们不再提升元素节点"。
  2. 替代者 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 的分配。但它有两个硬伤:

  1. vnode 不是不可变的。它携带 el(DOM 引用)、component(子组件实例引用)、ref 等运行时状态。一旦同一个 vnode 被复用,DOM 引用就可能被上一个挂载周期"污染",在卸载/重挂/嵌套组件场景下产生难以排查的 bug(这也是后来 hasOnce、cacheIndex、数组扩展运算符这些补丁的来源,见 cacheStatic.ts:219-228 引用的 #6978/#7138/#7114/#13221)。
  2. 块树让"提升元素"的收益变低了。有了 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
},

它做三件事:

  1. 把表达式塞进 context.hoists 数组;
  2. 造一个名为 _hoisted_${length} 的 SimpleExpressionNode 作为"引用";
  3. 在这个引用上挂 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 的静态优化能力列一张账:

优化载体省的什么触发条件
静态节点不进 dynamicChildrenBlock 树更新时遍历次数无 patchFlag 的节点天然不收集
整体缓存静态元素CacheExpression (_cache[n])vnode 分配 + 子树 diffgetConstantType >= CAN_CACHE
其 props / dynamicProps 提升_hoisted_n对象/数组分配props 常量、节点含动态
静态内容字符串化_createStaticVNode大量 vnode 分配stringifyStatic 阈值 + 安全
TEXT_CALL 加 CACHEDpatchFlag=-1hydration/克隆开销文本调用完全静态

对比 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 */)
      }
    }
  }
}

要点:

  1. seen WeakSet + inVOnce 防重入:嵌套的 v-once 只处理最外层的(内层 inVOnce=true 直接 return)。
  2. context.helper(SET_BLOCK_TRACKING):因为缓存需要暂停 block tracking,得引入 setBlockTracking helper。
  3. 在 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 的五个否决条件都值得理解:

  1. context.cacheHandlers 必须开启(且 prefixIdentifiers 必须为 true,compile.ts:85-87 强制);
  2. !context.inVOnce——v-once 内部不做事件缓存(反正整个子树只渲染一次,缓存没意义);
  3. !(SIMPLE_EXPRESSION && constType > 0)——运行时常量不缓存(<script setup> 里 defineProps 之类的编译期已优化);
  4. !(isMemberExp && isComponent)——传给组件的成员表达式 handler 不缓存(#1541 的坑:<transition> 依赖回调的 length 判断过渡结束,包裹箭头函数会破坏 arity);
  5. !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-oncev-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_ncacheStatic.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"
  }
}

逐条读:

  1. 只有静态 key 才可能进名单。:[foo]="bar" 走 else 分支只置 hasDynamicKeys——key 本身不确定,无法预生成名单,于是升级为 FULL_PROPS 全量 diff(transformElement.ts:752-753)。
  2. ref / key 被显式排除(transformElement.ts:487-489)。它们不进 dynamicProps,因为运行时有专门通道:ref 由 patchElement 的 dirs 分支调用 setRef,key 只在 vnode 创建时用一次、不参与 patch。塞进名单只会增加无意义的比较。
  3. class / style 单独走位:置 hasClassBinding/hasStyleBinding,最终对应 CLASS(2)/STYLE(4) 位,而非 PROPS。
  4. **保留名(onVnodeXXX、onUpdate:modelValue、onclick)**不进名单,相关规则见 7.6 的 NEED_HYDRATION。
  5. 已缓存/常量的 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 的普通 proptransformElement.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 逐行解读

  1. import 段:每个 helper 都是 x as _x 别名导入(7.3)。_createElementVNode 与 _createElementBlock 同时出现,说明模板里既有"非块元素"(p/ul/button)也有"块元素"(根 div、每个 li)。
  2. const _component_Child = _resolveComponent("Child"):写在 render 函数体内、return 之前。_resolveComponent 内部依赖 currentRenderingInstance 做缓存(把解析结果挂到实例上),所以不会每帧重新解析。它属于"函数前导"而非"模块前导"(见 7.1.2 的对比)。
  3. return (_openBlock(), _createElementBlock("div", ...)):根元素 div 被 createRootCodegen(transform.ts:358-404)升级为 block。它必须开块——因为它的 children 含动态节点,需要 dynamicChildren 来收集。
  4. { id: _ctx.id } + 8 /* PROPS */, ["id"]:div 只有 :id 一个动态 prop,无 class/ref,故 patchFlag 精确等于 PROPS;名单 ["id"] 是编译期常量(7.9.4)。
  5. _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。
  6. ul 的 null + [...]:ul 自身 patchFlag 为 0(无动态 props、无动态文本),故第二参数是 null,且没有第四个 flag 参数——genVNodeCall 在 patchFlag=0 时由 genNullableArgs(7.4.3)裁掉尾部参数,只留 3 个参数。但它仍用数组 children,因为唯一子节点是 v-for 生成的 Fragment(不是文本)。
  7. (_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)。
  8. _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。
  9. 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 位。
  10. 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 编译期已知,走专用通道比走名字数组更快。
  11. 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 比较。
  12. _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 的组件也必须被追踪,否则更新时无法保留/卸载其组件实例。
  13. 外层 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 用 createElementBlockcreateRootCodegentransform.ts:358-404
p 的 TEXT + 内联 children单动态文本子节点transformElement.ts:187-202
ul 无 flag、参数被裁genNullableArgs 尾部裁剪codegen.ts:880-886
v-for fragment openBlock(true)disableTracking = !isStableFragmentvFor.ts:110-122
每項 li 成块childBlock.isBlock 决策vFor.ts:189-215
li 的 3 /* TEXT, CLASS */文本 + class 绑定transformElement.ts:751-767
button 无 flagcached handler 提前 returntransformElement.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 读产物的三个抓手

  1. 看 helper 与 _hoisted_* → 定位静态优化边界(7.3/7.7)。
  2. 看 openBlock / createXxxBlock → 定位 Block 边界(7.5)。
  3. 看 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)。

一句话收束:模板编译器的一切努力,都是把"运行时无法预知"变成"编译期已知"——能省掉的分支、比较与分配,一个都不留给运行时。