Vue3.4+ 模板 AST 生成原理
新旧流程对比
-
Vue3.4 之前 模板字符串 → Scanner 生成完整 Token 数组 → Parser 循环消费 Token → 构建 AST。缺点:需要先把全部 Token 存入数组,长模板会占用额外内存
-
Vue3.4+ 新版 模板字符串 → Tokenizer 逐字符状态机扫描 → 识别文本 / 标签 / 插值 / 属性等单元 → 同步回调即时创建 AST 节点,不再生成完整 Token 数组
核心源码(保留原有代码,补充注释与类型语义)
ts
// compile.ts
import { baseParse } from './parser'
import type { CodegenResult, CompilerOptions, RootNode } from './types'
export function baseCompile(
source: string | RootNode,
options: CompilerOptions = {},
): CodegenResult {
// ... 其他编译前置逻辑省略
const ast = isString(source) ? baseParse(source, resolvedOptions) : source
// ... transform、generate 后续逻辑省略
}
ts
// parser.ts
import Tokenizer from './tokenizer'
import { NodeTypes, RootNode, ElementNode } from './types'
import { getLoc, createRoot } from './utils'
let currentRoot: RootNode | null = null
let currentInput = ''
const stack: ElementNode[] = []
type Callbacks = {
onText: (start: number, end: number) => void
// 还包含 onTagOpen / onTagClose / onInterpolation / onAttribute 等回调,这里仅保留示例onText
}
export function baseParse(input: string, options?: ParserOptions): RootNode {
currentInput = input
const root = (currentRoot = createRoot([], input))
// 实例化字符扫描器,传入元素栈和节点生成回调
const tokenizer = new Tokenizer(stack, {
onText(start: number, end: number) {
const parent = stack[0] || currentRoot!
const content = currentInput.slice(start, end)
parent.children.push({
type: NodeTypes.TEXT,
content,
loc: getLoc(start, end),
})
},
})
tokenizer.parse(currentInput)
currentRoot = null
return root
}
ts
// tokenizer.ts
import { CharCodes, State } from './constants'
export default class Tokenizer {
private buffer = ''
private index = 0
private sectionStart = 0
private state: State = State.Text
constructor(
private readonly stack: ElementNode[],
private readonly cbs: Callbacks,
) {}
public parse(input: string): void {
this.buffer = input
this.index = 0
this.sectionStart = 0
this.state = State.Text
// 逐字符遍历模板字符串
while (this.index < this.buffer.length) {
const c = this.buffer.charCodeAt(this.index)
// 根据当前状态分发处理逻辑
switch (this.state) {
case State.Text:
this.stateText(c)
break
// 省略 BeforeTagName / InTagName / InAttr 等其他状态分支
}
this.index++
}
}
private stateText(c: number): void {
// 遇到 < 符号:截断当前文本区间,触发onText回调,切换为标签解析状态
if (c === CharCodes.Lt) {
if (this.index > this.sectionStart) {
this.cbs.onText(this.sectionStart, this.index)
}
this.state = State.BeforeTagName
this.sectionStart = this.index
}
// 省略文本状态下插值 {{、换行等其他分支判断
}
}
示例执行流程:source = "123<div></div>"
-
baseCompile: 判断 source 是字符串,调用
baseParse -
baseParse
- 初始化根 root 节点,赋值全局
currentRoot - 创建 Tokenizer 实例,传入元素栈、onText 回调(用来生成 TEXT 节点)
- 执行
tokenizer.parse(source)启动逐字符扫描
- 初始化根 root 节点,赋值全局
-
Tokenizer.parse
开始逐个字符遍历:
- 依次读取
1、2、3,处于State.Text状态 - 读到
<,触发文本截断:调用onText(0,3),把"123"生成 TEXT 节点,挂载到根节点 children - 状态切换为
State.BeforeTagName,继续解析 div 开始标签、入栈元素节点 - 后续识别闭合
</div>,出栈元素
- 依次读取
-
扫描全部字符结束,baseParse 清空全局 currentRoot,返回构建完成的 Root AST
当 source="123<div></div>"时,执行逻辑如下:
Tokenizer:通过new Tokenizer创建tokenizer对象,注册回调函数baseCompile: 会调用baseParse传入source字符baseParse:会把source字符赋值给全局变量currentInput,通过createRoot函数创造根节点root并赋值给全局变量currentRoot,调用实例化的tokenizer.parse,最后返回根节点roottokenizer.parse:逐字符扫描模板,根据状态调用回调,例如onText回调会将 TEXT 节点挂载到根节点
分场景理解 Tokenizer 源码
基于 Vue 源码 __tests__/parse.spec.ts 纯文本场景测试用例,完整拆解 Tokenizer 对普通文本的解析逻辑、loc 位置信息生成规则、换行/多余空格处理原理,全程保留核心源码,按「执行流程+核心原理+案例佐证」的逻辑梳理,清晰还原 Vue 模板文本解析底层机制。
情形一:仅文本字符(source="\n some text")
测试场景:模板源码仅含换行、空格与普通文本,无标签、无插值语法,是最基础的模板解析场景。
ts
describe('Text', () => {
test('simple text', () => {
const ast = baseParse('\n some text')
const text = ast.children[0] as TextNode
expect(text).toStrictEqual({
type: NodeTypes.TEXT,
content: ' some text',
loc: {
start: { offset: 1, line: 1, column: 0 },
end: { offset: 13, line: 2, column: 13 },
source: '\n some text',
},
})
})
核心疑问:
baseParse如何逐段解析纯文本字符、生成文本 AST 节点?- AST 节点中
loc行列、偏移量、源码区间如何精准计算? - 模板中的换行符、首尾/连续多余空格如何预处理与压缩?
整体执行链路:baseParse 入口流程
所有模板解析均从 baseParse 函数启动,核心流程:创建根 AST 节点 → Tokenizer 逐字符分词解析 → 空白字符规范化处理 → 回填根节点信息并返回 AST。
ts
// baseParse 核心源码
let currentRoot;
export function baseParse(input: string, options?: ParserOptions): RootNode {
// parser.ts 1073~1076行
const root = (currentRoot = createRoot([], input)) // 绑定全局根节点变量
tokenizer.parse(currentInput) // 启动分词器逐字符解析模板
root.loc = getLoc(0, input.length) // 根节点绑定全局位置信息
root.children = condenseWhitespace(root.children) // 规范化空白字符
currentRoot = null // 清空全局临时变量
return root
}
分步执行逻辑
- 创建根节点:调用
createRoot生成模板根 AST 节点,初始化基础属性; - 分词解析:执行
tokenizer.parse,逐字符遍历模板字符串,识别文本、标签、插值等语法; - 空白处理:通过
condenseWhitespace压缩、清理冗余换行与空格; - 收尾返回:绑定根节点位置信息,清空全局变量,输出完整 AST。
createRoot 根节点创建源码:根节点为整棵 AST 的顶层节点,存储模板原始源码、子节点、编译辅助变量等全局信息。
ts
// ast.ts 603~622行
export function createRoot(
children: TemplateChildNode[],
source = '',
): RootNode {
return {
type: NodeTypes.ROOT,
source,
children,
helpers: new Set(),
components: [],
directives: [],
hoists: [],
imports: [],
cached: [],
temps: 0,
codegenNode: undefined,
loc: locStub,
}
}
Tokenizer 文本分词核心逻辑
Tokenizer 分词器默认初始状态为 State.Text(文本状态),绝大多数普通字符都会走文本解析逻辑,仅特殊触发字符会切换解析状态。
分词主循环源码:逐字符遍历模板,单独捕获换行符位置,用于后续loc 行列计算,默认进入文本解析分支。
ts
export default class Tokenizer {
// tokenizer.ts 944~955行
public parse(input: string): void {
this.buffer = input
while (this.index < this.buffer.length) {
const c = this.buffer.charCodeAt(this.index)
// 单独识别换行符,记录位置,用于loc行列解析(排除实体字符场景)
if (c === CharCodes.NewLine && this.state !== State.InEntity) {
this.newlines.push(this.index)
}
// 根据当前解析状态分发处理逻辑
switch (this.state) {
case State.Text: {
this.stateText(c)
break
}
// 其他状态分支省略(标签、插值、属性等)
}
this.index++
}
this.cleanup() // 收尾处理剩余文本片段
this.finish()
}
}
文本状态处理 stateText:仅对特殊字符切换状态,普通文本字符直接留存,不做额外处理。触发状态切换的特殊字符仅有3类:标签左尖括号、实体符、插值起始符,其余所有字符均判定为普通文本。
ts
// tokenizer.ts 334~348行
private stateText(c: number): void {
// 遇到标签开始符 <,结束当前文本解析,切换为标签解析状态
if (c === CharCodes.Lt) {
if (this.index > this.sectionStart) {
this.cbs.ontext(this.sectionStart, this.index)
}
this.state = State.BeforeTagName
this.sectionStart = this.index
}
// 浏览器环境外,遇到实体符&,进入实体解析状态
else if (!__BROWSER__ && c === CharCodes.Amp) {
this.startEntity()
}
// 非v-pre环境下,遇到插值起始符,进入插值解析状态
else if (!this.inVPre && c === this.delimiterOpen[0]) {
this.state = State.InterpolationOpen
this.delimiterIndex = 0
this.stateInterpolationOpen(c)
}
// 普通文本字符:无任何逻辑,继续遍历
}
文本片段收尾 cleanup:分词主循环结束后,调用 cleanup 处理最后一段未收尾的文本片段,触发 ontext 回调,生成文本节点。
ts
// tokenizer.ts 1098~1117行
private cleanup() {
if (this.sectionStart !== this.index) {
// 文本状态、CDATA状态下,收尾文本片段
if (
this.state === State.Text ||
(this.state === State.InRCDATA && this.sequenceIndex === 0)
) {
this.cbs.ontext(this.sectionStart, this.index)
this.sectionStart = this.index
}
// 属性值状态收尾逻辑(省略)
else if (
this.state === State.InAttrValueDq ||
this.state === State.InAttrValueSq ||
this.state === State.InAttrValueNq
) {
this.cbs.onattribdata(this.sectionStart, this.index)
this.sectionStart = this.index
}
}
}
ontext 回调:生成文本 AST 节点。分词器初始化时绑定 ontext 回调,接收文本片段的起止索引,最终生成/合并文本节点,挂载到根节点 children 数组。
ts
// parser.ts 100~105行
const tokenizer = new Tokenizer(stack, {
ontext(start, end) {
onText(getSlice(start, end), start, end)
},
})
// parser.ts 594~614行
function onText(content: string, start: number, end: number) {
// 无嵌套节点时,文本挂载到根节点
const parent = stack[0] || currentRoot
const lastNode = parent.children[parent.children.length - 1]
// 连续文本节点:合并内容、更新结束位置
if (lastNode && lastNode.type === NodeTypes.TEXT) {
lastNode.content += content
setLocEnd(lastNode.loc, end)
}
// 全新文本节点:创建节点、生成loc位置信息并推入子节点数组
else {
parent.children.push({
type: NodeTypes.TEXT,
content,
loc: getLoc(start, end),
})
}
}
loc 位置信息生成机制
loc 是 Vue 模板编译报错精准定位的核心,包含:起止行列号、字符偏移量、对应源码片段,通过 getLoc + getPos 双层方法计算生成。getLoc 统一封装位置信息
ts
// parser.ts 916~924行
function getLoc(start: number, end?: number): SourceLocation {
return {
start: tokenizer.getPos(start), // 起始位置(行、列、偏移)
end: end == null ? end : tokenizer.getPos(end), // 结束位置
source: end == null ? end : getSlice(start, end), // 对应源码片段
}
}
// 截取对应索引区间的源码字符串
function getSlice(start: number, end: number) {
return currentInput.slice(start, end)
}
getPos 索引转行列位置核心算法:通过预存储的 this.newlines(所有换行符的索引数组),将字符串一维偏移索引,转换为编辑器二维行列位置。
优化策略:换行符数组长度超100时用二分查找,否则倒序遍历,保证解析性能。
ts
// tokenizer.ts 296~328行
public getPos(index: number): Position {
// 默认值:如果前面没有任何换行,就是第1行,列默认 index+1
let line = 1
let column = index + 1
const length = this.newlines.length // this.newlines 换行符字符位置数组
let j = -1 // -1 代表 index 前面没有换行符
// 换行数组比较长,用二分查找优化
if (length > 100) {
let l = -1
let r = length
while (l + 1 < r) {
const m = (l + r) >>> 1 // 无符号右移,等价 Math.floor((l+r)/2)
// 如果当前换行位置 < index → 目标在右侧,更新左边界
this.newlines[m] < index ? (l = m) : (r = m)
}
j = l // 循环结束 l 就是最后一个满足 newlines[l] < index 的下标
} else {
// 数组很短,直接倒序遍历,找到第一个小于 index 的换行
for (let i = length - 1; i >= 0; i--) {
if (index > this.newlines[i]) {
j = i
break
}
}
}
// 找到了前面存在换行符
if (j >= 0) {
line = j + 2 // newlines[j] 是第 j+1 行末尾的换行,所以当前是 j+2 行
column = index - this.newlines[j] // 当前索引距离上一个换行的距离(列从1开始?这里注意!)
}
return {
column,
line,
offset: index,
}
}
算法案例实操
测试模板:source = a
bc
def,格式化后等效:a\nbc\ndef,换行符索引数组:this.newlines = [1,4]
- index=0(字符a):无前置换行,j=-1 → line=1、column=1、offset=0
- index=1(换行符\n):无小于1的换行,j=-1 → line=1、column=2、offset=1(换行符归属上一行末尾)
- index=2(字符b):前置最近换行索引1,j=0 → line=2、column=1、offset=2
- index=4(第二个\n):前置最近换行索引1,j=0 → line=2、column=3、offset=4
- index=5(字符d):前置最近换行索引4,j=1 → line=3、column=1、offset=5
空白字符规范化处理(换行/多余空格)
分词完成后,baseParse 最终执行 condenseWhitespace,对文本节点的换行、空格、Tab 做统一规范化处理,是 Vue 模板空白优雅渲染的核心。
核心配置规则
- 默认模式:
whitespace: 'condense'(压缩模式) - 特殊模式:
whitespace: 'preserve'(保留所有空白,不执行压缩逻辑) <pre>标签内:仅统一换行符\r\n为\n,所有空白原样保留
空白压缩完整源码
ts
// parser.ts 837~883行
function condenseWhitespace(nodes: TemplateChildNode[]): TemplateChildNode[] {
const shouldCondense = currentOptions.whitespace !== 'preserve'
let removedWhitespace = false
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
// 仅处理文本节点,元素、注释节点直接跳过
if (node.type === NodeTypes.TEXT) {
// 非pre标签内,执行空白压缩逻辑
if (!inPre) {
// 场景1:文本节点为【纯空白】(仅空格/换行/Tab)
if (isAllWhitespace(node.content)) {
const prev = nodes[i - 1] && nodes[i - 1].type
const next = nodes[i + 1] && nodes[i + 1].type
// 满足以下条件直接删除纯空白节点:
// 1. 首尾空白节点 2. 注释间/注释与元素间空白 3. 元素间带换行的空白
if (
!prev ||
!next ||
(shouldCondense &&
((prev === NodeTypes.COMMENT &&
(next === NodeTypes.COMMENT || next === NodeTypes.ELEMENT)) ||
(prev === NodeTypes.ELEMENT &&
(next === NodeTypes.COMMENT ||
(next === NodeTypes.ELEMENT &&
hasNewlineChar(node.content))))))
) {
removedWhitespace = true
nodes[i] = null as any
}
// 元素间无换行的空白:保留单个空格
else {
node.content = ' '
}
}
// 场景2:非纯空白文本,压缩内部连续空白为单个空格
else if (shouldCondense) {
node.content = condense(node.content)
}
}
// pre标签内:仅统一换行符,不压缩空白
else {
node.content = node.content.replace(windowsNewlineRE, '\n')
}
}
}
// 过滤被置空的空白节点
return removedWhitespace ? nodes.filter(Boolean) : nodes
}
空白处理核心规则总结
-
纯空白文本节点(仅空格/换行/Tab)
-
模板首尾空白、注释与元素间空白、元素间带换行的空白:直接删除
-
元素间无换行的空白:保留1个空格
-
-
含有效内容的文本节点:内部所有连续空格、换行、Tab:统一压缩为单个空格
-
<pre>标签内部-
不执行任何空白压缩,保留原始排版
-
仅将 Windows 换行
\r\n统一转为\n,统一跨平台渲染一致性
-
全文流程总结
- 分词阶段:Tokenizer 默认文本状态遍历字符,仅特殊语法切换状态,普通文本全部留存,结束后生成原始文本节点;
- 位置计算:通过换行符索引数组+二分/遍历算法,将一维字符偏移转为二维行列位置,精准生成
loc信息; - 空白优化:通过
condenseWhitespace清理冗余换行、压缩连续空格,兼顾渲染美观性与浏览器、SSR 一致性。