Vue 3 设计与实现

第 8 章 模板编译深度剖析

作者 杨艺韬 · 17,492 字 · 发布于 · 更新于

第 8 章 模板编译深度剖析

本章要点

  • Parse 阶段的完整实现:tokenizer.ts(改造自 htmlparser2 的字符级状态机)与 parser.ts(回调建树)如何协作产出 AST
  • 词法分析的状态机模型:标签开启、属性读取、指令分流、插值解析的状态流转
  • Transform 阶段的插件化架构:nodeTransforms 与 directiveTransforms 的协作机制
  • 核心转换插件剖析:v-if、v-for 的编译期展开与 transformElement / buildProps
  • PatchFlags 与 Block 的注入时机:从 AST 节点到 codegenNode 的关键跃迁
  • Codegen 阶段的代码拼接策略:如何生成可读且高效的渲染函数
  • Source Map 生成:从模板行列号到生成代码行列号的映射链路

第 7 章中,我们以"导游图"的视角鸟瞰了编译器的三阶段流水线。你知道了 Parse、Transform、Codegen 各自的职责边界,也知道了 PatchFlags、Block Tree、静态提升是在哪个阶段被注入的。但一份导游图再精美,也无法替代你亲自走进每一条街巷。

本章就是那次步行之旅。我们将从编译器的入口函数 baseCompile() 出发,沿着模板字符串被"消化"的路径,逐阶段、逐函数地追踪它的变形过程。每到一个关键节点,我们都会停下来,看看源码中的实际实现,理解设计者的意图。

一个忠告:本章代码量较大。建议你打开 Vue 3 源码仓库(packages/compiler-core/src/),与本章文字对照阅读。当你能在脑海中完整复现一个模板从字符串到渲染函数的旅程时,你就真正"拥有"了 Vue 编译器。

本章在全专栏中的位置

第 7 章(编译器概览)相当于"远景航拍"——让你看清编译器三阶段的整体轮廓、各自角色、接口契约。本章则是"地面走读"——带你走进每一条街巷、每一栋房子,看具体是怎么盖起来的。这两章是一体的:没有第 7 章的远景,本章的细节会让你迷失方向;没有本章的细节,第 7 章的远景只是一张漂亮的示意图、缺乏真实感。

本章也会多次回到前面章节的内容:第 2 章讲的"编译器平台无关性"在 8.1 节(DOMDirectiveTransforms)和 8.4 节(buildProps 调用 directiveTransforms)里会看到具体实现——DOM 版的 v-on、v-model 作为平台扩展覆盖核心编译器的同名实现;第 3-6 章讲的响应式 ref,会经 v-model 的 transformModel 展开成 modelValue prop 加 onUpdate:modelValue 事件处理器(8.4 节 buildProps 会看到这个处理器被特殊对待,完整展开留到第 13 章 §13.3);第 9 章的 Vapor 编译器会复用本章讲的 Parse 阶段——Parse 是平台无关、模式无关的公共基础设施。

本章读完后你应该能回答:一个包含 v-if / v-for / v-model / @click / :class 的模板,每一个指令分别在编译器的哪个阶段被处理、最终生成什么样的代码。这个问题的答案将会是你后续排查"为什么某个模板在某种场景下行为异常"的利器——直接去对应的 transform 文件找原因、而不是全盘重读源码。

8.1 编译器入口:baseCompile 与 compile

读编译器源码的最佳起点就是"入口"——从入口函数你能一眼看到整个编译流程的骨架,知道哪几个大步骤、以什么顺序、传什么数据。Vue 编译器的入口有两层:compile(平台相关的外层入口)和 baseCompile(平台无关的内层入口)。这种两层入口的设计是 Vue 编译器"核心 + 扩展"架构的起点——本节我们就从这两层入口讲起,逐步揭开整个编译器的内部结构。

两个 compile 函数

Vue 的两层 compile 设计反映的是平台无关 + 平台相关的经典分层。核心层(baseCompile)处理所有平台共通的逻辑——模板语法、指令系统、PatchFlag 计算;外层(DOM compile、SSR compile、Vapor compile)加入平台特有的扩展点——DOM 特定的指令(v-on、v-model)、SSR 特有的转换、Vapor 特有的 IR 生成。这种分层让 Vue 编译器可以用同一套核心服务多种编译目标——未来如果 Vue 要支持原生 UI 编译(类似 React Native)、添加一个 compile-native 包就行,核心代码不用动。

Vue 3 的编译器暴露了两层入口:

// packages/compiler-dom/src/index.ts:41
export function compile(
  src: string | RootNode,
  options: CompilerOptions = {},
): CodegenResult {
  return baseCompile(
    src,
    extend({}, parserOptions, options, {
      nodeTransforms: [
        // 忽略 <script> 与 <style>
        ignoreSideEffectTags,
        ...DOMNodeTransforms,
        ...(options.nodeTransforms || []),
      ],
      directiveTransforms: extend(
        {},
        DOMDirectiveTransforms,
        options.directiveTransforms || {},
      ),
      // 静态节点字符串化,只在非浏览器构建里开启
      transformHoist: __BROWSER__ ? null : stringifyStatic,
    }),
  )
}

parserOptions 里带着 parseMode: 'html'(让 8.2 节讲的 Tokenizer 识别 <script>、<textarea> 等特殊标签)、HTML/SVG/MathML 命名空间规则、void 标签表和 isBuiltInComponent(Transition / TransitionGroup);DOMDirectiveTransforms 用 DOM 版的 transformModel / transformOn 覆盖了 core 的同名实现,并新增 v-html / v-text / v-show / v-cloak。

compiler-dom 的 compile() 是面向用户的入口,它在 compiler-core 的 baseCompile() 基础上注入了 DOM 平台特定的解析选项和转换插件。这种"核心+平台"的分层模式在 Vue 3 中反复出现——响应式系统、渲染器、编译器都遵循同样的哲学。

baseCompile 的三步曲

// packages/compiler-core/src/compile.ts:67(省略了选项校验与 isTS 处理)
export function baseCompile(
  source: string | RootNode,
  options: CompilerOptions = {},
): CodegenResult {
  const isModuleMode = options.mode === 'module'
  // 浏览器构建不支持 prefixIdentifiers;module 模式隐含开启
  const prefixIdentifiers =
    !__BROWSER__ && (options.prefixIdentifiers === true || isModuleMode)
  const resolvedOptions = extend({}, options, { prefixIdentifiers })

  // 第一步:Parse(也允许直接传入已解析好的 AST)
  const ast = isString(source) ? baseParse(source, resolvedOptions) : source

  // 准备转换插件
  const [nodeTransforms, directiveTransforms] =
    getBaseTransformPreset(prefixIdentifiers)

  // 第二步:Transform
  transform(
    ast,
    extend({}, resolvedOptions, {
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []), // 用户 transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {}, // 用户 transforms
      ),
    }),
  )

  // 第三步:Codegen
  return generate(ast, resolvedOptions)
}

三行核心调用,三个阶段。简洁到令人愉悦。但每一行背后都藏着数千行实现代码。让我们逐一展开。

8.2 Parse 阶段:从字符串到 AST

Parse 是整个编译流水线的第一站——输入是用户写的模板字符串(长什么样我们说了不算、可能好几万字符),输出是结构化的 AST(抽象语法树)。这一步是"非结构化 → 结构化"的质变。所有后续阶段的工作都建立在 AST 之上——Parse 写错了、后面全部都会错。所以 Parse 是正确性最关键的一步。Vue 的 Parser 在 3.4 经历过一次彻底重写:3.0-3.3 是一个手写递归下降的 parse.ts,靠 source.slice() 不断"吃掉"字符串头部、用正则匹配标签名和属性;3.4 起(v3.4.0-alpha.2)换成了 tokenizer.ts + parser.ts 的两层结构——tokenizer.ts 是从 htmlparser2 移植改造的字符级状态机(约 1200 行),parser.ts(约 1100 行)在它的回调里建树。这么做的原因?状态机逐字符比较 charCode、直到一个 token 完整结束才切一次字符串,既没有正则回溯、也没有反复 slice 产生的临时字符串;同时 htmlparser2 已经把 HTML 解析的各种边界情况(RCDATA、CDATA、实体、注释变体)踩了一遍,Vue 只需在上面加插值和指令语法。 rc.4 用的就是这套解析器。

解析器的整体结构

Vue 的模板解析器分成两层,各司其职:

文件 角色 核心内容
tokenizer.ts 词法层:字符级状态机 Tokenizer 类、State 枚举(30 多个状态)、Callbacks 接口、Sequences(</script 等特殊序列)
parser.ts 语法层:回调建树 模块级解析状态、tokenizer 单例及其回调实现、baseParse() 入口、空白压缩、v-for 表达式解析

Tokenizer 不产生任何 AST 节点,它只在识别出一个片段(标签名、属性名、属性值、插值、文本、注释)时调用回调,传入的是这个片段在输入字符串里的起止下标。是否切出字符串、切出来放到哪个节点上,完全是 parser.ts 的事。入口 baseParse() 把两层串起来:

// packages/compiler-core/src/parser.ts:1028(精简)
export function baseParse(input: string, options?: ParserOptions): RootNode {
  reset()                       // 清空 tokenizer 与模块级状态(stack、currentOpenTag …)
  currentInput = input
  currentOptions = extend({}, defaultParserOptions)
  // ...合并 options

  tokenizer.mode =
    currentOptions.parseMode === 'html'
      ? ParseMode.HTML
      : currentOptions.parseMode === 'sfc'
        ? ParseMode.SFC
        : ParseMode.BASE

  const delimiters = options && options.delimiters
  if (delimiters) {
    tokenizer.delimiterOpen = toCharCodes(delimiters[0])
    tokenizer.delimiterClose = toCharCodes(delimiters[1])
  }

  const root = (currentRoot = createRoot([], input))
  tokenizer.parse(currentInput)          // 整个解析过程都发生在这一行里
  root.loc = getLoc(0, input.length)
  root.children = condenseWhitespace(root.children)
  currentRoot = null
  return root
}

为什么选状态机而不是继续手写递归下降?三个理由:

  1. 性能:每个字符只做整数比较(c === CharCodes.Lt),标签名、属性名的边界靠 isEndOfTagSection(c) 判断而不是正则;在只等待某个特定字符的状态里(注释、<script> 内部,浏览器构建下还有属性值)还能用 fastForwardTo() 直接跳过一大段
  2. 位置信息零成本:主循环顺手把每个换行符的下标记进 newlines 数组,getPos(index) 用二分查找就能算出行列号——不需要像旧 parser 那样每消费一段就 advancePositionWithMutation 地更新行列
  3. 借力成熟实现:htmlparser2 的状态机已经覆盖了 HTML 规范里注释、CDATA、<!DOCTYPE>、处理指令、实体解码的各种变体;Vue 在其上增加了 Interpolation*、InDir*、InSFCRootTagName 等状态来表达自己的语法扩展

解析状态:Tokenizer 字段与 parser.ts 的模块级变量

旧 parser 把所有状态装在一个 ParserContext 对象里;新 parser 的状态分在两处。Tokenizer 内部是扫描状态,parser.ts 顶层是建树状态:

// packages/compiler-core/src/tokenizer.ts:236(字段精简)
export default class Tokenizer {
  public state: State = State.Text   // 当前状态
  private buffer = ''                // 整个输入字符串(一次性传入,不再切割)
  public sectionStart = 0            // 当前正在读取的片段起点
  private index = 0                  // 当前扫描位置
  public inRCDATA = false            // 是否在 <script>/<style>/<textarea>/<title> 内
  public inXML = false               // 是否在 SVG / MathML 命名空间内
  public inVPre = false              // 是否在 v-pre 范围内(关闭插值识别)
  private newlines: number[] = []    // 所有换行符的下标,用于算行列号
  public mode: ParseMode = ParseMode.BASE
  public delimiterOpen: Uint8Array = defaultDelimitersOpen    // "{{" 的 charCode
  public delimiterClose: Uint8Array = defaultDelimitersClose  // "}}" 的 charCode
  constructor(private readonly stack: ElementNode[], private readonly cbs: Callbacks) {}
}

// packages/compiler-core/src/parser.ts:89(模块级状态)
let currentInput = ''                                  // 原始完整模板
let currentOpenTag: ElementNode | null = null          // 正在读取属性的开标签
let currentProp: AttributeNode | DirectiveNode | null = null  // 正在读取的属性/指令
let currentAttrValue = ''
let inPre = 0                                          // <pre> 嵌套深度
let inVPre = false
const stack: ElementNode[] = []                        // 开放元素栈,stack[0] 是当前父节点

const tokenizer = new Tokenizer(stack, { /* 回调实现,见下文 */ })

解析过程的核心模型不再是"消费字符串",而是"移动下标":index 每轮循环前进一格,sectionStart 记住当前片段从哪开始;片段结束时以 (sectionStart, index) 这对下标调用回调,parser.ts 需要文本时才 getSlice(start, end) 切一次。整个过程 buffer 从头到尾不变。

