在看 vapor 源码时,我发现 compiler-vapor 包里面的 parse 函数也是调用的 compiler-core 里头的 baseParse,所以说 v3 的 parse 原理清楚了,那么 vapor 的 parse 你也就清楚了 (但本文的 debug 还是采用的 vapor 模式
为了方便称呼,后面称传统 vue3(vdom),我都说成 v3了
在 packages\compiler-dom\src\index.ts 目录下,我们可以看到 parse 函数其实就是 return baseParse,所以我们主要研究 baseParse 的原理就行

在深入 baseParse 前,我想抛出一个问题,为啥在 compiler-vapor 中不直接调用 baseParse,而是经历一层 compiler-dom ?
这就得了解 compiler-sfc,compiler-dom,compiler-core 的关系了,也可以理解为 vue 的编译架构
compiler- 包

可以看到 compiler-sfc 是后续编译的起点,在 compiler-sfc 中有这样的一段代码,他会根据入参去选择后续的 平台层(compiler-dom,compiler-ssr,compiler-vapor)

compiler-sfc 的作用其实就是去将 .vue 文件去拆分三个部分,template, script, 以及 style ,会有一个 SFCDescriptor 接口去描述
ts
// 解析 .vue 文件为三个部分
export interface SFCDescriptor {
template: SFCTemplateBlock | null
script: SFCScriptBlock | null
scriptSetup: SFCScriptBlock | null
styles: SFCStyleBlock[]
customBlocks: SFCBlock[]
}
像 setup,vapor 也只是语法糖处理
v3 中 compiler-ssr,compiler-dom 区分了平台,dom 是浏览器特有的,ssr 为服务端渲染,而如今的 vapor 强调了无虚拟 dom,那么也应该是 浏览器 特有的。
而 compiler-core 才是最终去解析 source 的包,也就是专注语法解析逻辑
现在回到开头抛出的问题,为何不直接调用 baseParse,而是经历一层 compiler-dom,那必然是传入了 dom 环境相关的入参
parse 函数的入参 parseOptions 这个接口属性我们只看 parseMode
ts
/**
* Base mode is platform agnostic and only parses HTML-like template syntax,
* treating all tags the same way. Specific tag parsing behavior can be
* configured by higher-level compilers.
*
* HTML mode adds additional logic for handling special parsing behavior in
* `<script>`, `<style>`,`<title>` and `<textarea>`.
* The logic is handled inside compiler-core for efficiency.
*
* SFC mode treats content of all root-level tags except `<template>` as plain
* text.
*/
parseMode?: 'base' | 'html' | 'sfc'
比如 html 模式就会识别 html 标签,nameSpace 等
这里我随便写了个 template 去 debug,可以看到 vapor 模式下的 parse 的options 似乎有点不同于 v3

v3 为了减少运行时 diff,在编译阶段会有一个 hoistStatic,也就是静态提升,默认都是 true,而 vapor 这里为 false,这是因为 vapor 直接在编译过程中全部处理了
现在进入正题 baseParse
baseParse
由于是 debug 的方式看源码,为了不被层层调用栈所绕晕,我们在进入函数前一定要明白这个函数作用是什么,也就是关注拿到了什么入参,又得到了什么返回值
baseParse 后其实就是返回 ast
ts
const ast = parse(source, resolvedOptions)
parse 的第二个入参前面已经讲了,就是用来传递一些环境特性值,而第一个参数 source 其实就是 template 字符串

现在看返回值 ast 是啥

可以看到,是个非常标准的 ast 结构,只不过目前的 Templated AST 不同于 Transformed AST,此时还保留着 v- 指令
所以说 baseParse 的作用就是依据不同的环境特性去处理 template 字符串,最后解析成 ast,然后识别出 v- 指令
baseParse 函数如下
ts
export function baseParse(input: string, options?: ParserOptions): RootNode {
// 1. 初始化配置
reset() // 重置全局状态
currentInput = input // 设置输入模板
currentOptions = extend({}, defaultParserOptions, options) // 合并配置
// 2. 配置 Tokenizer
tokenizer.mode =
parseMode === 'html' ? ParseMode.HTML : // HTML 模式:处理 <script>, <style>
parseMode === 'sfc' ? ParseMode.SFC : // SFC 模式:单文件组件
ParseMode.BASE // BASE 模式:基础解析
tokenizer.inXML = (ns === SVG || ns === MATH_ML) // XML 命名空间处理
// 3. 设置插值分隔符
if (delimiters) {
tokenizer.delimiterOpen = toCharCodes('{{') // 默认 {{
tokenizer.delimiterClose = toCharCodes('}}') // 默认 }}
}
// 4. 解析
const root = createRoot([], input) // 创建根节点
tokenizer.parse(currentInput) // 核心:词法分析 + 语法分析
root.loc = getLoc(0, input.length) // 设置位置信息
// 5. return
root.children = condenseWhitespace(root.children) // 压缩空白符
currentRoot = null // 清理全局状态
return root // 返回 AST
}
可以看到,这里所有的代码其实都是为 tokenizer.parse(currentInput)
这一行代码服务,因此 baseParse 的核心就是 tokenizer.parse 函数
tokenizer
tokenizer(词法分析器),一般都是将源码或者输入文本分解成一系列有意义的标记,也就是 tokens,有个很常见的🌰,一个数学运算: const num = 1 + 1;
可能被分解为:
md
const (关键字)
age (标识符)
= (运算符)
1 (数字字面量)
+ (运算符)
1 (数字字面量)
; (分隔符)
一般词法分析都是先将源码 tokenizer 成 一系列 tokens,然后再去 parse 语法分析成 最终的 ast

baseParse 的核心就是 tokenizer.parse,再进入 tokenizer.parse 前,我们也需要理清前面的铺垫,其中我们先看下 实例对象 tokenizer 是如何被 new 出来的
ts
const stack: ElementNode[] = []
const tokenizer = new Tokenizer(stack, {
ontext(start: number, endIndex: number): void
ontextentity(char: string, start: number, endIndex: number): void
oninterpolation(start: number, endIndex: number): void
onopentagname(start: number, endIndex: number): void
onopentagend(endIndex: number): void
onselfclosingtag(endIndex: number): void
onclosetag(start: number, endIndex: number): void
onattribdata(start: number, endIndex: number): void
onattribentity(char: string, start: number, end: number): void
onattribend(quote: QuoteType, endIndex: number): void
onattribname(start: number, endIndex: number): void
onattribnameend(endIndex: number): void
ondirname(start: number, endIndex: number): void
ondirarg(start: number, endIndex: number): void
ondirmodifier(start: number, endIndex: number): void
oncomment(start: number, endIndex: number): void
oncdata(start: number, endIndex: number): void
onprocessinginstruction(start: number, endIndex: number): void
// ondeclaration(start: number, endIndex: number): void
onend(): void
onerr(code: ErrorCodes, index: number): void
})
第一个参数 stack ,这个栈就是用来存放元素节点的
[]
→ 遇到<div>
→[div]
[div]
→ 遇到<span>
→[div, span]
[div, span]
→ 遇到</span>
→[div]
[div]
→ 遇到</div>
→[]
第二个参数是个回调集合,Tokenizer 在解析过程中会调用这些回调来通知 Parser 构建 AST
比如其中的 oninterpolation ,会提取 {{ }} 里面的内容,然后 addNode 到 root 中去

第一步:初始化配置
ts
reset() // 重置全局状态
currentInput = input // 设置输入模板
currentOptions = extend({}, defaultParserOptions, options) // 合并配置
拿到 baseParse 的两个入参,一个 template 字符串 currentInput,一个 parseOptions 为 currentOptions ,还会调用 reset,这个 reset 函数里面会调用 tokenizer.reset()
我们可以进入 tokenizer.reset 看看这个 reset 做了啥
ts
public reset(): void {
this.state = State.Text
this.mode = ParseMode.BASE
this.buffer = ''
this.sectionStart = 0
this.index = 0
this.baseState = State.Text
this.inRCDATA = false
this.currentSequence = undefined!
this.newlines.length = 0
this.delimiterOpen = defaultDelimitersOpen
this.delimiterClose = defaultDelimitersClose
}
可以看到这里是 重置默认全局状态,之前的 parseMode 有个 默认值 base 这里也可以体现,不过稍后这里的 parseMode 在 vapor 模式下肯定会被赋值为 html
第二步:配置 tokenizer
ts
tokenizer.mode =
parseMode === 'html' ? ParseMode.HTML : // HTML 模式:处理 <script>, <style>
parseMode === 'sfc' ? ParseMode.SFC : // SFC 模式:单文件组件
ParseMode.BASE // BASE 模式:基础解析
tokenizer.inXML = (ns === SVG || ns === MATH_ML) // XML 命名空间处理
我们在 vapor 模式下 debug 会发现 parseMode 为 html 时 mode 为 1

这是因为 ts 枚举值,html 下标为 1
ts
export enum ParseMode {
BASE,
HTML,
SFC,
}
第三步:设置插值分隔符
ts
if (delimiters) {
tokenizer.delimiterOpen = toCharCodes('{{') // 默认 {{
tokenizer.delimiterClose = toCharCodes('}}') // 默认 }}
}
这里就是 vue 的挖坑写法,在 tokenizer 的 reset 阶段就已经初始化好了 左右 分隔符
第四步:解析(核心步骤)
ts
const root = createRoot([], input) // 创建根节点
tokenizer.parse(currentInput) // 🔥 词法分析 + 语法分析
root.loc = getLoc(0, input.length) // 设置位置信息
先创建了根节点,也就是把 ast 的结构先支撑起来

随后进入 parse 阶段
parse 阶段就是 tokenizer + 语法解析

tokenizer.parse 函数会遍历 template 字符串的每个字符的字符编码,然后根据当前状态去调用对应的处理函数,比如 Text 状态会对应一个 stateText 处理文本状态的函数,BeforeTagName 会有一个 stateBeforeTagName 处理标签名前状态的函数,每个状态对应的处理函数都会确定下一个状态,并且会触发回调
我们先看下 State,里面记录了 tokenizer 当前处于什么解析阶段,用于决定如何处理下一个字符
ts
export enum State {
// 文本、插值
Text = 1, // 解析普通文本内容
InterpolationOpen, // 遇到 {{ 开始解析插值
Interpolation, // 在插值表达式内部
InterpolationClose, // 遇到 }} 结束插值
// 标签
BeforeTagName, // 遇到 < 后,准备读取标签名
InTagName, // 正在读取标签名
InSelfClosingTag, // 在自闭合标签中 />
BeforeClosingTagName, // 遇到 </ 后
InClosingTagName, // 正在读取结束标签名
AfterClosingTagName, // 结束标签名读取完毕
// 属性
BeforeAttrName, // 准备读取属性名
InAttrName, // 正在读取属性名
AfterAttrName, // 属性名读取完毕
BeforeAttrValue, // 准备读取属性值
InAttrValueDq, // 在双引号属性值中 "value"
InAttrValueSq, // 在单引号属性值中 'value'
InAttrValueNq, // 在无引号属性值中
// v- 指令
InDirName, // 在指令名中 v-if, v-for
InDirArg, // 在指令参数中 v-on:click
InDirDynamicArg, // 在动态指令参数中 v-on:[event]
InDirModifier, // 在指令修饰符中 @click.prevent
// 特殊标签
BeforeDeclaration, // 遇到 <! 后
InDeclaration, // 在声明中 <!DOCTYPE>
InProcessingInstruction, // 在处理指令中 <?xml?>
BeforeComment, // 准备读取注释
InCommentLike, // 在注释或CDATA中
CDATASequence, // 在CDATA序列中
InSpecialComment, // 在特殊注释中
}
state 在 Tokenizer 中初始值为 Text,也就是 0 ,那么必然会走到第一个 case,然后执行 this.stateText,入参 c 就是当前 source 字符的字符编码,比如 A 就是对应 65,我们看 stateText 函数

template 字符串开头必然是 <template>
打头,所以第一个字符对应着 < ,这里的 CharCodes.Lt 就是对应着 < ,那么在状态转移这里也刚好对应着 BeforeTagName 的状态,也就意味着下一次 while 循环就是去执行 stateBeforeTagName 函数
我们可以看下 CharCodes 都有哪些字符对应关系
ts
export enum CharCodes {
Tab = 0x9, // "\t"
NewLine = 0xa, // "\n"
FormFeed = 0xc, // "\f"
CarriageReturn = 0xd, // "\r"
Space = 0x20, // " "
ExclamationMark = 0x21, // "!"
Number = 0x23, // "#"
Amp = 0x26, // "&"
SingleQuote = 0x27, // "'"
DoubleQuote = 0x22, // '"'
GraveAccent = 96, // "`"
Dash = 0x2d, // "-"
Slash = 0x2f, // "/"
Zero = 0x30, // "0"
Nine = 0x39, // "9"
Semi = 0x3b, // ";"
Lt = 0x3c, // "<"
Eq = 0x3d, // "="
Gt = 0x3e, // ">"
Questionmark = 0x3f, // "?"
UpperA = 0x41, // "A"
LowerA = 0x61, // "a"
UpperF = 0x46, // "F"
LowerF = 0x66, // "f"
UpperZ = 0x5a, // "Z"
LowerZ = 0x7a, // "z"
LowerX = 0x78, // "x"
LowerV = 0x76, // "v"
Dot = 0x2e, // "."
Colon = 0x3a, // ":"
At = 0x40, // "@"
LeftSquare = 91, // "["
RightSquare = 93, // "]"
}
stateBeforeTagName 如下
ts
private stateBeforeTagName(c: number): void {
if (c === CharCodes.ExclamationMark) {
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)) {
this.sectionStart = this.index
if (this.mode === ParseMode.BASE) {
// no special tags in base mode
this.state = State.InTagName
} else if (this.inSFCRoot) {
// SFC mode + root level
// - everything except <template> is RAWTEXT
// - <template> with lang other than html is also RAWTEXT
this.state = State.InSFCRootTagName
} else if (!this.inXML) {
// HTML mode
// - <script>, <style> RAWTEXT
// - <title>, <textarea> RCDATA
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)
}
}
function isTagStartChar(c: number): boolean {
return (
(c >= CharCodes.LowerA && c <= CharCodes.LowerZ) ||
(c >= CharCodes.UpperA && c <= CharCodes.UpperZ)
)
}
stateBeforeTagName 的作用就是判断 < 后接的是什么类型的字符,比如 <template>
后面就是 t,那么就会走 isTagStartChar 里面的分支
前面两个 if 走的都是属于特殊字符,比如 <!-- -->
这种
在 isTagStartChar 分支中,又会判断之前在 compiler-dom 层传递的 parseMode,这里自然是 HTML 模式,因此走到
ts
if (c === 116 /* t */) {
this.state = State.BeforeSpecialT
} else {
this.state =
c === 115 /* s */ ? State.BeforeSpecialS : State.InTagName
}
更新状态为 BeforeSpecialT
t 和 s 都是特殊字符串,t 有可能是 <title>, <textarea>
s 有可能是 <script>, <style>
template 我们就不再这样往下看了,中间会将状态从 specialT 中撤出来,遇到 > 时会触发 onopentagname 将记录的 下标 去 slice 切割出 template 然后设置到 当前 root 的 tag 属性

