Skip to content

第 8 章 模板编译深度剖析

本章要点

  • Parse 阶段的完整实现:手写递归下降解析器如何将模板字符串逐字符消费为 AST
  • 词法分析的状态机模型:标签开启、属性读取、插值解析的状态流转
  • Transform 阶段的插件化架构:nodeTransforms 与 directiveTransforms 的协作机制
  • 核心转换插件剖析:v-if、v-for、v-model 等指令的编译期展开
  • PatchFlags 与 Block 的注入时机:从 AST 节点到 codegenNode 的关键跃迁
  • Codegen 阶段的代码拼接策略:如何生成可读且高效的渲染函数
  • Source Map 生成:从模板行列号到生成代码行列号的映射链路

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

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

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

8.1 编译器入口:baseCompile 与 compile

两个 compile 函数

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

typescript
// packages/compiler-dom/src/index.ts
export function compile(
  template: string,
  options?: CompilerOptions
): CodegenResult {
  return baseCompile(
    template,
    extend({}, parserOptions, options, {
      nodeTransforms: [
        ...DOMNodeTransforms,
        ...(options?.nodeTransforms || [])
      ],
      directiveTransforms: extend(
        {},
        DOMDirectiveTransforms,
        options?.directiveTransforms || {}
      )
    })
  )
}

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

baseCompile 的三步曲

typescript
// packages/compiler-core/src/compile.ts
export function baseCompile(
  source: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {
  // 第一步:Parse
  const ast = isString(source) ? baseParse(source, options) : source

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

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

  // 第三步:Codegen
  return generate(ast, extend({}, options, { prefixIdentifiers }))
}

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

8.2 Parse 阶段:从字符串到 AST

解析器的整体结构

Vue 的模板解析器是一个手写的递归下降解析器(Recursive Descent Parser)。它没有使用 lex/yacc 之类的解析器生成工具,也没有使用正则表达式来做词法分析——所有的解析逻辑都是通过逐字符扫描和条件分支实现的。

为什么手写?三个理由:

  1. 性能:手写解析器可以避免正则引擎的回溯开销,在大型模板上比基于正则的方案快 2-3 倍
  2. 错误恢复:手写解析器可以在遇到语法错误时精确定位错误位置,给出有意义的提示("缺少闭合标签 </div>,对应第 12 行的 <div>"),而不是抛出一个含糊的"Unexpected token"
  3. 控制力:Vue 的模板语法有很多"非标准 HTML"的扩展(v-if@click{{ }}),手写解析器可以在任何位置插入自定义逻辑

解析上下文:ParserContext

解析器的所有状态都封装在一个 ParserContext 对象中:

typescript
// packages/compiler-core/src/parse.ts(简化)
interface ParserContext {
  source: string           // 剩余未解析的模板字符串
  originalSource: string   // 原始完整模板
  offset: number           // 当前偏移量(字符数)
  line: number             // 当前行号
  column: number           // 当前列号
  options: ParserOptions   // 解析选项
  inPre: boolean           // 是否在 <pre> 标签内
  inVPre: boolean          // 是否在 v-pre 指令范围内
}

解析过程的核心思想是消费(consume):每解析出一个 token(标签、属性、文本、插值),就从 source 的头部"吃掉"对应的字符,同时更新行号和列号。当 source 被完全消费时,解析完成。

parseChildren:递归下降的核心

parseChildren() 是整个解析器的心脏。它在一个 while 循环中不断检查 source 的当前字符,根据字符类型决定调用哪个子解析函数:

typescript
// packages/compiler-core/src/parse.ts(简化)
function parseChildren(
  context: ParserContext,
  mode: TextModes,
  ancestors: ElementNode[]
): TemplateChildNode[] {
  const nodes: TemplateChildNode[] = []

  while (!isEnd(context, mode, ancestors)) {
    const s = context.source
    let node: TemplateChildNode | undefined

    if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
      if (mode === TextModes.DATA && s[0] === '<') {
        if (s[1] === '!') {
          // 注释: <!-- ... -->
          if (startsWith(s, '<!--')) {
            node = parseComment(context)
          } else if (startsWith(s, '<!DOCTYPE')) {
            // DOCTYPE: 作为注释处理
            node = parseBogusComment(context)
          }
        } else if (s[1] === '/') {
          // 结束标签: </div>
          // 不生成节点,由父级处理
          break
        } else if (/[a-z]/i.test(s[1])) {
          // 开始标签: <div>
          node = parseElement(context, ancestors)
        }
      } else if (startsWith(s, context.options.delimiters[0])) {
        // 插值: { { msg } }
        node = parseInterpolation(context, mode)
      }
    }

    // 兜底:纯文本
    if (!node) {
      node = parseText(context, mode)
    }

    if (isArray(node)) {
      for (let i = 0; i < node.length; i++) {
        pushNode(nodes, node[i])
      }
    } else {
      pushNode(nodes, node)
    }
  }

  return nodes
}

🔥 深度洞察

parseChildren 的分支逻辑揭示了 Vue 模板的语法优先级:< 开头的先尝试解析为标签或注释;{{ 开头的解析为插值;其他一律视为纯文本。这个优先级决定了模板中的歧义如何被解析——比如文本中出现的 < 字符,如果后面跟的不是字母或 /,就会被当作普通文本处理。

parseElement:解析元素的三步走

一个 HTML 元素的解析分为三步:

typescript
function parseElement(
  context: ParserContext,
  ancestors: ElementNode[]
): ElementNode {
  // 第一步:解析开始标签 <div class="foo">
  const element = parseTag(context, TagType.Start)

  // 自闭合标签(如 <br/>)不需要后续步骤
  if (element.isSelfClosing || context.options.isVoidTag?.(element.tag)) {
    return element
  }

  // 第二步:递归解析子节点
  ancestors.push(element)
  const mode = context.options.getTextMode?.(element, parent) ?? TextModes.DATA
  const children = parseChildren(context, mode, ancestors)
  ancestors.pop()
  element.children = children

  // 第三步:解析结束标签 </div>
  if (startsWithEndTagOpen(context.source, element.tag)) {
    parseTag(context, TagType.End)
  } else {
    emitError(context, ErrorCodes.X_MISSING_END_TAG, 0, element.loc.start)
  }

  // 更新位置信息
  element.loc = getSelection(context, element.loc.start)
  return element
}

基于 VitePress 构建