这种"一次扫描、下标传递"的模型比旧的"逐段 slice"模型对 GC 友好得多——旧 parser 每解析一个 token 就会产生一个新的剩余字符串。代价是状态机代码比递归下降更"扁"、可读性差一些:语法结构不再和函数调用结构同构,而是散落在几十个 stateXxx 方法之间。Vue 用大量注释和按命中频率排序的 switch 来弥补。

flowchart LR
    A["index=0  Text\n'&lt;div&gt;hello&lt;/div&gt;'"] -->|"遇到 &lt;"| B["BeforeTagName → InTagName\nonopentagname(1,4)"]
    B -->|"遇到 &gt;"| C["onopentagend → Text\nsectionStart=5"]
    C -->|"遇到 &lt;"| D["ontext(5,10) 'hello'\nBeforeClosingTagName"]
    D -->|"遇到 &gt;"| E["onclosetag(12,15) → Text"]
    E -->|"index 到末尾"| F["onend() → AST Root"]
    style A fill:#fff3e0,stroke:#ef6c00
    style F fill:#e8f5e9,stroke:#2e7d32

Tokenizer.parse:状态机主循环

"状态机"是编写词法分析器的经典方法——每个状态对应一个方法、每读一个字符就在当前状态的方法里决定"吐出什么、跳到哪个状态"。这种方式的好处是每一步的开销恒定且极小,坏处是逻辑分散。读状态机最好的方式是先看状态枚举、再看主循环、最后按一条输入的路径跟着状态跳。

State 枚举把所有状态分成几组:文本与插值(Text、InterpolationOpen、Interpolation、InterpolationClose)、标签(BeforeTagName、InTagName、InSelfClosingTag、BeforeClosingTagName、InClosingTagName、AfterClosingTagName)、属性与指令(BeforeAttrName、InAttrName、InDirName、InDirArg、InDirDynamicArg、InDirModifier、AfterAttrName、BeforeAttrValue、InAttrValueDq/Sq/Nq)、声明与注释(BeforeDeclaration、InDeclaration、InProcessingInstruction、BeforeComment、CDATASequence、InSpecialComment、InCommentLike)、特殊标签(BeforeSpecialS、BeforeSpecialT、SpecialStartSequence、InRCDATA),以及 InEntity 和 InSFCRootTagName。主循环就是一个按状态分发的 switch:

// packages/compiler-core/src/tokenizer.ts:944(精简)
public parse(input: string): void {
  this.buffer = input
  while (this.index < this.buffer.length) {
    const c = this.buffer.charCodeAt(this.index)
    if (c === CharCodes.NewLine && this.state !== State.InEntity) {
      this.newlines.push(this.index)          // 顺手记录换行位置
    }
    switch (this.state) {
      case State.Text:              this.stateText(c); break
      case State.InterpolationOpen: this.stateInterpolationOpen(c); break
      case State.Interpolation:     this.stateInterpolation(c); break
      case State.InterpolationClose:this.stateInterpolationClose(c); break
      case State.SpecialStartSequence: this.stateSpecialStartSequence(c); break
      case State.InRCDATA:          this.stateInRCDATA(c); break
      // ...其余 27 个状态,按命中频率从高到低排列
      case State.InEntity:          this.stateInEntity(); break
    }
    this.index++
  }
  this.cleanup()   // 把尚未吐出的文本/属性值片段吐出去
  this.finish()    // 处理残留数据,最后调用 cbs.onend()
}

private stateText(c: number): void {
  if (c === CharCodes.Lt) {
    if (this.index > this.sectionStart) {
      this.cbs.ontext(this.sectionStart, this.index)   // 先把前面的文本吐出去
    }
    this.state = State.BeforeTagName
    this.sectionStart = this.index
  } else if (!__BROWSER__ && c === CharCodes.Amp) {
    this.startEntity()                                  // 实体解码(非浏览器构建)
  } else if (!this.inVPre && c === this.delimiterOpen[0]) {
    this.state = State.InterpolationOpen                // 可能是 "{{"
    this.delimiterIndex = 0
    this.stateInterpolationOpen(c)
  }
}

private stateBeforeTagName(c: number): void {
  if (c === CharCodes.ExclamationMark) {        // <!  注释 / DOCTYPE / CDATA
    this.state = State.BeforeDeclaration
    this.sectionStart = this.index + 1
  } else if (c === CharCodes.Questionmark) {    // <?  处理指令
    this.state = State.InProcessingInstruction
    this.sectionStart = this.index + 1
  } else if (isTagStartChar(c)) {               // <a-z / <A-Z  开始标签
    this.sectionStart = this.index
    if (this.mode === ParseMode.BASE) {
      this.state = State.InTagName
    } else if (this.inSFCRoot) {
      this.state = State.InSFCRootTagName
    } else if (!this.inXML) {
      // HTML 模式:s 可能是 <script>/<style>,t 可能是 <title>/<textarea>
      if (c === 116 /* t */) this.state = State.BeforeSpecialT
      else this.state = c === 115 /* s */ ? State.BeforeSpecialS : State.InTagName
    } else {
      this.state = State.InTagName
    }
  } else if (c === CharCodes.Slash) {           // </  结束标签
    this.state = State.BeforeClosingTagName
  } else {
    this.state = State.Text                     // 孤立的 "<":退回文本
    this.stateText(c)
  }
}

🔥 深度洞察

