Skip to content

实现编译器 - Parse 模板解析

本节对标 Vue 3 源码 @vue/compiler-core 中的 parse.ts 源码位置:packages/compiler-core/src/parse.ts

编译器的三段式架构

Vue 3 的编译器采用经典的三段式(Three-Phase)架构,将模板编译拆分为三个独立阶段:

Template String


  ┌───────┐
  │ Parse │ ── 模板字符串 → AST(抽象语法树)
  └───┬───┘


  ┌───────────┐
  │ Transform │ ── AST → 转换后的 AST(语义分析 + 优化)
  └─────┬─────┘


  ┌─────────┐
  │ Codegen │ ── 转换后的 AST → render 函数代码字符串
  └─────────┘

这种分层设计的好处:

  • 职责清晰:每个阶段只关注自己的任务
  • 可测试:每个阶段都可以独立测试
  • 可扩展:transform 阶段支持插件化,可以自由添加转换逻辑
  • 可复用:parse 生成的 AST 可以被多种后端消费(SSR codegen / Client codegen)
ts
// packages/compiler-core/src/compile.ts
// 编译器的核心入口函数,串联三个阶段完成模板编译
export function baseCompile(template: string, options = {}) {
  // 第一步:解析模板字符串,将其转换为 AST(抽象语法树)
  const ast = baseParse(template)

  // 第二步:对 AST 进行转换(语义分析 + 优化)
  // nodeTransforms 是一组插件函数,分别处理不同类型的节点
  transform(ast, {
    ...options, // 合并用户传入的选项
    nodeTransforms: [
      transformElement,     // 处理元素节点,生成 VNode 调用
      transformText,        // 处理文本节点,合并相邻文本和插值
      transformExpression,  // 处理表达式节点,添加 _ctx 前缀
    ],
  })

  // 第三步:根据转换后的 AST 生成 render 函数的代码字符串
  return generate(ast)
}

有限状态机思想

Parse 阶段的核心思想是有限状态机(Finite State Machine,FSM)。解析器在不同状态间切换,根据当前字符决定进入哪个解析分支:

初始状态

   ├── 遇到 '<'  → 进入"标签解析"状态
   │     ├── '</' → 解析结束标签
   │     ├── '<!' → 解析注释
   │     └── '<x' → 解析开始标签

   ├── 遇到 '{{' → 进入"插值解析"状态

   └── 其他字符  → 进入"文本解析"状态

在我们的 mini-vue 中,不需要实现完整的状态机,而是通过**递归下降(Recursive Descent)**的方式来解析——每个解析函数对应一种"状态",函数之间的调用关系体现了状态转换。

ts
// 递归下降解析子节点的核心函数
// context: 解析上下文,包含剩余待解析的模板字符串
// ancestors: 祖先元素栈,用于判断何时停止解析
function parseChildren(context, ancestors) {
  const nodes = [] // 收集所有解析出的子节点

  // 循环解析,直到遇到结束条件(模板消费完或遇到祖先的闭合标签)
  while (!isEnd(context, ancestors)) {
    let node
    const s = context.source // 获取当前剩余的模板字符串

    if (s.startsWith('{{')) {
      // 状态切换:遇到 {{ 进入插值表达式解析
      node = parseInterpolation(context)
    } else if (s[0] === '<') {
      if (/[a-z]/i.test(s[1])) {
        // 状态切换:遇到 <字母 进入元素解析
        node = parseElement(context, ancestors)
      }
    }

    if (!node) {
      // 默认状态:以上条件都不满足,当作纯文本解析
      node = parseText(context)
    }

    nodes.push(node) // 将解析出的节点加入结果数组
  }

  return nodes
}

AST 节点类型

AST(Abstract Syntax Tree,抽象语法树)是模板的结构化表示。Vue 3 定义了多种节点类型:

NodeTypes 枚举

ts
// 对标 packages/compiler-core/src/ast.ts
// 定义 AST 节点类型的枚举,每种类型对应模板中的一种语法结构
export const enum NodeTypes {
  ROOT,              // 根节点,AST 树的顶层容器
  ELEMENT,           // 元素节点,如 <div>、<p> 等 HTML 标签
  TEXT,              // 纯文本节点,如 "hello"
  INTERPOLATION,     // 插值节点,如 {{ msg }},表示动态绑定的表达式
  SIMPLE_EXPRESSION, // 简单表达式,插值内部的表达式内容,如 msg
  COMPOUND_EXPRESSION, // 复合表达式,由文本和插值混合组成的节点
  COMMENT,           // 注释节点,如 <!-- comment -->
  VNODE_CALL,        // VNode 调用节点,transform 阶段生成,用于 codegen
}

各节点类型的 AST 结构

