主题
实现编译器 - 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 Compiler | React 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 数组同时承担两个角色:
- 作为解析上下文:
isEnd通过检查祖先栈来判断是否遇到了祖先的结束标签 - 作为错误检测:如果结束标签匹配的不是栈顶元素,可以检测出标签嵌套错误
3. 消费式解析
通过 advanceBy 不断从 source 头部消费字符,这种"消费式"解析有一个很好的性质:永远只需要看 source 的开头,不需要维护额外的游标索引。
4. 为什么不用正则解析整个模板?
正则表达式无法处理递归嵌套结构(正则是有限自动机,无法识别上下文无关语言)。HTML 的标签嵌套天然是递归结构,必须用递归下降或类似的方法。
本节小结
- 三段式架构 — parse → transform → codegen,职责清晰、可测试、可扩展
- 有限状态机 — 根据当前字符决定进入哪个解析分支
- 递归下降 — 手写 parser,每个解析函数对应一种语法结构
- AST 节点 — Element / Text / Interpolation / Comment 四种基本类型
- ParserContext — 跟踪解析位置(source/line/column/offset)
- advanceBy — 核心推进函数,消费已解析字符
- 祖先栈 — 配合 isEnd 判断子节点解析边界
下一节实现 transform 阶段,对 AST 进行语义分析和转换。