stateText 和 stateBeforeTagName 合起来揭示了 Vue 模板的语法优先级:< 后面跟字母、!、?、/ 才进入标签相关状态,否则退回 Text——所以文本里的 a < b 不会被当成标签;{{ 的识别放在 < 之后,且 inVPre 时直接关闭。另一个细节是 newlines 数组:主循环每遇到 \n 就记一次下标,之后 getPos(index) 在超过 100 个换行时用二分查找、否则从末尾线性回扫——这就是新 parser 能给每个节点带上精确 loc 而几乎不付出代价的原因。

元素解析:开放元素栈与回调建树

一个元素的解析天然是三步——开标签、内容、闭标签——但在状态机 + 回调的模型里,这三步不再是同一个函数里的三行代码,而是三次回调:onopentagname 创建元素节点、onopentagend 把它挂到父节点并压栈、onclosetag 从栈里找到匹配的元素并出栈。嵌套靠栈而不是靠递归——这是把递归下降翻译成状态机的标准手法。

// packages/compiler-core/src/parser.ts:100(精简)
const tokenizer = new Tokenizer(stack, {
  onopentagname(start, end) {
    const name = getSlice(start, end)
    currentOpenTag = {
      type: NodeTypes.ELEMENT,
      tag: name,
      ns: currentOptions.getNamespace(name, stack[0], currentOptions.ns),
      tagType: ElementTypes.ELEMENT,   // 先按普通元素记,闭合时再细化
      props: [],
      children: [],
      loc: getLoc(start - 1, end),
      codegenNode: undefined,
    }
  },

  onopentagend(end) {
    endOpenTag(end)
  },

  onclosetag(start, end) {
    const name = getSlice(start, end)
    if (!currentOptions.isVoidTag(name)) {
      let found = false
      for (let i = 0; i < stack.length; i++) {
        const e = stack[i]
        if (e.tag.toLowerCase() === name.toLowerCase()) {
          found = true
          if (i > 0) {
            // 中间隔着未闭合的元素:报错,并把它们"隐式闭合"
            emitError(ErrorCodes.X_MISSING_END_TAG, stack[0].loc.start.offset)
          }
          for (let j = 0; j <= i; j++) {
            const el = stack.shift()!
            onCloseTag(el, end, j < i)
          }
          break
        }
      }
      if (!found) {
        emitError(ErrorCodes.X_INVALID_END_TAG, backTrack(start, CharCodes.Lt))
      }
    }
  },
  // ...
})

function endOpenTag(end: number) {
  addNode(currentOpenTag!)                  // 挂到 stack[0] 或 root 的 children 上
  const { tag, ns } = currentOpenTag!
  if (ns === Namespaces.HTML && currentOptions.isPreTag(tag)) inPre++
  if (currentOptions.isVoidTag(tag)) {
    onCloseTag(currentOpenTag!, end)        // <br> <img> 之类:立刻闭合
  } else {
    stack.unshift(currentOpenTag!)          // 否则压栈,成为后续子节点的父节点
    if (ns === Namespaces.SVG || ns === Namespaces.MATH_ML) tokenizer.inXML = true
  }
  currentOpenTag = null
}

function onCloseTag(el: ElementNode, end: number, isImplied = false) {
  // 补上结束位置;隐式闭合的元素回退到下一个 "<" 之前
  if (isImplied) setLocEnd(el.loc, backTrack(end, CharCodes.Lt))
  else setLocEnd(el.loc, lookAhead(end, CharCodes.Gt) + 1)

  // 细化元素类型:<slot> / 带 v-if、v-for、v-slot 的 <template> / 组件
  const { tag, ns, children } = el
  if (!inVPre) {
    if (tag === 'slot') el.tagType = ElementTypes.SLOT
    else if (isFragmentTemplate(el)) el.tagType = ElementTypes.TEMPLATE
    else if (isComponent(el)) el.tagType = ElementTypes.COMPONENT
  }

  // 空白处理:压缩 / 删除纯空白文本节点
  if (!tokenizer.inRCDATA) el.children = condenseWhitespace(children)
  // ...<pre>、v-pre 边界、命名空间的退出处理
}

注意 stack 的用法——unshift / shift 让 stack[0] 永远是"当前最内层的开放元素",addNode() 直接往 stack[0].children 里 push。当 onclosetag 收到 </div> 时,它从栈顶往下找同名元素:找到在第 i 层,就说明中间 i 个元素没闭合,一并出栈并报 X_MISSING_END_TAG;完全找不到,报 X_INVALID_END_TAG。文件结束时 onend() 会把栈里剩下的元素全部闭合并逐个报错。这就是 Vue 能给出精确的"标签未闭合"错误提示、同时还能产出一棵可用 AST 的原因。

还有一个容易忽略的点:tagType 是在闭合时才确定的。因为判断"是不是组件"需要看完所有属性(is="vue:xxx")、判断"是不是片段 <template>"需要知道它有没有 v-if / v-for / v-slot——这些在读开标签名的那一刻都还不知道。

属性解析:标签内部的状态流转

标签内部是整个状态机里状态最密集的区域,也是 Vue 相对 htmlparser2 改动最大的地方——因为 Vue 要在词法层就区分普通属性和指令。旧 parser 是先用正则把整个属性名抠出来再用另一个正则拆成 v-name:arg.mod;新 parser 在读第一个字符时就分流了:

// packages/compiler-core/src/tokenizer.ts:679(精简)
private handleAttrStart(c: number) {
  if (c === CharCodes.LowerV && this.peek() === CharCodes.Dash) {
    this.state = State.InDirName            // v-xxx
    this.sectionStart = this.index
  } else if (
    c === CharCodes.Dot || c === CharCodes.Colon ||
    c === CharCodes.At || c === CharCodes.Number
  ) {
    this.cbs.ondirname(this.index, this.index + 1)   // . : @ # 本身就是指令名
    this.state = State.InDirArg
    this.sectionStart = this.index + 1
  } else {
    this.state = State.InAttrName             // 普通属性
    this.sectionStart = this.index
  }
}

private stateInDirName(c: number): void {
  if (c === CharCodes.Eq || isEndOfTagSection(c)) {
    this.cbs.ondirname(this.sectionStart, this.index)
    this.handleAttrNameEnd(c)
  } else if (c === CharCodes.Colon) {         // v-bind:title
    this.cbs.ondirname(this.sectionStart, this.index)
    this.state = State.InDirArg
    this.sectionStart = this.index + 1
  } else if (c === CharCodes.Dot) {           // v-on.stop
    this.cbs.ondirname(this.sectionStart, this.index)
    this.state = State.InDirModifier
    this.sectionStart = this.index + 1
  }
}

private stateInDirArg(c: number): void {
  if (c === CharCodes.Eq || isEndOfTagSection(c)) {
    this.cbs.ondirarg(this.sectionStart, this.index)
    this.handleAttrNameEnd(c)
  } else if (c === CharCodes.LeftSquare) {    // :[dynamicKey]
    this.state = State.InDirDynamicArg
  } else if (c === CharCodes.Dot) {           // :title.prop
    this.cbs.ondirarg(this.sectionStart, this.index)
    this.state = State.InDirModifier
    this.sectionStart = this.index + 1
  }
}

private stateInDirModifier(c: number): void {
  if (c === CharCodes.Eq || isEndOfTagSection(c)) {
    this.cbs.ondirmodifier(this.sectionStart, this.index)
    this.handleAttrNameEnd(c)
  } else if (c === CharCodes.Dot) {           // .stop.prevent:一个修饰符结束
    this.cbs.ondirmodifier(this.sectionStart, this.index)
    this.sectionStart = this.index + 1
  }
}

属性名读完后进入 AfterAttrName:遇到 = 转 BeforeAttrValue,再按引号类型进入 InAttrValueDq / InAttrValueSq / InAttrValueNq;属性值结束时以 onattribdata + onattribend(quote, end) 收尾。这里值得注意的是空白字符的处理——<div class="foo"> 里的双空格由 stateBeforeAttrName 的 !isWhitespace(c) 分支静默跳过;而 = 前后允许空白(class = "foo")则靠 AfterAttrName 和 BeforeAttrValue 两个状态各吞一段空白。这种"微小但不能错"的细节在整个状态机里到处都是——好 parser 和差 parser 的区别就是这些细节处理得是否滴水不漏。

属性与指令的分流:parser.ts 的 ondirname / onattribend

Tokenizer 只负责"切分",属性节点的实际形态由 parser.ts 的回调决定。Vue 的属性有多种形态:

语法 类型 示例
普通属性 ATTRIBUTE class="foo"
v- 指令 DIRECTIVE v-if="show"
: 缩写 DIRECTIVE (bind) :title="msg"
@ 缩写 DIRECTIVE (on) @click="handler"
# 缩写 DIRECTIVE (slot) #default="{ item }"
. 修饰符 DIRECTIVE (bind + prop) .textContent="text"
// packages/compiler-core/src/parser.ts:190(精简)
onattribname(start, end) {
  // 普通属性
  currentProp = {
    type: NodeTypes.ATTRIBUTE,
    name: getSlice(start, end),
    nameLoc: getLoc(start, end),
    value: undefined,
    loc: getLoc(start),
  }
},

ondirname(start, end) {
  const raw = getSlice(start, end)
  const name =
    raw === '.' || raw === ':' ? 'bind'
      : raw === '@' ? 'on'
        : raw === '#' ? 'slot'
          : raw.slice(2)                       // "v-if" → "if"

  if (!inVPre && name === '') {
    emitError(ErrorCodes.X_MISSING_DIRECTIVE_NAME, start)
  }

  if (inVPre || name === '') {
    // v-pre 范围内:所有指令退化为普通属性
    currentProp = { type: NodeTypes.ATTRIBUTE, name: raw, nameLoc: getLoc(start, end), value: undefined, loc: getLoc(start) }
  } else {
    currentProp = {
      type: NodeTypes.DIRECTIVE,
      name,
      rawName: raw,
      exp: undefined,
      arg: undefined,
      modifiers: raw === '.' ? [createSimpleExpression('prop')] : [],   // .foo 自动带 prop 修饰符
      loc: getLoc(start),
    }
    if (name === 'pre') {
      inVPre = tokenizer.inVPre = true
      currentVPreBoundary = currentOpenTag
      // 同一标签上写在 v-pre 之前的指令也要转回属性
      const props = currentOpenTag!.props
      for (let i = 0; i < props.length; i++) {
        if (props[i].type === NodeTypes.DIRECTIVE) props[i] = dirToAttr(props[i] as DirectiveNode)
      }
    }
  }
},

ondirarg(start, end) {
  if (start === end) return
  const arg = getSlice(start, end)
  if (inVPre && !isVPre(currentProp!)) {
    ;(currentProp as AttributeNode).name += arg
  } else {
    const isStatic = arg[0] !== `[`             // [expr] 为动态参数
    ;(currentProp as DirectiveNode).arg = createExp(
      isStatic ? arg : arg.slice(1, -1),
      isStatic,
      getLoc(start, end),
      isStatic ? ConstantTypes.CAN_STRINGIFY : ConstantTypes.NOT_CONSTANT,
    )
  }
},

ondirmodifier(start, end) {
  const mod = getSlice(start, end)
  // ...v-pre 与 v-slot 的特殊处理
  const exp = createSimpleExpression(mod, true, getLoc(start, end))
  ;(currentProp as DirectiveNode).modifiers.push(exp)
},

onattribend(quote, end) {
  if (currentOpenTag && currentProp) {
    setLocEnd(currentProp.loc, end)
    if (quote !== QuoteType.NoValue) {
      if (currentProp.type === NodeTypes.ATTRIBUTE) {
        if (currentProp.name === 'class') currentAttrValue = condense(currentAttrValue).trim()
        currentProp.value = { type: NodeTypes.TEXT, content: currentAttrValue, loc: /* ... */ }
      } else {
        // 指令:属性值就是表达式;非浏览器构建下这里会直接用 @babel/parser 解析出 exp.ast
        currentProp.exp = createExp(currentAttrValue, false, getLoc(currentAttrStartIndex, currentAttrEndIndex))
        if (currentProp.name === 'for') {
          currentProp.forParseResult = parseForExpression(currentProp.exp)
        }
      }
    }
    if (currentProp.type !== NodeTypes.DIRECTIVE || currentProp.name !== 'pre') {
      currentOpenTag.props.push(currentProp)
    }
  }
  currentAttrValue = ''
  currentAttrStartIndex = currentAttrEndIndex = -1
},

🔥 深度洞察

Vue 模板中 v-bind 的三种写法(v-bind:title、:title、.title)在 Parse 阶段就被统一为 DirectiveNode,name 字段都是 'bind';.title 会自动带上 prop 修饰符。这种"语法糖在 Parse 阶段脱糖"的策略让后续的 Transform 和 Codegen 只需处理规范化后的 AST。另一个 3.4 起的变化藏在 createExp() 里:非浏览器构建且开启 prefixIdentifiers 时,指令表达式会在 Parse 阶段就交给 @babel/parser 解析、结果挂在 exp.ast 上(简单标识符走快速路径 ast = null,解析失败则 ast = false 并报 X_INVALID_EXPRESSION)。Transform 阶段的 transformExpression 直接复用这棵 AST,避免重复解析。

插值解析:oninterpolation

插值({{ expr }})是 Vue 模板里最高频的动态表达——任何一个模板几乎都有。它在 Tokenizer 里由三个状态处理:InterpolationOpen 逐字符匹配开定界符、Interpolation 等待闭定界符的首字符、InterpolationClose 逐字符匹配闭定界符。定界符被预先转成 Uint8Array 的 charCode 序列,所以自定义 delimiters: ['${', '}'] 也走同一条路径、不需要正则。

// packages/compiler-core/src/tokenizer.ts:354(精简)
private stateInterpolationOpen(c: number): void {
  if (c === this.delimiterOpen[this.delimiterIndex]) {
    if (this.delimiterIndex === this.delimiterOpen.length - 1) {
      const start = this.index + 1 - this.delimiterOpen.length
      if (start > this.sectionStart) {
        this.cbs.ontext(this.sectionStart, start)     // 先吐出 "{{" 之前的文本
      }
      this.state = State.Interpolation
      this.sectionStart = start
    } else {
      this.delimiterIndex++
    }
  } else if (this.inRCDATA) {
    this.state = State.InRCDATA; this.stateInRCDATA(c)   // 只有一个 "{":不是插值
  } else {
    this.state = State.Text; this.stateText(c)
  }
}

private stateInterpolationClose(c: number) {
  if (c === this.delimiterClose[this.delimiterIndex]) {
    if (this.delimiterIndex === this.delimiterClose.length - 1) {
      this.cbs.oninterpolation(this.sectionStart, this.index + 1)   // 含定界符的完整区间
      this.state = this.inRCDATA ? State.InRCDATA : State.Text
      this.sectionStart = this.index + 1
    } else {
      this.delimiterIndex++
    }
  } else {
    this.state = State.Interpolation; this.stateInterpolation(c)
  }
}
// packages/compiler-core/src/parser.ts:111(精简)
oninterpolation(start, end) {
  if (inVPre) {
    return onText(getSlice(start, end), start, end)   // v-pre 内:原样当文本
  }
  let innerStart = start + tokenizer.delimiterOpen.length
  let innerEnd = end - tokenizer.delimiterClose.length
  while (isWhitespace(currentInput.charCodeAt(innerStart))) innerStart++   // 用下标 trim
  while (isWhitespace(currentInput.charCodeAt(innerEnd - 1))) innerEnd--
  let exp = getSlice(innerStart, innerEnd)
  if (exp.includes('&')) {
    exp = __BROWSER__ ? currentOptions.decodeEntities!(exp, false) : decodeHTML(exp)
  }
  addNode({
    type: NodeTypes.INTERPOLATION,
    content: createExp(exp, false, getLoc(innerStart, innerEnd)),
    loc: getLoc(start, end),
  })
},

插值解析依旧简洁:Tokenizer 报告 {{ ... }} 的整体区间,parser.ts 用移动下标的方式去掉两端空白,所以 {{ msg }} 和 {{msg}} 的解析结果完全一致。表达式内容在这一层仍然被当成不透明字符串(createExp 只在非浏览器构建下顺手解析出 Babel AST,不做任何语义判断)——"把这段 JS 表达式字符串抠出来"是 parser 的全部职责。

特殊文本模式:RCDATA、RAWTEXT 与 ParseMode

"文本模式"是 HTML parser 必须处理的一个特殊复杂性——并非所有元素的内容都按同样规则解析。这不是 Vue 独有的问题——任何一个合格的 HTML parser(包括 WebKit、Gecko)都要处理。<textarea> 内部的 <div> 不应被当成嵌套标签、<script> 内部的 {{ foo }} 不应被当成插值……这些特殊规则是 HTML 标准的一部分,Vue parser 必须严格遵守、否则会和浏览器行为不一致。

旧 parser 用 TextModes 枚举(DATA / RCDATA / RAWTEXT / CDATA / ATTRIBUTE_VALUE)表达这件事;新 Tokenizer 用两样东西替代:一个 inRCDATA 标志加一组结束序列,以及决定"哪些标签算特殊"的 ParseMode:

// packages/compiler-core/src/tokenizer.ts:41
export enum ParseMode {
  BASE,   // compiler-core 单独使用:没有任何特殊标签
  HTML,   // compiler-dom:<script>/<style> 为 RAWTEXT,<title>/<textarea> 为 RCDATA
  SFC,    // compiler-sfc:根级除 <template> 外全部 RAWTEXT;<template lang="pug"> 也是
}

export const Sequences = {
  Cdata:       new Uint8Array([0x43, 0x44, 0x41, 0x54, 0x41, 0x5b]), // CDATA[
  CdataEnd:    new Uint8Array([0x5d, 0x5d, 0x3e]),                   // ]]>
  CommentEnd:  new Uint8Array([0x2d, 0x2d, 0x3e]),                   // -->
  ScriptEnd:   new Uint8Array([0x3c, 0x2f, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74]), // </script
  StyleEnd:    new Uint8Array([0x3c, 0x2f, 0x73, 0x74, 0x79, 0x6c, 0x65]),       // </style
  TitleEnd:    new Uint8Array([0x3c, 0x2f, 0x74, 0x69, 0x74, 0x6c, 0x65]),       // </title
  TextareaEnd: new Uint8Array([0x3c, 0x2f, 116, 101, 120, 116, 97, 114, 101, 97]), // </textarea
}

在 HTML 模式下,stateBeforeTagName 看到 <s / <t 会先进入 BeforeSpecialS / BeforeSpecialT,逐字符比对是不是 script、style、title、textarea;确认后调用 enterRCDATA(sequence, offset) 记下对应的结束序列,开标签结束时就进入 InRCDATA 状态——在这个状态里 Tokenizer 只找结束序列:

// packages/compiler-core/src/tokenizer.ts:425(精简)
private stateInRCDATA(c: number): void {
  if (this.sequenceIndex === this.currentSequence.length) {
    if (c === CharCodes.Gt || isWhitespace(c)) {
      // 找到完整的 </script 等结束序列:吐出前面的原始文本,转去处理闭标签
      const endOfText = this.index - this.currentSequence.length
      if (this.sectionStart < endOfText) this.cbs.ontext(this.sectionStart, endOfText)
      this.sectionStart = endOfText + 2
      this.stateInClosingTagName(c)
      this.inRCDATA = false
      return
    }
    this.sequenceIndex = 0
  }
  if ((c | 0x20) === this.currentSequence[this.sequenceIndex]) {
    this.sequenceIndex += 1                       // 大小写不敏感地逐字符匹配
  } else if (this.sequenceIndex === 0) {
    if (
      this.currentSequence === Sequences.TitleEnd ||
      (this.currentSequence === Sequences.TextareaEnd && !this.inSFCRoot)
    ) {
      // <title>/<textarea>(RCDATA):仍要解析实体和插值
      if (!__BROWSER__ && c === CharCodes.Amp) this.startEntity()
      else if (!this.inVPre && c === this.delimiterOpen[0]) {
        this.state = State.InterpolationOpen
        this.delimiterIndex = 0
        this.stateInterpolationOpen(c)
      }
    } else if (this.fastForwardTo(CharCodes.Lt)) {
      // <script>/<style>(RAWTEXT):直接快进到下一个 "<"
      this.sequenceIndex = 1
    }
  } else {
    this.sequenceIndex = Number(c === CharCodes.Lt)
  }
}

于是旧的四种文本模式在新实现里对应为:DATA 就是普通的 Text 状态;RCDATA(<textarea> / <title>)是 InRCDATA 且仍识别实体与插值;RAWTEXT(<script> / <style>)是 InRCDATA 且用 fastForwardTo 跳过一切;CDATA 走 CDATASequence → InCommentLike,解析完由 oncdata 回调判断——在 HTML 命名空间里 CDATA 是错误(CDATA_IN_HTML_CONTENT),在 SVG / MathML 里才作为文本接受。属性值不再是单独的"模式",而是 InAttrValueDq/Sq/Nq 三个状态。

stateDiagram-v2
    [*] --> Text: 默认
    Text --> BeforeTagName: 遇到 &lt;
    BeforeTagName --> BeforeSpecialS: HTML 模式 + s
    BeforeTagName --> BeforeSpecialT: HTML 模式 + t
    BeforeSpecialS --> SpecialStartSequence: 匹配 script / style
    BeforeSpecialT --> SpecialStartSequence: 匹配 title / textarea
    SpecialStartSequence --> InRCDATA: 开标签结束(inRCDATA=true)
    InRCDATA --> InClosingTagName: 匹配到结束序列
    InClosingTagName --> Text: 遇到 &gt;
    BeforeTagName --> CDATASequence: &lt;![
    CDATASequence --> InCommentLike: CDATA[ 匹配完成
    InCommentLike --> Text: 匹配到 ]]&gt; 或 &#45;&#45;&gt;

ParseMode.SFC 是 3.4 新 parser 顺带解决的一个老问题:.vue 文件的顶层 <script>、<style>、<i18n> 等块的内容不是模板,以前 compiler-sfc 要给旧 parser 传一个 getTextMode 回调,逐个告诉它哪些根级块按 RAWTEXT 解析;现在只要 parseMode: 'sfc',Tokenizer 在栈为空(inSFCRoot)时把除 <template> 之外的所有根级标签都按 RAWTEXT 处理,<template lang="pug"> 这种带预处理器的模板块也一样——compiler-sfc 的 parse() 直接复用 baseParse 就拿到了各个块的原始内容和 innerLoc。

最后是空白处理:onCloseTag 和 baseParse 末尾都会调用 condenseWhitespace()。默认(whitespace: 'condense')模式下,位于首尾、两个元素之间且含换行、或处在注释与注释 / 元素之间的纯空白文本节点被删除,其余空白压缩为一个空格;<pre> 内部(inPre > 0)只把 \r\n 归一为 \n。whitespace: 'preserve' 时只删除首尾的纯空白节点,元素间的纯空白保留为一个空格,含内容的文本不做压缩。这也是为什么模板里换行缩进不会变成一堆多余的文本节点。

8.3 Transform 阶段:从模板 AST 到增强 AST

Transform 是编译流水线里工作量最大的阶段——Parse 出来的 AST 只是"忠实反映用户写了什么",Transform 要做的是"把这些语法糖展开成运行时能理解的形式 + 做各种优化"。v-if 要变成条件分支、v-for 要变成循环、静态节点要被提升、Block 要被识别、PatchFlag 要被计算……这所有工作都在 Transform 阶段发生。本节讲的就是这个"流水线最繁忙的车间"内部是怎么工作的。Vue 在这里采用了插件化架构——所有的转换逻辑都是独立的"transformer 函数",编译器本身只是一个调度器。这种架构让整个编译器既灵活(可以加新指令转换)又清晰(每个 transformer 只管一件事)。

如果说 Parse 是"忠实的翻译官"——它只负责把模板结构如实转换为树形数据,不做任何判断。那么 Transform 就是"战略分析师"——它分析 AST 的语义,做出优化决策,为每个节点生成最优的代码生成方案。

Transform 的核心架构

读下面的 transform 函数你会惊讶于它有多短——不到 20 行就把整个 Transform 阶段的骨架搭起来了:遍历 AST、做静态提升、创建根 Block、收集元信息。核心函数薄意味着具体的变换工作全都下沉到了遍历过程中调用的插件里。这种"薄核心 + 多插件"的架构是 Vue 编译器能持续扩展的结构性原因——每加一个新优化就加一个新插件、不用动核心代码。

Transform 阶段的入口是 transform() 函数:

// packages/compiler-core/src/transform.ts:352(省略 __COMPAT__ 分支)
export function transform(root: RootNode, options: TransformOptions) {
  const context = createTransformContext(root, options)

  // 遍历并转换整棵 AST
  traverseNode(root, context)

  // 静态节点缓存(选项名沿用 hoistStatic,实现在 3.5 起改为 cacheStatic)
  if (options.hoistStatic) {
    cacheStatic(root, context)
  }

  // 创建根级 Block
  if (!options.ssr) {
    createRootCodegen(root, context)
  }

  // 收集元信息
  root.helpers = new Set([...context.helpers.keys()])
  root.components = [...context.components]
  root.directives = [...context.directives]
  root.imports = context.imports
  root.hoists = context.hoists
  root.temps = context.temps
  root.cached = context.cached
  root.transformed = true
}

TransformContext:转换上下文

"Context"这个概念在编译器里很常见——它承载着"跨节点共享的状态"。Vue 的 TransformContext 里几乎囊括了编译过程中所有需要共享的东西:当前遍历位置(currentNode / parent / childIndex)、全局收集的信息(helpers / components / directives / hoists)、辅助方法(helper / replaceNode)。读懂这个 Context 你就理解了整个 Transform 阶段的"共享数据模型"——后面所有具体 transformer 的代码都围绕这个模型工作。

// packages/compiler-core/src/transform.ts:86(精简)
export interface TransformContext
  extends Required<Omit<TransformOptions, keyof CompilerCompatOptions>> {
  // 插件列表(来自 TransformOptions)
  nodeTransforms: NodeTransform[]
  directiveTransforms: Record<string, DirectiveTransform>

  // 遍历状态
  root: RootNode
  currentNode: RootNode | TemplateChildNode | null
  parent: ParentNode | null
  grandParent: ParentNode | null
  childIndex: number
  scopes: { vFor: number; vSlot: number; vPre: number; vOnce: number }
  inVOnce: boolean

  // 收集器
  helpers: Map<symbol, number>   // 运行时辅助函数 → 引用计数
  components: Set<string>        // 使用到的组件
  directives: Set<string>        // 使用到的指令
  hoists: (JSChildNode | null)[] // 提升为模块级常量的表达式(静态 props 等)
  cached: (CacheExpression | null)[] // _cache[n] 缓存表达式
  temps: number                  // 临时变量计数
  imports: ImportItem[]          // 额外导入
  constantCache: WeakMap<TemplateChildNode, ConstantTypes>

  // 工具方法
  helper<T extends symbol>(name: T): T
  removeHelper<T extends symbol>(name: T): void
  helperString(name: symbol): string
  replaceNode(node: TemplateChildNode): void
  removeNode(node?: TemplateChildNode): void
  onNodeRemoved(): void
  addIdentifiers(exp: ExpressionNode | string, type?: 'local' | 'slot'): void
  removeIdentifiers(exp: ExpressionNode | string): void
  hoist(exp: string | JSChildNode | ArrayExpression): SimpleExpressionNode
  cache(exp: JSChildNode, isVNode?: boolean, inVOnce?: boolean): CacheExpression
}

helpers 是一个特别重要的字段。每当转换插件生成的代码需要调用运行时辅助函数(如 createVNode、toDisplayString、openBlock),就会通过 context.helper() 注册。这些 helper 最终会出现在生成代码的 import 语句中。

traverseNode:深度优先遍历

// packages/compiler-core/src/transform.ts:443
export function traverseNode(
  node: RootNode | TemplateChildNode,
  context: TransformContext
) {
  context.currentNode = node

  // 执行所有 nodeTransforms(收集退出函数)
  const { nodeTransforms } = context
  const exitFns: (() => void)[] = []
  for (let i = 0; i < nodeTransforms.length; i++) {
    const onExit = nodeTransforms[i](node, context)
    if (onExit) {
      if (isArray(onExit)) {
        exitFns.push(...onExit)
      } else {
        exitFns.push(onExit)
      }
    }
    // 节点可能被替换或删除
    if (!context.currentNode) {
      return
    } else {
      node = context.currentNode
    }
  }

  // 根据节点类型处理子节点
  switch (node.type) {
    case NodeTypes.COMMENT:
      if (!context.ssr) context.helper(CREATE_COMMENT)
      break
    case NodeTypes.INTERPOLATION:
      if (!context.ssr) context.helper(TO_DISPLAY_STRING)
      break
    case NodeTypes.IF:
      for (let i = 0; i < node.branches.length; i++) {
        traverseNode(node.branches[i], context)
      }
      break
    case NodeTypes.IF_BRANCH:
    case NodeTypes.FOR:
    case NodeTypes.ELEMENT:
    case NodeTypes.ROOT:
      traverseChildren(node, context)
      break
  }

  // 逆序执行退出函数(后进先出)
  context.currentNode = node
  let i = exitFns.length
  while (i--) {
    exitFns[i]()
  }
}

🔥 深度洞察

Transform 插件的"进入-退出"模式是整个转换架构中最精妙的设计。一个 nodeTransform 被调用时,返回的函数不会立即执行,而是等到所有子节点都已完成转换之后才执行。这意味着退出函数可以拿到子节点的完整转换结果,从而做出依赖子节点信息的决策。例如,transformElement 的退出函数需要知道子节点中哪些是动态的,才能正确计算 PatchFlags——这只有在子节点都已转换完毕后才能确定。

内置 nodeTransforms

Vue 3 内置了以下核心转换插件,它们的执行顺序至关重要:

// packages/compiler-core/src/compile.ts:32(省略 __COMPAT__ 下的 transformFilter)
export function getBaseTransformPreset(
  prefixIdentifiers?: boolean
): TransformPreset {
  return [
    [
      transformVBindShorthand,// 同名简写 :id → :id="id"(3.5.22 起独立成 transform)
      transformOnce,          // v-once
      transformIf,            // v-if / v-else-if / v-else
      transformMemo,          // v-memo
      transformFor,           // v-for
      // 以下在 prefixIdentifiers 模式(非浏览器构建)下启用;
      // 浏览器开发构建只启用 transformExpression 做表达式校验
      ...(!__BROWSER__ && prefixIdentifiers
        ? [trackVForSlotScopes, transformExpression]
        : __BROWSER__ && __DEV__
          ? [transformExpression]
          : []),
      transformSlotOutlet,    // <slot>
      transformElement,       // 普通元素和组件
      trackSlotScopes,        // 插槽作用域
      transformText,          // 文本合并
    ],
    {
      on: transformOn,        // v-on / @
      bind: transformBind,    // v-bind / :
      model: transformModel,  // v-model
    }
  ]
}

执行顺序有讲究:transformIf 和 transformFor 排在前面,因为它们会改变 AST 的结构(将元素节点替换为 IfNode 或 ForNode)。transformElement 排在后面,因为它需要在结构性指令已经处理完毕后,再为普通元素生成 codegenNode。

8.4 核心转换插件深度解析

上一节讲了 Transform 的整体框架——这一节深入具体的转换插件。Vue 的几个"招牌指令"(v-if / v-for / v-model / v-on / v-bind)都有各自的 transformer 文件——读这些 transformer 你会发现一个共同模式:每个指令的本质都是把"声明式描述"展开成"命令式执行代码"。v-if 展开成条件分支、v-for 展开成循环、v-model 展开成 "value 属性 + 事件监听 + ref 赋值"三部分——所有指令最终都归约为普通的 JavaScript 结构。这种"语法糖展开"的工作哲学在所有前端编译器里都一样——你读懂了 Vue 的就能迅速上手 React 的 JSX 编译器、Svelte 的编译器、SolidJS 的编译器。

transformIf:条件渲染的编译期展开

v-if / v-else-if / v-else 是所有指令里处理起来最棘手的——因为它们不是作用在单个元素上的修饰,而是定义多个元素之间的关系。transformIf 必须把"三个相邻元素分别带 v-if / v-else-if / v-else"的扁平结构折叠成一个单一的 IfNode——里面包含三个分支条件。这种"扁平结构折叠成嵌套结构"是所有结构性指令的共同模式。读下面的代码你会看到这个折叠是怎么实现的——关键在于遇到 v-else-if / v-else 时回头找上一个 IfNode、挂进去当作新分支。

当解析器遇到 v-if、v-else-if、v-else 指令时,它只是在元素的 props 中记录了一个 DirectiveNode。真正的结构变换发生在 transformIf 中:

// packages/compiler-core/src/transforms/vIf.ts:44(简化)
export const transformIf = createStructuralDirectiveTransform(
  /^(?:if|else|else-if)$/,
  (node, dir, context) => {
    return processIf(node, dir, context, (ifNode, branch, isRoot) => {
      // key 起点 = 前面同级 IfNode 的分支总数(#1587,计算过程略)
      let key = 0
      // 退出函数:在子节点都转换完后执行
      return () => {
        if (isRoot) {
          // v-if: 为整个 if 链生成 codegenNode
          ifNode.codegenNode = createCodegenNodeForBranch(branch, key, context)
        } else {
          // v-else-if / v-else: 嵌套到上一个分支的 alternate 中
          const parentCondition = getParentCondition(ifNode.codegenNode!)
          parentCondition.alternate = createCodegenNodeForBranch(
            branch,
            key + ifNode.branches.length - 1,
            context
          )
        }
      }
    })
  }
)

processIf 的关键逻辑:

  1. 遇到 v-if:创建一个新的 IfNode,将当前元素作为第一个分支(IfBranchNode),然后用 IfNode 替换原来的元素节点
  2. 遇到 v-else-if 或 v-else:在 AST 中找到相邻的 IfNode,将当前元素作为新分支追加进去,然后从父节点的 children 中删除当前元素
flowchart TB
    subgraph Before["Parse 后的 AST"]
        A["div v-if='a'"]
        B["span v-else-if='b'"]
        C["p v-else"]
    end
    subgraph After["Transform 后的 AST"]
        D["IfNode"]
        D --> E["Branch 0: div (condition: a)"]
        D --> F["Branch 1: span (condition: b)"]
        D --> G["Branch 2: p (condition: undefined)"]
    end
    Before -->|transformIf| After
    style Before fill:#fff3e0,stroke:#ef6c00
    style After fill:#e8f5e9,stroke:#2e7d32

生成的 codegenNode 是一个嵌套的条件表达式:

// 对应的生成代码结构
a
  ? /* render div */
  : b
    ? /* render span */
    : /* render p */

transformFor:列表渲染的编译期展开

和 transformIf 一样,transformFor 也是一个结构性指令 transformer——它把 v-for="(item, index) in list" 这样的声明式表达展开成 renderList(list, (item, index) => ...) 的函数调用。这是一个典型的"语法糖 → 运行时函数调用"转换模式,所有现代框架的列表渲染都这么处理。特别要注意下面代码里的 PatchFlags.KEYED_FRAGMENT / UNKEYED_FRAGMENT 标记——它决定了运行时 diff 走哪条路径(带 key 的用 LIS 算法、不带 key 的用简单顺序对比),是性能优化的一个关键开关。

这里值得再展开一句:为什么必须有 key?Vue 3 带 key 的 fragment 走的是第 11 章要详细讲的"最长递增子序列"算法——O(N log N) 复杂度就能找出最小 DOM 移动操作数。没有 key 的 fragment 只能用最简单的顺序比较——新旧列表按位置一一 patch,多出的挂载、缺少的卸载。这两种路径在列表重排时差别很大:把一万项列表的第一项挪到末尾,带 key 只需移动这一个 DOM 节点;不带 key 则每个位置的内容都变了,要逐项更新(量级示意,非实测)。更要紧的是正确性:Vue 官方文档指出,就地更新只适用于列表渲染结果不依赖子组件状态或临时 DOM 状态(如表单输入值)的情况——这才是 Vue 文档强调"v-for 要加 key"的主要原因,性能差距是另一面。

v-for 的转换与 v-if 类似,也是一个结构性转换:

// packages/compiler-core/src/transforms/vFor.ts:52(精简)
export const transformFor: NodeTransform = createStructuralDirectiveTransform(
  'for',
  (node, dir, context) => {
    const { helper, removeHelper } = context
    return processFor(node, dir, context, forNode => {
      // 先创建 renderList(source) 调用,迭代函数等子节点遍历完再补上
      const renderExp = createCallExpression(helper(RENDER_LIST), [
        forNode.source,
      ]) as ForRenderListExpression
      const keyProp = findProp(node, `key`, false, true)
      // ...

      // source 是常量(如字面量数组)时列表结构稳定
      const isStableFragment =
        forNode.source.type === NodeTypes.SIMPLE_EXPRESSION &&
        forNode.source.constType > ConstantTypes.NOT_CONSTANT
      const fragmentFlag = isStableFragment
        ? PatchFlags.STABLE_FRAGMENT
        : keyProp
          ? PatchFlags.KEYED_FRAGMENT
          : PatchFlags.UNKEYED_FRAGMENT

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

      return () => {
        // 退出函数:子节点已转换完毕,把每一项包成 Block、补上迭代函数
        // renderList(source, (item, key, index) => (openBlock(), createElementBlock(...)))
        let childBlock: BlockCodegenNode = /* 由 forNode.children 与 keyProp 组装 */
        renderExp.arguments.push(
          createFunctionExpression(
            createForLoopParams(forNode.parseResult),
            childBlock,
            true /* force newline */,
          ),
        )
      }
    })
  },
)

v-for="(item, index) in list" 这段表达式本身早在 Parse 阶段就被 parseForExpression() 拆成了 forParseResult(source / value / key / index),processFor 只是把它搬到 ForNode.parseResult 上、并在 prefixIdentifiers 模式下为迭代变量登记作用域标识符。

🔥 深度洞察

v-for 渲染的结果被包裹在一个 Fragment 中,这是因为 v-for 可能生成多个同级节点。PatchFlag 会标记为 UNKEYED_FRAGMENT(没有 :key)或 KEYED_FRAGMENT(有 :key),两者在运行时 Diff 时走完全不同的路径——后者使用"最长递增子序列"算法来最小化 DOM 操作。还有第三种情况:遍历源是编译期常量(比如 v-for="n in 10")时标记为 STABLE_FRAGMENT,此时列表长度不会变、Fragment 保留 Block 追踪(openBlock() 不传 true),运行时可以走 patchBlockChildren 快速路径。

transformElement:普通元素的代码生成准备

transformElement 是所有 transformers 里最"重"的一个——因为每个元素都要走它。它的职责也最多:判断元素是组件还是普通标签、收集并归类所有属性、决定 children 的表达方式、计算 PatchFlag、最终生成 codegenNode。读 transformElement 是理解 Vue 整个编译器设计的关键节点——理解了它,你就理解了 Vue 编译器"把模板转成 createVNode 调用"的具体机制。

它的退出函数负责为每个元素生成完整的 codegenNode——即这个元素最终应该生成什么样的 createVNode 调用。

// packages/compiler-core/src/transforms/transformElement.ts:73(大幅简化)
export const transformElement: NodeTransform = (node, context) => {
  // 所有工作都在退出时做——那时子表达式已经处理、合并完毕
  return function postTransformElement() {
    node = context.currentNode!
    // 只处理普通元素和组件(<slot> 与 <template> 另有 transform 负责)
    if (!(node.type === NodeTypes.ELEMENT &&
          (node.tagType === ElementTypes.ELEMENT || node.tagType === ElementTypes.COMPONENT))) {
      return
    }
    const { tag, props } = node
    const isComponent = node.tagType === ElementTypes.COMPONENT

    // 1. 确定 vnodeTag:组件解析为 _component_Foo / resolveDynamicComponent(...)
    let vnodeTag = isComponent
      ? resolveComponentType(node as ComponentNode, context)
      : `"${tag}"`
    const isDynamicComponent =
      isObject(vnodeTag) && vnodeTag.callee === RESOLVE_DYNAMIC_COMPONENT

    let vnodeProps, vnodeChildren, vnodeDynamicProps, vnodeDirectives
    let patchFlag: number = 0
    let dynamicPropNames: string[] | undefined

    // 动态组件、Teleport、Suspense、svg/foreignObject/math 必须是 Block
    let shouldUseBlock =
      isDynamicComponent || vnodeTag === TELEPORT || vnodeTag === SUSPENSE ||
      (!isComponent && (tag === 'svg' || tag === 'foreignObject' || tag === 'math'))

    // 2. 处理 props:PatchFlag 的主体在这里算出
    if (props.length > 0) {
      const propsBuildResult = buildProps(node, context, undefined, isComponent, isDynamicComponent)
      vnodeProps = propsBuildResult.props
      patchFlag = propsBuildResult.patchFlag
      dynamicPropNames = propsBuildResult.dynamicPropNames
      const directives = propsBuildResult.directives
      vnodeDirectives = directives && directives.length
        ? createArrayExpression(directives.map(dir => buildDirectiveArgs(dir, context)))
        : undefined
      if (propsBuildResult.shouldUseBlock) shouldUseBlock = true
    }

    // 3. 处理 children
    if (node.children.length > 0) {
      if (isComponent && vnodeTag !== TELEPORT && vnodeTag !== KEEP_ALIVE) {
        // 组件的 children 编译成插槽对象
        const { slots, hasDynamicSlots } = buildSlots(node, context)
        vnodeChildren = slots
        if (hasDynamicSlots) patchFlag |= PatchFlags.DYNAMIC_SLOTS
      } else if (node.children.length === 1 && vnodeTag !== TELEPORT) {
        const child = node.children[0]
        const type = child.type
        const hasDynamicTextChild =
          type === NodeTypes.INTERPOLATION || type === NodeTypes.COMPOUND_EXPRESSION
        // TEXT 标志在这里打上:唯一子节点是动态文本、且不是常量
        if (hasDynamicTextChild &&
            getConstantType(child, context) === ConstantTypes.NOT_CONSTANT) {
          patchFlag |= PatchFlags.TEXT
        }
        // 唯一子节点是文本 / 插值 / 表达式:直接作为 children,避免数组包装
        if (hasDynamicTextChild || type === NodeTypes.TEXT) {
          vnodeChildren = child
        } else {
          vnodeChildren = node.children
        }
      } else {
        vnodeChildren = node.children
      }
    }

    // 4. dynamicProps 转成字符串数组字面量 ["id", "title"]
    if (dynamicPropNames && dynamicPropNames.length) {
      vnodeDynamicProps = stringifyDynamicPropNames(dynamicPropNames)
    }

    // 5. 生成 codegenNode
    node.codegenNode = createVNodeCall(
      context,
      vnodeTag,
      vnodeProps,
      vnodeChildren,
      patchFlag === 0 ? undefined : patchFlag,
      vnodeDynamicProps,
      vnodeDirectives,
      !!shouldUseBlock,
      false /* disableTracking */,
      isComponent,
      node.loc,
    )
  }
}

buildProps:PatchFlags 的诞生地

PatchFlag 是 Vue 3 最具标志性的编译期优化——每个元素在编译时被打上一个数字标记,告诉运行时"这个元素只有这些部分可能会变"。比如一个 <div :class="cls">Hello</div> 元素会被标记为 CLASS 类型,运行时看到这个标记就只更新 class 属性、跳过其他所有检查(children 文本、style、其他属性)。这种"编译期预计算变化范围"的思想在 VDOM 模式下把 diff 的开销大幅压低——Vue 3 比 Vue 2 快,PatchFlag 是主要原因之一。本小节讲的就是这个魔法标记是怎么被计算出来的。

buildProps 是 PatchFlags(除 TEXT 之外)真正被计算的地方。它遍历元素的所有属性:静态属性直接变成对象字面量的一个 property;指令则交给对应的 directiveTransform(v-bind → transformBind、v-on → transformOn ……)翻译成 property,再由内部的 analyzePatchFlag 逐个分析:

// packages/compiler-core/src/transforms/transformElement.ts:417(精简:形参类型已略,
// 源码里签名折成多行,:434 才是函数体第一条语句)
export function buildProps(node, context, props = node.props, isComponent, isDynamicComponent, ssr = false) {
  let properties: ObjectExpression['properties'] = []
  const runtimeDirectives: DirectiveNode[] = []   // 需要 withDirectives 的自定义指令
  const dynamicPropNames: string[] = []

  // patchFlag 分析用的一组标志
  let patchFlag = 0
  let hasRef = false
  let hasClassBinding = false
  let hasStyleBinding = false
  let hasHydrationEventBinding = false
  let hasDynamicKeys = false
  let hasVnodeHook = false

  const analyzePatchFlag = ({ key, value }: Property) => {
    if (isStaticExp(key)) {
      const name = key.content
      const isEventHandler = isOn(name)
      if (isEventHandler && (!isComponent || isDynamicComponent) &&
          name.toLowerCase() !== 'onclick' &&          // click 在 hydration 里有专门快路径
          name !== 'onUpdate:modelValue' &&            // v-model 的处理器
          !isReservedProp(name)) {                     // onVnodeXXX 钩子
        hasHydrationEventBinding = true
      }
      if (isEventHandler && isReservedProp(name)) hasVnodeHook = true
      if (name === 'ref') hasRef = true

      // 值是 _cache[n] 缓存的处理器、或常量表达式:不算动态
      if (value.type === NodeTypes.JS_CACHE_EXPRESSION ||
          ((value.type === NodeTypes.SIMPLE_EXPRESSION ||
            value.type === NodeTypes.COMPOUND_EXPRESSION) &&
            getConstantType(value, context) > 0)) {
        return
      }
      if (name === 'class') hasClassBinding = true
      else if (name === 'style') hasStyleBinding = true
      else if (name !== 'ref' && name !== 'key' && !dynamicPropNames.includes(name)) {
        dynamicPropNames.push(name)
      }
      // 组件上的 class/style 也算普通动态 prop
      if (isComponent && (name === 'class' || name === 'style') && !dynamicPropNames.includes(name)) {
        dynamicPropNames.push(name)
      }
    } else {
      hasDynamicKeys = true                           // :[foo]="bar"
    }
  }

  for (let i = 0; i < props.length; i++) {
    const prop = props[i]
    if (prop.type === NodeTypes.ATTRIBUTE) {
      // 静态属性:直接进对象字面量(key/ref/is 等有特殊处理,此处省略)
      properties.push(createObjectProperty(
        createSimpleExpression(prop.name, true, prop.nameLoc),
        createSimpleExpression(prop.value ? prop.value.content : '', true, prop.value ? prop.value.loc : prop.loc),
      ))
    } else {
      // 指令:v-bind / v-on 无参数形式走 mergeProps;其余交给 directiveTransforms
      const directiveTransform = context.directiveTransforms[prop.name]
      if (directiveTransform) {
        const { props, needRuntime } = directiveTransform(prop, node, context)
        !ssr && props.forEach(analyzePatchFlag)
        properties.push(...props)
        if (needRuntime) runtimeDirectives.push(prop)
      } else if (!isBuiltInDirective(prop.name)) {
        runtimeDirectives.push(prop)                  // 自定义指令:运行时处理
      }
    }
  }

  // 汇总 patchFlag
  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
  }
  // 没有其他动态标志、但有 ref / vnode 钩子 / 运行时指令:仍需 patch
  const needsPatch =
    (patchFlag === 0 || patchFlag === PatchFlags.NEED_HYDRATION) &&
    (hasRef || hasVnodeHook || runtimeDirectives.length > 0)
  if (!shouldUseBlock && needsPatch) patchFlag |= PatchFlags.NEED_PATCH

  return { props: propsExpression, directives: runtimeDirectives, patchFlag, dynamicPropNames, shouldUseBlock, needsPatch, isBlockRequired }
}

TEXT 标志不在这里——它由 postTransformElement 在处理 children 时打上(唯一子节点是非常量的插值或复合表达式)。另外注意 analyzePatchFlag 对 JS_CACHE_EXPRESSION 的跳过:开启 cacheHandlers 后 @click="handleClick" 被编译成 _cache[n] || (_cache[n] = ...),这个 prop 就不再进入 dynamicPropNames,元素也不会因此带上 PROPS 标志。

PatchFlags 是一个位掩码(bitmask),多个标志可以叠加:

PatchFlag 值 含义
TEXT 1 动态文本内容
CLASS 2 动态 class 绑定
STYLE 4 动态 style 绑定
PROPS 8 动态非 class/style 属性
FULL_PROPS 16 有动态 key 的属性,需要完整 diff
NEED_HYDRATION 32 需要 hydration 事件监听器
STABLE_FRAGMENT 64 子节点顺序不会变的 Fragment
KEYED_FRAGMENT 128 带 key 的 Fragment(v-for)
UNKEYED_FRAGMENT 256 不带 key 的 Fragment(v-for)
NEED_PATCH 512 只需要非 props 的 patch(如 ref)
DYNAMIC_SLOTS 1024 动态插槽
DEV_ROOT_FRAGMENT 2048 开发模式下根级多节点
CACHED -1 被缓存的静态节点(3.5 前叫 HOISTED)
BAIL -2 退出优化模式

8.5 静态提升:cacheStatic

"静态提升"是 Vue 3 相对 Vue 2 的一项标志性优化——它体现的是"编译期识别不变、运行时不重复工作"的经典优化思想。任何一段在模板里永远不变的内容(纯静态节点、静态 props、纯文本)都只需要创建一次——之后每次渲染直接复用同一个 VNode 引用。3.0-3.4 的实现是把它们"提升"到 render 函数外部当模块级常量(hoistStatic.ts);3.5 起(PR #11067)改为按组件实例缓存到 _cache[n],文件也改名为 cacheStatic.ts——但编译选项仍叫 hoistStatic,"静态提升"这个叫法也沿用了下来。这种优化在 dashboard、CMS、电商详情页这类"大量静态骨架 + 少量动态点"的场景下收益巨大——一个页面里绝大多数 VNode 都能被缓存复用、每次 render 实际要造的只剩少数动态节点。

静态提升是 Transform 阶段的最后一个重大优化。其核心思想极其简单:如果一个 VNode 在任何重新渲染中都不会改变,就把它缓存进组件实例的 _cache,只创建一次。

// 未开启 hoistStatic 的渲染函数
function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("p", null, "静态文本"),                          // 每次渲染都创建
    _createElementVNode("span", null, _toDisplayString(_ctx.msg), 1 /* TEXT */) // 动态节点
  ]))
}