ts
// Element 节点:表示 HTML 元素
interface ElementNode {
  type: NodeTypes.ELEMENT      // 节点类型标识
  tag: string                // 标签名,如 'div'、'span'
  props: AttributeNode[]     // 属性列表,包含所有 HTML 属性
  children: TemplateChildNode[] // 子节点数组,包含嵌套的元素、文本等
  isSelfClosing: boolean     // 是否是自闭合标签,如 <br />、<img />
}

// Text 节点:表示纯文本内容
interface TextNode {
  type: NodeTypes.TEXT         // 节点类型标识
  content: string            // 文本内容字符串
}

// Interpolation 节点:表示 {{ }} 插值表达式
interface InterpolationNode {
  type: NodeTypes.INTERPOLATION  // 节点类型标识
  content: ExpressionNode    // 内部的表达式节点,描述插值的具体内容
}

// SimpleExpression 节点:表示简单的 JS 表达式
interface SimpleExpressionNode {
  type: NodeTypes.SIMPLE_EXPRESSION  // 节点类型标识
  content: string            // 表达式字符串,如 'msg'、'count + 1'
}

// Comment 节点:表示 HTML 注释
interface CommentNode {
  type: NodeTypes.COMMENT      // 节点类型标识
  content: string            // 注释文本内容(不含 <!-- 和 -->)
}

AST 示例

模板:

html
<div id="app">
  <p>hello {{ name }}</p>
</div>

生成的 AST:

json
{
  "type": "ROOT",
  "children": [
    {
      "type": "ELEMENT",
      "tag": "div",
      "props": [{ "name": "id", "value": "app" }],
      "children": [
        {
          "type": "ELEMENT",
          "tag": "p",
          "props": [],
          "children": [
            { "type": "TEXT", "content": "hello " },
            {
              "type": "INTERPOLATION",
              "content": {
                "type": "SIMPLE_EXPRESSION",
                "content": "name"
              }
            }
          ]
        }
      ]
    }
  ]
}

上下文对象 ParserContext

解析器需要跟踪当前解析位置等信息,这些信息封装在上下文对象中:

ts
// 对标 packages/compiler-core/src/parse.ts - createParserContext
// 解析上下文接口:封装解析过程中需要跟踪的状态信息
interface ParserContext {
  source: string    // 剩余未解析的模板字符串,随着解析不断缩短
  offset: number    // 当前偏移量(从模板开头算起的字符数),用于错误定位
  line: number      // 当前行号,用于编译错误提示
  column: number    // 当前列号,用于编译错误提示
}

// 创建解析上下文的工厂函数
function createParserContext(content: string): ParserContext {
  return {
    source: content,  // 初始时 source 为完整的模板字符串
    offset: 0,        // 偏移量从 0 开始
    line: 1,          // 行号从 1 开始(符合文本编辑器习惯)
    column: 1,        // 列号从 1 开始
  }
}

随着解析的推进,source 会不断缩短(已解析的部分被截掉),而 offset/line/column 不断前进。这些位置信息对编译时错误提示至关重要。

advanceBy —— 推进解析位置

ts
// 对标 packages/compiler-core/src/parse.ts - advanceBy
// 推进解析位置:将已解析的字符从 source 中截掉,并更新位置信息
function advanceBy(context: ParserContext, numberOfCharacters: number): void {
  const { source } = context
  // 先更新行列位置信息(需要在截断 source 之前,因为要扫描被截掉的字符)
  advancePositionWithMutation(context, source, numberOfCharacters)
  // 截掉已解析的部分,保留剩余未解析的字符串
  context.source = source.slice(numberOfCharacters)
}

// 更新位置信息(行号、列号、偏移量)
// 通过扫描被消费的字符中的换行符来计算新的行列位置
function advancePositionWithMutation(
  pos: ParserContext,
  source: string,
  numberOfCharacters: number,
): void {
  let linesCount = 0       // 记录经过了多少个换行符
  let lastNewLinePos = -1  // 记录最后一个换行符的位置

  // 遍历被消费的每个字符,统计换行符数量
  for (let i = 0; i < numberOfCharacters; i++) {
    if (source.charCodeAt(i) === 10 /* newline 换行符的 ASCII 码 */) {
      linesCount++
      lastNewLinePos = i
    }
  }

  pos.offset += numberOfCharacters  // 偏移量直接累加字符数
  pos.line += linesCount            // 行号累加换行符数量
  // 列号计算:如果没有遇到换行符,直接累加;否则从最后一个换行符之后算起
  pos.column =
    lastNewLinePos === -1
      ? pos.column + numberOfCharacters        // 没有换行,列号直接累加
      : numberOfCharacters - lastNewLinePos    // 有换行,列号 = 最后换行符到结尾的距离
}