这里面的 nameSpace 由于是 HTML mode 会单独再次判断 svg 这个 tag,而 getLoc 则是依据传入的起始位置和终止位置解析出 column ,line 信息,方便做 sourceMap 源码地图
因此我们明白了 onopentagname 的目的就是去更新当前 root 的节点信息
执行完 onopentagname 这个回调后还会去执行 stateBeforeAttrName 函数,stateBeforeAttrName 的目的则是将当前 root addNode 到 currentRoot 中

我们现在可以发现,在 tokenizer 实例化传入的回调函数 cbs ,会在 parse 的 while 循环中的 状态转移函数中进行执行,而这些回调函数会将当前解析好的 token 去 parse 到 currentRoot 中,这就是 addNode
第五步:return
ts
root.children = condenseWhitespace(root.children) // 压缩空白符
currentRoot = null // 清理全局状态
return root // 返回 AST
总结
其实 parse 的过程还是非常复杂的,本文还是简单用了一个 <template>
的例子去作讲解,讲了从 <template>
到 ast 的过程。
一段话总结就是 template 到 templated AST 需要调用 compiler-core 的 baseParse 函数,这个函数的本质就是调用的 Tokenizer.parse,parse 的过程中需要遍历每个字符,根据当前状态去执行对应的状态函数,得到下一个状态,然后触发回调 cbs,通过 addNode 去构建 AST
字符串 → 状态机 → 状态函数 → 回调触发 → Parser处理 → AST构建