// 开启 hoistStatic 后(rc.4 实际产物)
function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", null, [
    _cache[0] || (_cache[0] = _createElementVNode("p", null, "静态文本", -1 /* CACHED */)), // 首次创建,之后复用
    _createElementVNode("span", null, _toDisplayString(_ctx.msg), 1 /* TEXT */) // 动态节点
  ]))
}

_cache 就是渲染函数的第二个参数——运行时为每个组件实例准备的 renderCache 数组,v-once、cacheHandlers 缓存的事件处理器和这里的静态节点共用同一个数组、按 context.cache() 分配的下标各占一格。

静态分析算法

cacheStatic 的 walk() 遍历 AST,用 getConstantType() 为每个节点计算"常量等级":

// packages/compiler-core/src/ast.ts:215
export enum ConstantTypes {
  NOT_CONSTANT = 0,    // 非常量(含动态绑定)
  CAN_SKIP_PATCH,      // 可以跳过 patch,但不能缓存(例如引用 <script setup> 里的 const 绑定)
  CAN_CACHE,           // 可以整棵缓存(3.5 前叫 CAN_HOIST)
  CAN_STRINGIFY,       // 可以序列化为字符串(连续多个静态节点合并)
}

getConstantType 是带缓存(context.constantCache)的递归判断,对元素节点的逻辑如下:

// packages/compiler-core/src/transforms/cacheStatic.ts:253(精简:形参类型已略,
// 源码里签名折成多行,:257 才是函数体第一条语句)
export function getConstantType(node, context): ConstantTypes {
  const { constantCache } = context
  switch (node.type) {
    case NodeTypes.ELEMENT:
      if (node.tagType !== ElementTypes.ELEMENT) {
        return ConstantTypes.NOT_CONSTANT   // 组件 / <slot> / <template> 不缓存
      }
      const cached = constantCache.get(node)
      if (cached !== undefined) return cached
      const codegenNode = node.codegenNode!
      if (codegenNode.type !== NodeTypes.VNODE_CALL) return ConstantTypes.NOT_CONSTANT
      // Block 元素不缓存(svg / foreignObject / math 例外,静态时会被降级为普通 VNode)
      if (codegenNode.isBlock && node.tag !== 'svg' && node.tag !== 'foreignObject' && node.tag !== 'math') {
        return ConstantTypes.NOT_CONSTANT
      }
      if (codegenNode.patchFlag === undefined) {
        let returnType = ConstantTypes.CAN_STRINGIFY
        // 1. 没有 patchFlag 不代表 props 全是常量:可能含编译器注入的 key、缓存的处理器
        const generatedPropsType = getGeneratedPropsConstantType(node, context)
        if (generatedPropsType === ConstantTypes.NOT_CONSTANT) {
          constantCache.set(node, ConstantTypes.NOT_CONSTANT)
          return ConstantTypes.NOT_CONSTANT
        }
        if (generatedPropsType < returnType) returnType = generatedPropsType
        // 2. 所有 children
        for (let i = 0; i < node.children.length; i++) {
          const childType = getConstantType(node.children[i], context)
          if (childType === ConstantTypes.NOT_CONSTANT) {
            constantCache.set(node, ConstantTypes.NOT_CONSTANT)
            return ConstantTypes.NOT_CONSTANT
          }
          if (childType < returnType) returnType = childType
        }
        // 3. v-bind 的表达式可能把等级拉低(例如 :class="staticClass" 引用作用域变量)
        if (returnType > ConstantTypes.CAN_SKIP_PATCH) {
          for (let i = 0; i < node.props.length; i++) {
            const p = node.props[i]
            if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind' && p.exp) {
              const expType = getConstantType(p.exp, context)
              if (expType === ConstantTypes.NOT_CONSTANT) {
                constantCache.set(node, ConstantTypes.NOT_CONSTANT)
                return ConstantTypes.NOT_CONSTANT
              }
              if (expType < returnType) returnType = expType
            }
          }
        }
        // ...静态的 svg 等 Block 元素在此被降级为普通 createElementVNode
        constantCache.set(node, returnType)
        return returnType
      } else {
        // 有 patchFlag 就一定有动态部分
        constantCache.set(node, ConstantTypes.NOT_CONSTANT)
        return ConstantTypes.NOT_CONSTANT
      }
    case NodeTypes.TEXT:
    case NodeTypes.COMMENT:
      return ConstantTypes.CAN_STRINGIFY
    case NodeTypes.IF:
    case NodeTypes.FOR:
    case NodeTypes.IF_BRANCH:
      return ConstantTypes.NOT_CONSTANT
    case NodeTypes.INTERPOLATION:
    case NodeTypes.TEXT_CALL:
      return getConstantType(node.content, context)
    case NodeTypes.SIMPLE_EXPRESSION:
      return node.constType       // 由 Parse / transformExpression 阶段标好
    case NodeTypes.COMPOUND_EXPRESSION:
      // 取所有子表达式的最低等级
      let returnType = ConstantTypes.CAN_STRINGIFY
      for (let i = 0; i < node.children.length; i++) {
        const child = node.children[i]
        if (isString(child) || isSymbol(child)) continue
        const childType = getConstantType(child, context)
        if (childType === ConstantTypes.NOT_CONSTANT) return ConstantTypes.NOT_CONSTANT
        else if (childType < returnType) returnType = childType
      }
      return returnType
    case NodeTypes.JS_CACHE_EXPRESSION:
      return ConstantTypes.CAN_CACHE        // 已缓存的表达式(如 _cache 里的事件处理器)
    default:
      return ConstantTypes.NOT_CONSTANT
  }
}