advanceBy 是解析器最核心的辅助函数。每次成功解析一段内容后,调用 advanceBy 将已解析的字符从 source 中移除,并更新行列信息。

ts
// 辅助函数:跳过空白字符(空格、制表符、换行符等)
// 用于在解析标签名、属性之间跳过无意义的空白
function advanceSpaces(context: ParserContext): void {
  // 匹配开头的连续空白字符
  const match = /^[\t\r\n\f ]+/.exec(context.source)
  if (match) {
    advanceBy(context, match[0].length) // 消费掉所有空白字符
  }
}

isEnd —— 判断结束条件

ts
// 对标 packages/compiler-core/src/parse.ts - isEnd
// 判断 parseChildren 循环是否应该结束
function isEnd(context: ParserContext, ancestors: ElementNode[]): boolean {
  const s = context.source

  // 情况 1:模板字符串已经全部消费完毕,没有更多内容需要解析
  if (!s) {
    return true
  }

  // 情况 2:遇到了祖先栈中某个元素的结束标签
  // 从栈顶(最近的祖先)向栈底遍历,优先匹配最近的父元素
  if (s.startsWith('</')) {
    for (let i = ancestors.length - 1; i >= 0; i--) {
      if (startsWithEndTagOpen(s, ancestors[i].tag)) {
        return true // 找到匹配的结束标签,停止当前层级的解析
      }
    }
  }

  return false
}

// 判断 source 是否以指定标签的结束标签开头
// 例如:startsWithEndTagOpen('</div>', 'div') === true
function startsWithEndTagOpen(source: string, tag: string): boolean {
  return (
    source.startsWith('</') && // 以 </ 开头
    source.slice(2, 2 + tag.length).toLowerCase() === tag.toLowerCase() && // 标签名匹配(忽略大小写)
    /[\t\r\n\f />]/.test(source[2 + tag.length] || '>') // 标签名后面必须是空白、/ 或 >,防止部分匹配
  )
}

ancestors(祖先栈)的作用至关重要:

html
<div>
  <p>hello</p>    ← 解析 <p> 的子节点时,ancestors = [div, p]
</div>              当遇到 </p> 时,isEnd 返回 true,停止解析 <p> 的子节点

这种设计还能检测标签未正确闭合的情况——如果遇到的结束标签匹配的是更外层的祖先元素,说明当前元素缺少闭合标签。

baseParse —— 解析入口

ts
// 对标 packages/compiler-core/src/parse.ts - baseParse
// 解析器的入口函数:接收模板字符串,返回完整的 AST
export function baseParse(content: string) {
  const context = createParserContext(content) // 创建解析上下文
  // 解析所有子节点,并用 createRoot 包装为根节点
  return createRoot(parseChildren(context, []))
}

// 创建 AST 根节点,作为所有子节点的容器
function createRoot(children) {
  return {
    type: NodeTypes.ROOT,
    children, // 模板中所有顶层节点
  }
}

parseChildren —— 子节点解析入口

ts
// 对标 packages/compiler-core/src/parse.ts - parseChildren
// 解析子节点的核心循环,根据当前字符决定进入哪个解析分支
function parseChildren(
  context: ParserContext,
  ancestors: ElementNode[], // 祖先元素栈,用于结束条件判断
) {
  const nodes: any[] = [] // 收集解析出的所有子节点

  // 持续解析直到遇到结束条件
  while (!isEnd(context, ancestors)) {
    let node: any
    const s = context.source // 获取剩余待解析字符串

    if (s.startsWith('{{')) {
      // 遇到 {{ 开头,解析插值表达式
      node = parseInterpolation(context)
    } else if (s[0] === '<') {
      if (s[1] === '!') {
        if (s.startsWith('<!--')) {
          // 遇到 <!-- 开头,解析 HTML 注释
          node = parseComment(context)
        }
      } else if (s[1] === '/') {
        // 结束标签 — 不应该在这里遇到,说明有多余的结束标签
        // 在完整实现中需要报错
      } else if (/[a-z]/i.test(s[1])) {
        // 遇到 <字母 的模式,解析元素标签
        node = parseElement(context, ancestors)
      }
    }

    if (!node) {
      // 以上分支都未匹配,作为纯文本解析(兜底逻辑)
      node = parseText(context)
    }

    nodes.push(node) // 将节点加入结果数组
  }

  return nodes
}

parseElement —— 解析元素节点

元素解析分为四步:解析开始标签 → 解析子节点 → 解析结束标签 → 返回元素节点。

