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
}

注意第二步中的 ancestors 参数——它是一个栈,记录了从根节点到当前节点的路径。当 parseChildren 遇到一个结束标签时,它会向上查找 ancestors 栈,确认这个结束标签确实与某个祖先的开始标签匹配。如果不匹配,就报错。这就是 Vue 能给出精确的"标签未闭合"错误提示的原因。

parseTag:解析标签名和属性

typescript
function parseTag(
  context: ParserContext,
  type: TagType
): ElementNode {
  // 匹配标签名: <div 或 </div
  const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source)!
  const tag = match[1]

  advanceBy(context, match[0].length)  // 消费 "<div" 或 "</div"
  advanceSpaces(context)               // 跳过空白

  // 解析属性列表
  const props = parseAttributes(context, type)

  // 检查自闭合
  let isSelfClosing = false
  if (context.source.length === 0) {
    emitError(context, ErrorCodes.EOF_IN_TAG)
  } else {
    isSelfClosing = startsWith(context.source, '/>')
    advanceBy(context, isSelfClosing ? 2 : 1)  // 消费 "/>" 或 ">"
  }

  // 确定标签类型
  let tagType = ElementTypes.ELEMENT
  if (tag === 'slot') {
    tagType = ElementTypes.SLOT
  } else if (tag === 'template') {
    if (props.some(p => p.type === NodeTypes.DIRECTIVE &&
                        isSpecialTemplateDirective(p.name))) {
      tagType = ElementTypes.TEMPLATE
    }
  } else if (isComponent(tag, props, context)) {
    tagType = ElementTypes.COMPONENT
  }

  return {
    type: NodeTypes.ELEMENT,
    tag,
    tagType,
    props,
    isSelfClosing,
    children: [],
    loc: getSelection(context, start),
    codegenNode: undefined
  }
}

parseAttribute:属性与指令的分流

属性解析是模板解析中最复杂的部分之一,因为 Vue 的属性有多种形态:

语法类型示例
普通属性ATTRIBUTEclass="foo"
v- 指令DIRECTIVEv-if="show"
: 缩写DIRECTIVE (bind):title="msg"
@ 缩写DIRECTIVE (on)@click="handler"
# 缩写DIRECTIVE (slot)#default="{ item }"
. 修饰符DIRECTIVE (bind + prop).textContent="text"
typescript
function parseAttribute(
  context: ParserContext,
  nameSet: Set<string>
): AttributeNode | DirectiveNode {
  // 解析属性名
  const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source)!
  const name = match[0]

  // 重复属性检测
  if (nameSet.has(name)) {
    emitError(context, ErrorCodes.DUPLICATE_ATTRIBUTE)
  }
  nameSet.add(name)

  // 解析 = 和属性值
  let value: AttributeValue = undefined
  if (/^[\t\r\n\f ]*=/.test(context.source)) {
    advanceSpaces(context)
    advanceBy(context, 1)  // 消费 "="
    advanceSpaces(context)
    value = parseAttributeValue(context)
  }

  // 判断是否为指令
  if (/^(v-[A-Za-z0-9-]|:|\.|@|#)/.test(name)) {
    // 这是一个指令
    const dirMatch = /^(?:v-([a-z0-9-]+))?(?:(?::|^\.|^@|^#)(\[[^\]]+\]|[^\.]+))?(.+)?$/i
      .exec(name)!

    let dirName = dirMatch[1] ||
      (startsWith(name, ':') || startsWith(name, '.') ? 'bind'
        : startsWith(name, '@') ? 'on'
          : 'slot')

    let arg: ExpressionNode | undefined
    if (dirMatch[2]) {
      const isSlot = dirName === 'slot'
      const startOffset = name.lastIndexOf(dirMatch[2])
      arg = {
        type: NodeTypes.SIMPLE_EXPRESSION,
        content: dirMatch[2],
        isStatic: !(dirMatch[2].startsWith('['))  // [expr] 为动态参数
      }
    }

    // 解析修饰符: .stop.prevent
    const modifiers = dirMatch[3]
      ? dirMatch[3].slice(1).split('.').filter(Boolean)
      : []

    return {
      type: NodeTypes.DIRECTIVE,
      name: dirName,
      exp: value && { type: NodeTypes.SIMPLE_EXPRESSION, content: value.content, isStatic: false },
      arg,
      modifiers,
      loc: getSelection(context, start)
    }
  }

  // 普通属性
  return {
    type: NodeTypes.ATTRIBUTE,
    name,
    value: value && { type: NodeTypes.TEXT, content: value.content },
    loc: getSelection(context, start)
  }
}

🔥 深度洞察

Vue 模板中 v-bind 的三种写法(v-bind:title:title.title)在 Parse 阶段就被统一为 DirectiveNodename 字段都是 'bind'。但 .title 会自动添加 prop 修饰符。这种"语法糖在 Parse 阶段脱糖"的策略让后续的 Transform 和 Codegen 只需处理规范化后的 AST,极大简化了下游逻辑。

parseInterpolation:解析插值表达式

typescript
function parseInterpolation(
  context: ParserContext,
  mode: TextModes
): InterpolationNode | undefined {
  const [open, close] = context.options.delimiters  // 默认 ['{ {', '} }']

  const closeIndex = context.source.indexOf(close, open.length)
  if (closeIndex === -1) {
    emitError(context, ErrorCodes.X_MISSING_INTERPOLATION_END)
    return undefined
  }

  advanceBy(context, open.length)  // 消费 "{{"

  const rawContentLength = closeIndex - open.length
  const rawContent = context.source.slice(0, rawContentLength)
  const preTrimContent = parseTextData(context, rawContentLength, mode)
  const content = preTrimContent.trim()

  advanceBy(context, close.length)  // 消费 "}}"

  return {
    type: NodeTypes.INTERPOLATION,
    content: {
      type: NodeTypes.SIMPLE_EXPRESSION,
      isStatic: false,
      content,
      loc: getSelection(context, innerStart, innerEnd)
    },
    loc: getSelection(context, start)
  }
}

插值解析相对简单:找到 {{}} 的位置,提取中间的表达式字符串。注意表达式内容会被 trim(),所以 {{ msg }}{{msg}} 的解析结果完全一致。

TextModes:解析模式的影响

不是所有的 HTML 元素内部都按同样的规则解析。Vue 定义了四种文本模式:

typescript
enum TextModes {
  DATA,         // 普通元素内部:解析标签、插值、实体
  RCDATA,       // <textarea>/<title> 内部:解析插值和实体,但不解析标签
  RAWTEXT,      // <style>/<script> 内部:一切都是纯文本
  CDATA,        // <![CDATA[...]]> 内部:纯文本
  ATTRIBUTE_VALUE // 属性值内部:解析实体
}

当解析进入一个 <textarea> 时,模式切换为 RCDATA。此时即使遇到 <div>,也不会被当作标签解析——因为 <textarea> 的内容规范不允许嵌套标签。这与浏览器的行为保持一致。

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

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

Transform 的核心架构

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

typescript
// packages/compiler-core/src/transform.ts
export function transform(root: RootNode, options: TransformOptions) {
  const context = createTransformContext(root, options)

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

  // 静态提升
  if (options.hoistStatic) {
    hoistStatic(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
}

基于 VitePress 构建