walk() 拿到等级后的处理分三档:>= CAN_CACHE 的普通元素和 TEXT_CALL 节点,把 patchFlag 设成 PatchFlags.CACHED、收进 toCache 列表,最后用 context.cache() 包成 _cache[n];若一个元素的所有子节点都被收进了 toCache,就改为把整个 children 数组作为一个整体缓存([...(_cache[n] || (_cache[n] = [...]))],展开是为了防止 mount 时对缓存数组的原地修改造成内存泄漏);等级为 NOT_CONSTANT 但 patchFlag 为空、NEED_PATCH 或 TEXT 的元素,只要它生成的 props 对象是常量,就把 props 对象用 context.hoist() 提升为模块级 _hoisted_n(不可缓存元素若带 dynamicProps 数组,该数组无论如何都会被提升)——这就是 8.6 节示例里 const _hoisted_1 = { class: "container" } 的来源。此外,v-if / v-for 的唯一子节点和根节点的唯一元素子节点会以 doNotHoistNode = true 跳过缓存判断——前者必须保持为 Block,后者要接收父组件的 fallthrough attrs。

字符串化(Stringify Static)

walk() 的最后一步是调用 context.transformHoist——compiler-dom 在非浏览器构建下把它设为 stringifyStatic。当一段连续的可缓存静态节点达到阈值时,它们会被合并为一个 createStaticVNode 调用:

// packages/compiler-dom/src/transforms/stringifyStatic.ts:41
export enum StringifyThresholds {
  ELEMENT_WITH_BINDING_COUNT = 5,   // 累计 5 个带属性的元素(props 非空即计,含静态 class),或
  NODE_COUNT = 20,                  // 累计 20 个节点
}

// 20 个静态 <li> 会被合并为:
_cache[0] || (_cache[0] = _createStaticVNode(
  "<li>item 1</li><li>item 2</li>...<li>item 20</li>",
  20   // 节点数,hydration 时用来跳过对应数量的 DOM 节点
))

这比 20 个独立的 createElementVNode 调用高效得多——只需一次 innerHTML 赋值就能创建所有 DOM 节点。表格相关标签(caption / thead / tr / td 等,innerHTML 会被浏览器改写结构)、v-once 节点、插槽内容,以及带有非 HTML 已知属性(如只能作为 DOM property 设置的 indeterminate)或非常量绑定的元素不参与字符串化。

8.6 Codegen 阶段:从 AST 到代码字符串

Codegen 是编译流水线的最后一站——它把 Transform 产出的增强 AST 变成最终的 JavaScript 代码字符串。这一步是"结构化 → 非结构化"的反方向转换——我们从 Parse 阶段结构化了模板、中间阶段加了各种优化信息、最后又把这一切序列化成 JS 代码。这种"结构化 → 优化 → 反序列化"是所有编译器的通用架构——LLVM、JavaScriptCore、V8 都是这个模式。

generate 函数的结构