ts
// 对标 packages/compiler-core/src/parse.ts - parseElement
// 解析完整的元素节点:开始标签 → 子节点 → 结束标签
function parseElement(
  context: ParserContext,
  ancestors: ElementNode[],
) {
  // 第一步:解析开始标签,得到元素节点(包含标签名、属性等信息)
  const element = parseTag(context, TagType.Start)

  // 自闭合标签(如 <br />)不包含子节点和结束标签,直接返回
  if (element.isSelfClosing) {
    return element
  }

  // 第二步:递归解析子节点
  // 将当前元素压入祖先栈,让子节点解析时能正确判断结束条件
  ancestors.push(element)
  const children = parseChildren(context, ancestors)
  ancestors.pop() // 子节点解析完毕,将当前元素从祖先栈弹出

  element.children = children // 将解析出的子节点挂载到元素上

  // 第三步:解析结束标签(如 </div>)
  if (startsWithEndTagOpen(context.source, element.tag)) {
    parseTag(context, TagType.End) // 消费结束标签
  } else {
    // 缺少结束标签,输出错误提示
    console.error(`缺少结束标签: </${element.tag}>`)
  }

  return element
}

// 标签类型枚举:区分开始标签和结束标签的解析行为
const enum TagType {
  Start, // 开始标签 <div>
  End,   // 结束标签 </div>
}

注意 ancestors.push(element)ancestors.pop() 的配对使用——这形成了一个"祖先栈",让 isEnd 能够正确判断何时停止解析子节点。

parseTag —— 解析标签名和属性

ts
// 对标 packages/compiler-core/src/parse.ts - parseTag
// 解析标签的开始部分或结束部分(标签名 + 属性 + 闭合符号)
function parseTag(context: ParserContext, type: TagType) {
  // 正则匹配标签名:< 或 </ 后面跟随的标签名(如 div、span)
  const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source)!
  const tag = match[1] // 提取捕获组中的标签名

  advanceBy(context, match[0].length) // 消费 <tag 或 </tag 部分
  advanceSpaces(context) // 跳过标签名后面的空白字符

  // 解析标签上的所有属性(如 id="app" class="container")
  const props = parseAttributes(context)

  // 检查是否是自闭合标签(以 /> 结尾)
  const isSelfClosing = context.source.startsWith('/>')
  advanceBy(context, isSelfClosing ? 2 : 1) // 消费 /> (2个字符) 或 > (1个字符)

  // 如果是结束标签(</div>),不需要返回节点,因为它只是用来闭合的
  if (type === TagType.End) {
    return undefined
  }

  // 返回元素节点的 AST 结构
  return {
    type: NodeTypes.ELEMENT,
    tag,                    // 标签名
    props,                  // 属性数组
    children: [],           // 子节点稍后由 parseElement 填充
    isSelfClosing,          // 是否自闭合
  }
}

正则 /^<\/?([a-z][^\t\r\n\f />]*)/i 的含义:

  • ^<\/? — 匹配 <</
  • ([a-z][^\t\r\n\f />]*) — 捕获标签名:以字母开头,后接非空白、非 /、非 > 的字符

parseAttributes —— 解析属性列表

ts
// 对标 packages/compiler-core/src/parse.ts - parseAttributes
// 解析标签上的所有属性,循环直到遇到 > 或 />
function parseAttributes(context: ParserContext) {
  const props: any[] = []

  // 循环解析属性,直到遇到标签的闭合符号
  while (
    context.source.length > 0 &&
    !context.source.startsWith('>') &&   // 普通闭合 >
    !context.source.startsWith('/>') // 自闭合 />
  ) {
    const attr = parseAttribute(context) // 解析单个属性
    props.push(attr)
    advanceSpaces(context) // 跳过属性之间的空白
  }

  return props
}

// 解析单个属性,如 id="app"
function parseAttribute(context: ParserContext) {
  // 匹配属性名:以非空白、非特殊字符开头的字符序列
  const nameMatch = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source)!
  const name = nameMatch[0]
  advanceBy(context, name.length) // 消费属性名

  // 尝试解析 = 号和属性值
  let value: string | undefined
  if (/^[\t\r\n\f ]*=/.test(context.source)) {
    advanceSpaces(context)    // 跳过 = 前的空白
    advanceBy(context, 1)     // 消费 = 号
    advanceSpaces(context)    // 跳过 = 后的空白
    value = parseAttributeValue(context) // 解析属性值
  }

  return {
    type: 'ATTRIBUTE',
    name,   // 属性名,如 'id'
    value,  // 属性值,如 'app'
  }
}