// packages/compiler-core/src/codegen.ts:292(精简)
export function generate(
  ast: RootNode,
  options: CodegenOptions & {
    onContextCreated?: (context: CodegenContext) => void
  } = {},
): CodegenResult {
  const context = createCodegenContext(ast, options)
  const { mode, push, prefixIdentifiers, indent, deindent, newline, scopeId, ssr } = context

  const helpers = Array.from(ast.helpers)      // ast.helpers 是 Set<symbol>
  const hasHelpers = helpers.length > 0
  // 没开 prefixIdentifiers 的 function 模式才用 with (_ctx)
  const useWithBlock = !prefixIdentifiers && mode !== 'module'
  const isSetupInlined = !__BROWSER__ && !!options.inline

  // 前导代码:module 模式生成 import 语句,function 模式生成 const 解构
  // <script setup> inline 模式下前导代码生成到一个子 context、单独返回
  const preambleContext = isSetupInlined ? createCodegenContext(ast, options) : context
  if (!__BROWSER__ && mode === 'module') {
    genModulePreamble(ast, preambleContext, genScopeId, isSetupInlined)
  } else {
    genFunctionPreamble(ast, preambleContext)
  }

  // 函数签名:SSR 是 ssrRender(_ctx, _push, _parent, _attrs)
  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 = args.join(', ')

  if (isSetupInlined) {
    push(`(${signature}) => {`)       // inline 模式:箭头函数,直接放进 setup() 返回
  } else {
    push(`function ${functionName}(${signature}) {`)
  }
  indent()

  if (useWithBlock) {
    push(`with (_ctx) {`)
    indent()
    // function 模式下 helper 解构放在 with 内部,并重命名以免和用户属性冲突
    if (hasHelpers) {
      push(`const { ${helpers.map(aliasHelper).join(', ')} } = _Vue\n`, NewlineType.End)
      newline()
    }
  }

  // 声明组件和指令
  if (ast.components.length) {
    genAssets(ast.components, 'component', context)
  }
  if (ast.directives.length) {
    genAssets(ast.directives, 'directive', context)
  }

  // 声明临时变量
  if (ast.temps > 0) {
    push(`let `)
    for (let i = 0; i < ast.temps; i++) {
      push(`${i > 0 ? ', ' : ''}_temp${i}`)
    }
    newline()
  }

  // 生成 return 语句(SSR 的 ssrRender 靠 _push 输出,不 return)
  if (!ssr) push('return ')
  if (ast.codegenNode) {
    genNode(ast.codegenNode, context)
  } else {
    push('null')
  }

  // 关闭 with 块与函数
  if (useWithBlock) {
    deindent()
    push(`}`)
  }
  deindent()
  push(`}`)

  return {
    ast,
    code: context.code,
    preamble: isSetupInlined ? preambleContext.code : ``,
    map: context.map ? context.map.toJSON() : undefined,
    helpers: ast.helpers,
  }
}

CodegenContext:代码生成上下文

// packages/compiler-core/src/codegen.ts:131(精简)
interface CodegenContext {
  code: string          // 生成的代码字符串
  column: number        // 当前列号
  line: number          // 当前行号
  indentLevel: number   // 缩进层级
  map?: CodegenSourceMapGenerator  // Source Map 生成器(source-map-js)

  // 输出方法
  push(code: string, newlineIndex?: number, node?: CodegenNode): void
  indent(): void
  deindent(withoutNewLine?: boolean): void
  newline(): void
}

push 方法是代码生成的原子操作。每次调用 push 都会将代码片段追加到 context.code,同时更新行列号和 Source Map 映射。

genNode:节点到代码的映射

genNode 是一个大型的分发函数,根据 AST 节点类型调用对应的代码生成函数:

// packages/compiler-core/src/codegen.ts:660(精简:省略 SSR 专用节点类型与 dev 断言)
function genNode(node: CodegenNode | symbol | string, context: CodegenContext) {
  if (isString(node)) {
    context.push(node, NewlineType.Unknown)
    return
  }
  if (isSymbol(node)) {
    context.push(context.helper(node))
    return
  }

  switch (node.type) {
    case NodeTypes.ELEMENT:
    case NodeTypes.IF:
    case NodeTypes.FOR:
      genNode(node.codegenNode!, context)
      break
    case NodeTypes.TEXT:
      genText(node, context)
      break
    case NodeTypes.SIMPLE_EXPRESSION:
      genExpression(node, context)
      break
    case NodeTypes.INTERPOLATION:
      genInterpolation(node, context)
      break
    case NodeTypes.TEXT_CALL:
      genNode(node.codegenNode, context)
      break
    case NodeTypes.COMPOUND_EXPRESSION:
      genCompoundExpression(node, context)
      break
    case NodeTypes.COMMENT:
      genComment(node, context)
      break
    case NodeTypes.VNODE_CALL:
      genVNodeCall(node, context)
      break
    case NodeTypes.JS_CALL_EXPRESSION:
      genCallExpression(node, context)
      break
    case NodeTypes.JS_OBJECT_EXPRESSION:
      genObjectExpression(node, context)
      break
    case NodeTypes.JS_ARRAY_EXPRESSION:
      genArrayExpression(node, context)
      break
    case NodeTypes.JS_FUNCTION_EXPRESSION:
      genFunctionExpression(node, context)
      break
    case NodeTypes.JS_CONDITIONAL_EXPRESSION:
      genConditionalExpression(node, context)
      break
    case NodeTypes.JS_CACHE_EXPRESSION:
      genCacheExpression(node, context)
      break
    case NodeTypes.JS_BLOCK_STATEMENT:
      genNodeList(node.body, context, true, false)
      break
  }
}

genVNodeCall:最关键的生成函数

大多数元素最终都会通过 genVNodeCall 生成代码:

// packages/compiler-core/src/codegen.ts:827(精简:源码先把 patchFlag 拼成带 /* TEXT */ 注释的字符串,并处理 PURE 标注)
function genVNodeCall(node: VNodeCall, context: CodegenContext) {
  const {
    tag, props, children, patchFlag, dynamicProps,
    directives, isBlock, disableTracking, isComponent
  } = node
  const { push, helper } = context

  if (directives) {
    push(helper(WITH_DIRECTIVES) + '(')
  }
  if (isBlock) {
    push(`(${helper(OPEN_BLOCK)}(${disableTracking ? 'true' : ''}), `)
  }

  const callHelper = isBlock
    ? getVNodeBlockHelper(context.inSSR, isComponent)
    : getVNodeHelper(context.inSSR, isComponent)

  push(helper(callHelper) + '(')

  // 生成参数列表(跳过尾部的 null 参数)
  genNodeList(
    genNullableArgs([tag, props, children, patchFlag, dynamicProps]),
    context
  )

  push(')')

  if (isBlock) {
    push(')')
  }
  if (directives) {
    push(', ')
    genNode(directives, context)
    push(')')
  }
}

一个完整的编译示例

让我们追踪一个模板从输入到输出的完整旅程:

输入模板:

<div class="container">
  <h1>{{ title }}</h1>
  <p v-if="showDesc">{{ description }}</p>
  <ul>
    <li v-for="item in items" :key="item.id">{{ item.name }}</li>
  </ul>
</div>

编译输出(简化):

import {
  createElementVNode as _createElementVNode,
  toDisplayString as _toDisplayString,
  openBlock as _openBlock,
  createElementBlock as _createElementBlock,
  createCommentVNode as _createCommentVNode,
  renderList as _renderList,
  Fragment as _Fragment
} from "vue"

const _hoisted_1 = { class: "container" }

export function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _createElementVNode("h1", null,
      _toDisplayString(_ctx.title),
      1 /* TEXT */
    ),
    (_ctx.showDesc)
      ? (_openBlock(), _createElementBlock("p", { key: 0 },
          _toDisplayString(_ctx.description),
          1 /* TEXT */
        ))
      : _createCommentVNode("v-if", true),
    _createElementVNode("ul", null, [
      (_openBlock(true), _createElementBlock(_Fragment, null,
        _renderList(_ctx.items, (item) => {
          return (_openBlock(), _createElementBlock("li", {
            key: item.id
          },
            _toDisplayString(item.name),
            1 /* TEXT */
          ))
        }),
        128 /* KEYED_FRAGMENT */
      ))
    ])
  ]))
}

让我们逐行分析编译器的决策:

  1. _hoisted_1 = { class: "container" }:class="container" 是静态属性,被提升到函数外部
  2. 根 <div> 使用 createElementBlock:它是 Block 根节点,参与 Block Tree 优化
  3. <h1> 使用 createElementVNode:它不是 Block 节点,但有动态文本,PatchFlag = 1(TEXT)
  4. v-if 生成三元表达式:条件为真时创建 <p> Block,条件为假时创建注释节点作为占位符
  5. v-for 使用 renderList:遍历 items,每个 <li> 都是独立的 Block(外层 Fragment 用 openBlock(true) 关掉了动态子节点收集,每一项只能自成 Block 来收集自己的动态节点;见 compiler-core/src/transforms/vFor.ts:119、190)
  6. v-for 的 Fragment PatchFlag = 128:KEYED_FRAGMENT,告诉运行时使用基于 key 的 Diff 算法

8.7 Source Map:连接模板与生成代码

Source Map 是开发者体验里最容易被低估的一项技术——没有它,编译器生成的代码在浏览器里报错时你只能看到 "render function anonymous:42" 这种毫无意义的位置信息;有了它,你能直接看到原始模板的行号列号。Vue 编译器生成 Source Map 的过程有相当复杂性——一个模板元素在生成代码里会展开成 _openBlock()、_createElementBlock(...)、props 对象、子节点数组等好几段,而真正带映射的只是 push 时传入了 AST 节点的那几段(例如 _createElementBlock( 这个调用和 _ctx.foo 这样的表达式)。决定哪些片段带映射、并在每次 push 时同步维护生成代码的行列号,是 Source Map 生成要解决的主要问题。

为什么需要 Source Map

当用户在浏览器 DevTools 中看到一个渲染错误,错误堆栈指向的是编译后的渲染函数——render 函数的第 15 行。但用户写的是模板,他需要知道这对应模板的哪一行。Source Map 就是这座桥梁。

生成原理

Vue 编译器使用 source-map-js 库(source-map 的纯 JS 分支)来生成 Source Map。关键在于每次 push 代码时,如果提供了关联的 AST 节点,就会记录一条映射:

// packages/compiler-core/src/codegen.ts:191(createCodegenContext 内,简化)
push(code, newlineIndex = NewlineType.None, node) {
  context.code += code

  if (!__BROWSER__ && context.map) {
    if (node && node.loc.source) {
      // 记录映射:模板源码位置 → 当前生成位置(context.line / column)
      addMapping(node.loc.start, name)   // name:_ctx.foo 这类标识符的原名,计算略
    }
    // 更新行列号:含多个换行时全量扫描,否则按 newlineIndex 走快速路径
    if (newlineIndex === NewlineType.Unknown) {
      advancePositionWithMutation(context, code)
    } else {
      // ...直接累加 column,或在已知位置换行时 line++
    }
    if (node && node.loc !== locStub && node.loc.source) {
      addMapping(node.loc.end)
    }
  }
}

每个 AST 节点都携带 loc 信息(在 Parse 阶段生成),包含源码中的起始行号、列号和结束行号、列号。当这个节点的生成代码被 push 时,就建立了"生成代码第 X 行第 Y 列 → 模板第 A 行第 B 列"的映射。

8.8 编译器的错误处理

编译器的错误处理是一门容易被忽视的功夫——但它决定了用户的调试体验。想象一下两种编译器:差的编译器收到语法错误时抛出 "Unexpected token"——用户一脸懵逼、不知道哪行错了;好的编译器会说 "在 Foo.vue 第 15 行第 23 列,<div 之后期望属性或 >,但看到了 <——你是不是忘了闭合前一个标签?"。Vue 编译器走的是后者路线——每个错误都有明确的位置、明确的问题描述、甚至有修复建议。这种"对用户友好"的错误处理是一个框架"成熟度"的重要指标。

错误分类与容错

Vue 编译器定义了大量的错误码,覆盖了从语法错误到语义错误的各种情况:

// packages/compiler-core/src/errors.ts:40
export enum ErrorCodes {
  // Parse 阶段错误(与 HTML 规范的解析错误同名)
  ABRUPT_CLOSING_OF_EMPTY_COMMENT,
  CDATA_IN_HTML_CONTENT,
  DUPLICATE_ATTRIBUTE,
  END_TAG_WITH_ATTRIBUTES,
  END_TAG_WITH_TRAILING_SOLIDUS,
  EOF_BEFORE_TAG_NAME,
  EOF_IN_CDATA,
  EOF_IN_COMMENT,
  EOF_IN_SCRIPT_HTML_COMMENT_LIKE_TEXT,
  EOF_IN_TAG,
  INCORRECTLY_CLOSED_COMMENT,
  INCORRECTLY_OPENED_COMMENT,
  INVALID_FIRST_CHARACTER_OF_TAG_NAME,
  MISSING_ATTRIBUTE_VALUE,
  MISSING_END_TAG_NAME,
  MISSING_WHITESPACE_BETWEEN_ATTRIBUTES,
  NESTED_COMMENT,
  UNEXPECTED_CHARACTER_IN_ATTRIBUTE_NAME,
  UNEXPECTED_CHARACTER_IN_UNQUOTED_ATTRIBUTE_VALUE,
  UNEXPECTED_EQUALS_SIGN_BEFORE_ATTRIBUTE_NAME,
  UNEXPECTED_NULL_CHARACTER,
  UNEXPECTED_QUESTION_MARK_INSTEAD_OF_TAG_NAME,
  UNEXPECTED_SOLIDUS_IN_TAG,

  // Vue 专属的解析错误
  X_INVALID_END_TAG,
  X_MISSING_END_TAG,
  X_MISSING_INTERPOLATION_END,
  X_MISSING_DIRECTIVE_NAME,
  X_MISSING_DYNAMIC_DIRECTIVE_ARGUMENT_END,

  // Transform 阶段错误
  X_V_IF_NO_EXPRESSION,
  X_V_IF_SAME_KEY,
  X_V_ELSE_NO_ADJACENT_IF,
  X_V_FOR_NO_EXPRESSION,
  X_V_FOR_MALFORMED_EXPRESSION,
  X_V_FOR_TEMPLATE_KEY_PLACEMENT,
  X_V_BIND_NO_EXPRESSION,
  X_V_ON_NO_EXPRESSION,
  X_V_SLOT_UNEXPECTED_DIRECTIVE_ON_SLOT_OUTLET,
  X_V_SLOT_MIXED_SLOT_USAGE,
  X_V_SLOT_DUPLICATE_SLOT_NAMES,
  X_V_SLOT_EXTRANEOUS_DEFAULT_SLOT_CHILDREN,
  X_V_SLOT_MISPLACED,
  X_V_MODEL_NO_EXPRESSION,
  X_V_MODEL_MALFORMED_EXPRESSION,
  X_V_MODEL_ON_SCOPE_VARIABLE,
  X_V_MODEL_ON_PROPS,
  X_V_MODEL_ON_CONST,
  X_INVALID_EXPRESSION,
  X_KEEP_ALIVE_INVALID_CHILDREN,

  // 通用错误:浏览器构建不支持的选项
  X_PREFIX_ID_NOT_SUPPORTED,
  X_MODULE_MODE_NOT_SUPPORTED,
  X_CACHE_HANDLER_NOT_SUPPORTED,
  X_SCOPE_ID_NOT_SUPPORTED,
  X_VNODE_HOOKS,
  // ...
}

错误是否中断编译由 onError 选项决定:默认的 defaultOnError 直接 throw;而 compiler-sfc 的 compileTemplate 传入的是 onError: e => errors.push(e),此时编译器记录错误后继续解析、照样产出 AST。这样用户可以一次性看到所有错误,而不是修一个才暴露下一个。

错误信息的精确定位

// packages/compiler-core/src/parser.ts:1012
function emitError(code: ErrorCodes, index: number, message?: string) {
  currentOptions.onError(
    createCompilerError(code, getLoc(index, index), undefined, message),
  )
}

Tokenizer 自己不知道 onError 选项——它只通过 Callbacks.onerr(code, index) 报告错误码和字符下标,parser.ts 把 emitError 直接注册为这个回调。index 经 getLoc() → tokenizer.getPos() 换算成 { offset, line, column },所以每个编译错误都包含精确的位置信息。配合编辑器的 language server(如 Volar),这些错误可以直接在模板中以红色波浪线的形式呈现,并提供有意义的错误描述。

8.9 编译器扩展:自定义指令的编译

Vue 内置的 v-if / v-for / v-model 在编译期被展开成命令式代码——但用户自定义的指令(v-click-outside、v-debounce 等)不能这么做,因为编译器不知道它们的语义。Vue 的解决方案是:把自定义指令统一编译成 withDirectives() 调用——运行时根据指令注册表找到对应的钩子函数。这种"内置指令 = 编译期展开 / 用户指令 = 运行时查表"的分层设计让编译器保持简单(只处理已知语法糖)、同时给用户足够的扩展空间(任何新行为都能通过自定义指令接入)。

directiveTransforms 扩展点

开发者可以通过 directiveTransforms 为自定义指令提供编译期处理:

// 自定义指令的编译器插件
const myDirectiveTransform: DirectiveTransform = (dir, node, context) => {
  // dir.exp — 指令表达式
  // dir.arg — 指令参数
  // dir.modifiers — 修饰符

  // 返回值告诉编译器如何处理这个指令
  return {
    props: [
      createObjectProperty(
        createSimpleExpression('onMyDirective', true),
        dir.exp || createSimpleExpression('true', false)
      )
    ],
    needRuntime: false  // true 表示运行时也需要指令钩子
  }
}

// 使用
compile(template, {
  directiveTransforms: {
    'my-directive': myDirectiveTransform
  }
})

如果 needRuntime 为 true,生成的代码会包含 withDirectives 包装,在运行时调用指令的生命周期钩子。如果为 false,则指令在编译期就被完全消解——这是性能最优的方式。

nodeTransforms 扩展点

nodeTransforms 提供了更底层的扩展能力,可以在 AST 的任何节点上执行自定义逻辑:

const myNodeTransform: NodeTransform = (node, context) => {
  if (node.type === NodeTypes.ELEMENT && node.tag === 'my-component') {
    // 在进入阶段修改 AST
    // ...

    // 返回退出函数
    return () => {
      // 在退出阶段生成 codegenNode
      // ...
    }
  }
}

8.10 编译器与运行时的协作协议

编译器和运行时之间存在一套隐式协议——编译器生成的代码会调用运行时的特定 API,运行时实现这些 API 时必须遵循编译器的假设。这种协议是所有编译器-运行时分离架构(Vue、React JSX、Svelte 等)的基石。本节把这些协议一一列出来——理解它们你会知道为什么 Vue 团队修改某个运行时 API 的签名要非常慎重(会破坏所有已编译的代码)、也会理解为什么 Vapor 需要新增一个运行时包(新的编译输出需要新的运行时 API)。

flowchart LR
    subgraph Compiler["编译器(编译时)"]
        A["PatchFlags"]
        B["Block Tree"]
        C["Static Hoisting"]
        D["Cache Handlers"]
    end
    subgraph Runtime["运行时"]
        E["patch()\nDiff 引擎"]
        F["openBlock()\ncreateBlock()"]
        G["VNode 复用"]
        H["事件处理器缓存"]
    end
    A -->|"位掩码标记"| E
    B -->|"dynamicChildren 数组"| F
    C -->|"提升的 VNode 引用"| G
    D -->|"_cache[n] 缓存函数"| H
    style Compiler fill:#fff3e0,stroke:#ef6c00
    style Runtime fill:#e3f2fd,stroke:#1565c0

PatchFlags 协议

编译器在 createVNode 调用中传入数字类型的 PatchFlag,运行时的 patchElement 根据这个标记决定比较策略:

// 运行时 patch 逻辑(简化)
function patchElement(n1: VNode, n2: VNode) {
  const { patchFlag, dynamicChildren } = n2

  if (patchFlag > 0) {
    if (patchFlag & PatchFlags.FULL_PROPS) {
      // 完整比对所有 props
      patchProps(el, n1.props, n2.props)
    } else {
      if (patchFlag & PatchFlags.CLASS) {
        // 只更新 class
        if (n1.props.class !== n2.props.class) {
          hostPatchProp(el, 'class', null, n2.props.class)
        }
      }
      if (patchFlag & PatchFlags.STYLE) {
        // 只更新 style
        hostPatchProp(el, 'style', n1.props.style, n2.props.style)
      }
      if (patchFlag & PatchFlags.PROPS) {
        // 只更新列出的动态属性
        const propsToUpdate = n2.dynamicProps!
        for (let i = 0; i < propsToUpdate.length; i++) {
          const key = propsToUpdate[i]
          const prev = n1.props[key]
          const next = n2.props[key]
          if (next !== prev) {
            hostPatchProp(el, key, prev, next)
          }
        }
      }
    }
    if (patchFlag & PatchFlags.TEXT) {
      // 只更新文本内容
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string)
      }
    }
  }

  // Block 优化:直接 patch dynamicChildren
  if (dynamicChildren) {
    patchBlockChildren(n1.dynamicChildren!, dynamicChildren)
  }
}

Block 协议

编译器在 Block 节点上使用 openBlock() + createBlock()。运行时中,openBlock() 创建一个数组来收集动态子节点,createBlock() 将收集到的动态子节点保存到 VNode 的 dynamicChildren 字段。在 patch 时,如果 VNode 有 dynamicChildren,就只 patch 这些动态节点,跳过静态子树。

// 运行时(简化;真实实现见 runtime-core/src/vnode.ts 的 openBlock / setupBlock)
let currentBlock: VNode[] | null = null

function openBlock(disableTracking = false) {
  blockStack.push(currentBlock = disableTracking ? null : [])
}

function createBlock(type, props, children, patchFlag): VNode {
  const vnode = createVNode(type, props, children, patchFlag)
  vnode.dynamicChildren = currentBlock  // 保存动态子节点列表
  blockStack.pop()
  currentBlock = blockStack[blockStack.length - 1] || null
  if (currentBlock) {
    currentBlock.push(vnode)  // 将自身注册到父 Block
  }
  return vnode
}

8.11 本章小结

本章完成了对 Vue 模板编译器的一次完整走读——从入口到 Parse、Transform、Codegen、Source Map、错误处理、扩展点。你现在掌握的不只是 Vue 编译器的具体实现、还是"现代模板编译器"的通用骨架。React JSX、Svelte、SolidJS、Angular 的编译器架构和 Vue 大同小异——你读懂了本章内容、再看其他框架的编译器会有"原来如此"的熟悉感。

总结一下本章的几个关键 take-home:

  1. 编译器流水线是"阶段性细化"的过程——每一阶段都把数据从一种形式变到另一种更接近目标的形式。Parse:字符串 → AST;Transform:AST → 增强 AST;Codegen:增强 AST → 代码字符串。每一步的职责清晰、边界清晰。
  2. 插件化是框架扩展的基础——nodeTransforms、directiveTransforms 让 Vue 编译器既保持核心稳定又能灵活扩展。想加新指令?不用改 Vue 核心代码,写一个 transformer 插件即可。
  3. 编译期优化比运行时优化成本更低——静态提升、PatchFlag、Block Tree 都是"构建时多花一点、运行时省一大截"的经典操作。
  4. 编译器-运行时协议是隐形的但至关重要——它是 Vue 保持 API 稳定、同时能重写内核的底层支撑。

延伸阅读

  • Vue 3 源码 packages/compiler-core/src/:本章讨论的所有代码的原始位置。rc.4 里 compiler-core 约 1.1 万行 TypeScript(实测 11271 行;packages/compiler-core/src/tokenizer.ts 1197 行、packages/compiler-core/src/parser.ts 1079 行、packages/compiler-core/src/codegen.ts 1128 行、packages/compiler-core/src/transforms/transformElement.ts 998 行、packages/compiler-core/src/transform.ts 538 行),值得配合本章精读一遍。
  • Crafting Interpreters(Robert Nystrom):免费在线书、讲如何从零实现一个完整编译器/解释器。读完你对 Vue 编译器的每一部分都会有更深理解。
  • Babel 官方文档 Plugins 章节:Babel 是 JavaScript 生态里最成熟的编译器——它的 plugin API 设计和 Vue 的 transformer 设计高度相似、对比阅读有启发。
  • ESTree 规范:JavaScript 的标准 AST 结构。Vue 的模板 AST 不是 ESTree,Transform 产出的 JS_* 代码生成节点也是 Vue 自定义的精简结构、由 Codegen 直接拼成字符串;与 ESTree 相关的是 Parse 阶段交给 @babel/parser 解析出的表达式 AST(exp.ast)。
  • Rich Harris Write less code(2019):Svelte 作者对"编译器派"前端框架的理论基础讨论,是理解所有现代模板编译器的好材料。

思考题

  1. Vue 3.4 把手写递归下降的 parse.ts 换成了移植自 htmlparser2 的字符级状态机 tokenizer.ts + 回调建树的 parser.ts。两种写法在性能、可读性、位置信息计算、错误恢复上各有什么取舍?为什么状态机模型下 tagType(组件 / 插槽 / 片段模板)要等到元素闭合时才能确定?

  2. Transform 阶段的"进入-退出"模式借鉴了编译器领域的什么经典设计?如果取消退出函数机制,改为单次遍历,会遇到什么问题?

  3. PatchFlags 使用位掩码(bitmask)而非数组来表示标记类型,这种设计的优势和局限分别是什么?在什么情况下需要用 BAIL(-2)强制退出优化模式?

  4. 静态提升的"字符串化"优化(将多个静态节点合并为 createStaticVNode)在什么场景下可能反而降低性能?提示:考虑 SSR hydration 的场景。

  5. 假设你要为 Vue 编译器编写一个自定义的 nodeTransform,用于在编译期自动为所有 <img> 标签添加 loading="lazy" 属性——你会如何实现?是在进入阶段还是退出阶段处理?为什么?