// 解析属性值,支持带引号和不带引号两种形式
function parseAttributeValue(context: ParserContext) {
  let content: string
  const quote = context.source[0] // 获取第一个字符,判断是否为引号
  const isQuoted = quote === '"' || quote === "'"

  if (isQuoted) {
    // 带引号的属性值,如 "app" 或 'app'
    advanceBy(context, 1) // 消费开始引号
    const endIndex = context.source.indexOf(quote) // 查找配对的结束引号
    if (endIndex === -1) {
      // 未找到结束引号,将剩余内容全部作为属性值(容错处理)
      content = context.source
      advanceBy(context, context.source.length)
    } else {
      content = context.source.slice(0, endIndex) // 提取引号之间的内容
      advanceBy(context, endIndex)
      advanceBy(context, 1) // 消费结束引号
    }
  } else {
    // 无引号的属性值,遇到空白或 > 停止
    const match = /^[^\t\r\n\f >]+/.exec(context.source)!
    content = match[0]
    advanceBy(context, content.length)
  }

  return content
}

parseInterpolation —— 解析插值表达式

ts
// 对标 packages/compiler-core/src/parse.ts - parseInterpolation
// 解析插值表达式 {{ xxx }},返回 Interpolation AST 节点
function parseInterpolation(context: ParserContext) {
  const openDelimiter = '{{'   // 插值的开始定界符
  const closeDelimiter = '}}'  // 插值的结束定界符

  // 从 {{ 之后开始查找 }} 的位置
  const closeIndex = context.source.indexOf(
    closeDelimiter,
    openDelimiter.length, // 跳过 {{ 本身,从第3个字符开始搜索
  )

  // 未找到 }},说明插值没有正确闭合
  if (closeIndex === -1) {
    console.error('插值表达式缺少结束的 }}')
    return undefined
  }

  // 消费开始定界符 {{
  advanceBy(context, openDelimiter.length)

  // 计算 {{ 和 }} 之间内容的长度
  const rawContentLength = closeIndex - openDelimiter.length
  // 提取原始内容(可能包含首尾空格)
  const rawContent = context.source.slice(0, rawContentLength)
  // 去除首尾空格得到最终的表达式内容
  const content = rawContent.trim()

  // 消费表达式内容部分
  advanceBy(context, rawContentLength)
  // 消费结束定界符 }}
  advanceBy(context, closeDelimiter.length)

  // 返回插值节点,内部嵌套一个简单表达式节点
  return {
    type: NodeTypes.INTERPOLATION,
    content: {
      type: NodeTypes.SIMPLE_EXPRESSION,
      content, // 表达式字符串,如 'message'
    },
  }
}

解析过程示意:

{{ message }}
^^              advanceBy(2)  消费 {{
   message_    rawContent(含空格)→ trim → "message"
             ^^  advanceBy(2)  消费 }}

parseText —— 解析纯文本

ts
// 对标 packages/compiler-core/src/parse.ts - parseText
// 解析纯文本节点:提取从当前位置到下一个特殊标记之间的文本
function parseText(context: ParserContext) {
  // 定义文本的结束标记:遇到 < (标签开始)或 {{ (插值开始)就停止
  const endTokens = ['<', '{{']
  let endIndex = context.source.length // 默认取整个剩余字符串

  // 查找最近的结束标记位置
  for (let i = 0; i < endTokens.length; i++) {
    const index = context.source.indexOf(endTokens[i])
    // 如果找到了结束标记,且比当前记录的位置更靠前,则更新 endIndex
    if (index !== -1 && endIndex > index) {
      endIndex = index
    }
  }

  // 截取文本内容
  const content = context.source.slice(0, endIndex)
  // 消费已提取的文本
  advanceBy(context, content.length)

  return {
    type: NodeTypes.TEXT,
    content, // 纯文本字符串
  }
}

parseText 的关键点:文本在遇到 <(标签开始)或 {{(插值开始)时结束。这保证了文本不会"吞掉"后面的标签或插值。

parseComment —— 解析注释

ts
// 解析 HTML 注释节点 <!-- ... -->
function parseComment(context: ParserContext) {
  advanceBy(context, '<!--'.length) // 消费注释的开始标记 <!--

  const closeIndex = context.source.indexOf('-->') // 查找注释的结束标记 -->
  const content = context.source.slice(0, closeIndex) // 提取注释内容

  advanceBy(context, content.length)    // 消费注释内容
  advanceBy(context, '-->'.length)      // 消费注释的结束标记 -->

  return {
    type: NodeTypes.COMMENT,
    content, // 注释文本(不含 <!-- 和 -->)
  }
}

完整实现

将以上所有函数整合:

ts
// AST 节点类型枚举
export const enum NodeTypes {
  ROOT,                // 根节点
  ELEMENT,             // 元素节点
  TEXT,                // 文本节点
  INTERPOLATION,       // 插值节点
  SIMPLE_EXPRESSION,   // 简单表达式
  COMPOUND_EXPRESSION, // 复合表达式
  COMMENT,             // 注释节点
  VNODE_CALL,          // VNode 调用节点
}

// 解析入口:将模板字符串解析为 AST
export function baseParse(content: string) {
  const context = createParserContext(content) // 创建解析上下文
  return createRoot(parseChildren(context, [])) // 解析子节点并包装为根节点
}

// 创建解析上下文,初始化源字符串和位置信息
function createParserContext(content: string) {
  return {
    source: content, // 剩余未解析的模板字符串
    offset: 0,
    line: 1,
    column: 1,
  }
}

// 创建 AST 根节点
function createRoot(children: any[]) {
  return {
    type: NodeTypes.ROOT,
    children, // 所有顶层子节点
  }
}

// 解析子节点列表:循环解析直到遇到结束条件
function parseChildren(context: any, ancestors: any[]) {
  const nodes: any[] = []

  while (!isEnd(context, ancestors)) {
    let node: any
    const s = context.source

    if (s.startsWith('{{')) {
      // 遇到 {{ 开头,解析插值表达式
      node = parseInterpolation(context)
    } else if (s[0] === '<') {
      if (s.startsWith('<!--')) {
        // 遇到 <!-- 开头,解析注释
        node = parseComment(context)
      } else if (/[a-z]/i.test(s[1])) {
        // 遇到 <字母,解析元素标签
        node = parseElement(context, ancestors)
      }
    }

    if (!node) {
      // 默认作为纯文本解析
      node = parseText(context)
    }

    nodes.push(node)
  }

  return nodes
}

// 解析元素节点:开始标签 → 递归解析子节点 → 结束标签
function parseElement(context: any, ancestors: any[]) {
  const element: any = parseTag(context) // 解析开始标签

  if (element.isSelfClosing) return element // 自闭合标签直接返回

  // 将当前元素压入祖先栈,用于子节点解析时的结束条件判断
  ancestors.push(element)
  element.children = parseChildren(context, ancestors) // 递归解析子节点
  ancestors.pop() // 子节点解析完毕,弹出祖先栈

  // 消费结束标签(如 </div>)
  if (context.source.startsWith(`</${element.tag}`)) {
    parseTag(context, true) // 以结束标签模式解析
  }

  return element
}

// 解析标签(开始标签或结束标签)
// isEnd 为 true 时表示解析的是结束标签
function parseTag(context: any, isEnd = false) {
  // 正则匹配 <tag 或 </tag 中的标签名
  const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source)!
  const tag = match[1] // 提取标签名
  advanceBy(context, match[0].length) // 消费 <tag 或 </tag
  advanceSpaces(context) // 跳过空白

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

  // 判断是否是自闭合标签 />
  const isSelfClosing = context.source.startsWith('/>')
  advanceBy(context, isSelfClosing ? 2 : 1) // 消费 /> 或 >

  if (isEnd) return undefined // 结束标签不需要返回节点

  return {
    type: NodeTypes.ELEMENT,
    tag,
    props,
    children: [] as any[], // 子节点稍后填充
    isSelfClosing,
  }
}

// 解析标签上的所有属性
function parseAttributes(context: any) {
  const props: any[] = []
  // 循环解析,直到遇到 > 或 />
  while (
    context.source.length > 0 &&
    !context.source.startsWith('>') &&
    !context.source.startsWith('/>')
  ) {
    // 匹配并消费属性名
    const name = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source)![0]
    advanceBy(context, name.length)
    advanceSpaces(context)

    let value: string | undefined
    if (context.source[0] === '=') {
      advanceBy(context, 1)       // 消费 =
      advanceSpaces(context)
      const quote = context.source[0] // 获取引号类型(" 或 ')
      advanceBy(context, 1)       // 消费开始引号
      const end = context.source.indexOf(quote) // 查找结束引号
      value = context.source.slice(0, end) // 提取属性值
      advanceBy(context, end + 1) // 消费属性值和结束引号
    }

    advanceSpaces(context) // 跳过属性之间的空白
    props.push({ name, value })
  }
  return props
}

// 解析插值表达式 {{ xxx }}
function parseInterpolation(context: any) {
  advanceBy(context, '{{'.length) // 消费 {{
  const closeIndex = context.source.indexOf('}}') // 查找 }}
  const content = context.source.slice(0, closeIndex).trim() // 提取并去除空白
  advanceBy(context, closeIndex + '}}'.length) // 消费内容和 }}
  return {
    type: NodeTypes.INTERPOLATION,
    content: { type: NodeTypes.SIMPLE_EXPRESSION, content },
  }
}

// 解析纯文本:从当前位置到下一个 < 或 {{ 之间的文本
function parseText(context: any) {
  const endTokens = ['<', '{{'] // 文本的结束标记
  let endIndex = context.source.length // 默认取到末尾
  // 查找最近的结束标记
  for (const token of endTokens) {
    const i = context.source.indexOf(token)
    if (i !== -1 && i < endIndex) endIndex = i // 取最小的位置
  }
  const content = context.source.slice(0, endIndex) // 截取文本
  advanceBy(context, endIndex) // 消费文本
  return { type: NodeTypes.TEXT, content }
}

// 解析 HTML 注释 <!-- ... -->
function parseComment(context: any) {
  advanceBy(context, '<!--'.length) // 消费 <!--
  const end = context.source.indexOf('-->') // 查找 -->
  const content = context.source.slice(0, end) // 提取注释内容
  advanceBy(context, end + '-->'.length) // 消费注释内容和 -->
  return { type: NodeTypes.COMMENT, content }
}

// 判断子节点解析是否应该结束
function isEnd(context: any, ancestors: any[]) {
  if (!context.source) return true // 模板已消费完
  // 检查是否遇到了祖先元素的结束标签
  for (let i = ancestors.length - 1; i >= 0; i--) {
    if (context.source.startsWith(`</${ancestors[i].tag}`)) {
      return true // 遇到祖先的结束标签,停止解析
    }
  }
  return false
}

// 推进解析位置:截掉已消费的字符
function advanceBy(context: any, numberOfCharacters: number) {
  context.source = context.source.slice(numberOfCharacters)
}

// 跳过空白字符(空格、制表符、换行符等)
function advanceSpaces(context: any) {
  const match = /^[\t\r\n\f ]+/.exec(context.source)
  if (match) advanceBy(context, match[0].length)
}

对比 React

维度Vue 3 CompilerReact JSX
编译时机构建时 / 运行时构建时(Babel / SWC)
输入HTML-like 模板JSX(JavaScript 扩展语法)
解析器自研递归下降解析器Babel parser(acorn 扩展)
输出render 函数字符串React.createElement() / jsx() 调用
AST 类型自定义 AST(Element/Text/Interpolation)Babel AST(JSXElement/JSXText/JSXExpression)
优化空间编译时可做静态分析优化运行时优化为主(React Compiler 是新尝试)
表达式仅支持 {{ }} 内的 JS 表达式完整 JS 表达式(JSX 本身就是 JS)

Vue 模板的限制(只能在 {{ }} 中写表达式)反而成为了优势——编译器可以在编译时对模板进行深度静态分析,提前标记动态/静态节点,从而在运行时跳过不必要的 diff。

测试用例

ts
import { baseParse, NodeTypes } from '../src/parse'

describe('parse', () => {
  // 测试纯文本解析
  describe('text', () => {
    it('should parse simple text', () => {
      // 解析纯文本字符串,验证生成的 AST 节点类型和内容
      const ast = baseParse('some text')
      const text = ast.children[0] // 获取第一个子节点

      // 断言:文本节点的类型为 TEXT,内容为 'some text'
      expect(text).toStrictEqual({
        type: NodeTypes.TEXT,
        content: 'some text',
      })
    })
  })

  // 测试插值表达式解析
  describe('interpolation', () => {
    it('should parse simple interpolation', () => {
      // 解析包含插值的模板,验证 {{ message }} 被正确解析
      const ast = baseParse('{{ message }}')
      const interpolation = ast.children[0]

      // 断言:插值节点类型为 INTERPOLATION,内部表达式为 'message'(已去除空白)
      expect(interpolation).toStrictEqual({
        type: NodeTypes.INTERPOLATION,
        content: {
          type: NodeTypes.SIMPLE_EXPRESSION,
          content: 'message',
        },
      })
    })
  })

  // 测试元素节点解析
  describe('element', () => {
    it('should parse simple element', () => {
      // 解析简单的空元素标签
      const ast = baseParse('<div></div>')
      const element = ast.children[0]

      // 断言:元素节点包含正确的标签名、空属性、空子节点,非自闭合
      expect(element).toStrictEqual({
        type: NodeTypes.ELEMENT,
        tag: 'div',
        props: [],
        children: [],
        isSelfClosing: false,
      })
    })

    it('should parse element with attributes', () => {
      // 解析带属性的元素,验证属性名值对被正确提取
      const ast = baseParse('<div id="app" class="container"></div>')
      const element = ast.children[0]

      expect(element.tag).toBe('div')
      // 断言:两个属性都被正确解析
      expect(element.props).toEqual([
        { name: 'id', value: 'app' },
        { name: 'class', value: 'container' },
      ])
    })

    it('should parse self-closing element', () => {
      // 解析自闭合标签,验证 isSelfClosing 标记
      const ast = baseParse('<br />')
      const element = ast.children[0]

      expect(element.tag).toBe('br')
      // 断言:自闭合标签的 isSelfClosing 为 true
      expect(element.isSelfClosing).toBe(true)
    })

    it('should parse nested elements', () => {
      // 解析嵌套元素,验证父子层级关系
      const ast = baseParse('<div><p>hello</p></div>')
      const div = ast.children[0]       // 外层 div
      const p = div.children[0]         // 内层 p(div 的子节点)
      const text = p.children[0]        // 文本(p 的子节点)

      // 断言:嵌套层级正确,文本内容正确
      expect(div.tag).toBe('div')
      expect(p.tag).toBe('p')
      expect(text.content).toBe('hello')
    })
  })

  // 测试混合内容解析(文本 + 插值 + 元素的组合)
  describe('combined', () => {
    it('should parse text with interpolation', () => {
      // 解析 "hello {{ name }} world",应产生3个子节点:文本 + 插值 + 文本
      const ast = baseParse('hello {{ name }} world')

      // 断言:共3个子节点
      expect(ast.children.length).toBe(3)
      // 第1个:纯文本 "hello "
      expect(ast.children[0]).toStrictEqual({
        type: NodeTypes.TEXT,
        content: 'hello ',
      })
      // 第2个:插值表达式 {{ name }}
      expect(ast.children[1]).toStrictEqual({
        type: NodeTypes.INTERPOLATION,
        content: {
          type: NodeTypes.SIMPLE_EXPRESSION,
          content: 'name',
        },
      })
      // 第3个:纯文本 " world"
      expect(ast.children[2]).toStrictEqual({
        type: NodeTypes.TEXT,
        content: ' world',
      })
    })

    it('should parse element with text and interpolation children', () => {
      // 解析元素内包含文本和插值的混合内容
      const ast = baseParse('<p>hi {{ msg }}</p>')
      const p = ast.children[0]

      expect(p.tag).toBe('p')
      // 断言:p 元素有2个子节点(文本 + 插值)
      expect(p.children.length).toBe(2)
      expect(p.children[0].type).toBe(NodeTypes.TEXT)
      expect(p.children[0].content).toBe('hi ')
      expect(p.children[1].type).toBe(NodeTypes.INTERPOLATION)
      // 验证插值内容为 'msg'
      expect(p.children[1].content.content).toBe('msg')
    })

    it('should parse complex template', () => {
      // 解析复杂的嵌套模板:div 内包含 p 和 span 两个子元素
      const ast = baseParse(
        '<div id="app"><p>hello</p><span>{{ msg }}</span></div>',
      )
      const div = ast.children[0]

      expect(div.tag).toBe('div')
      // 断言:div 有2个子元素,分别是 p 和 span
      expect(div.children.length).toBe(2)
      expect(div.children[0].tag).toBe('p')
      expect(div.children[1].tag).toBe('span')
    })
  })

  // 测试注释节点解析
  describe('comment', () => {
    it('should parse comment', () => {
      // 解析 HTML 注释,验证注释内容被正确提取
      const ast = baseParse('<!-- this is a comment -->')
      const comment = ast.children[0]

      // 断言:注释节点的内容保留了原始空白
      expect(comment).toStrictEqual({
        type: NodeTypes.COMMENT,
        content: ' this is a comment ',
      })
    })
  })
})

设计分析

1. 递归下降的优雅

整个 parser 没有使用任何外部工具(如 PEG.js、ANTLR),而是手写递归下降。这种方式:

  • 代码量小,容易理解
  • 错误信息可以做到非常精准(知道当前行列号)
  • 性能好,没有额外的 parser generator 开销

2. 祖先栈的巧妙设计

ancestors 数组同时承担两个角色:

  1. 作为解析上下文isEnd 通过检查祖先栈来判断是否遇到了祖先的结束标签
  2. 作为错误检测:如果结束标签匹配的不是栈顶元素,可以检测出标签嵌套错误

3. 消费式解析

通过 advanceBy 不断从 source 头部消费字符,这种"消费式"解析有一个很好的性质:永远只需要看 source 的开头,不需要维护额外的游标索引。

4. 为什么不用正则解析整个模板?

正则表达式无法处理递归嵌套结构(正则是有限自动机,无法识别上下文无关语言)。HTML 的标签嵌套天然是递归结构,必须用递归下降或类似的方法。

本节小结

  1. 三段式架构 — parse → transform → codegen,职责清晰、可测试、可扩展
  2. 有限状态机 — 根据当前字符决定进入哪个解析分支
  3. 递归下降 — 手写 parser,每个解析函数对应一种语法结构
  4. AST 节点 — Element / Text / Interpolation / Comment 四种基本类型
  5. ParserContext — 跟踪解析位置(source/line/column/offset)
  6. advanceBy — 核心推进函数,消费已解析字符
  7. 祖先栈 — 配合 isEnd 判断子节点解析边界

下一节实现 transform 阶段,对 AST 进行语义分析和转换。

用心学习,用代码说话 💻