情形二:文本含插值(source="some {{ foo + bar }} text")
本小节核心验证文本节点与插值节点的解析逻辑。插值表达式已有独立测试用例,源码分析阶段可将普通文本与插值场景合并梳理,我们先看对应的测试用例,验证解析后生成的 AST 结构是否符合预期。
ts
// __tests__/parse.spec.ts 64~87行
test('text with interpolation', () => {
const ast = baseParse('some {{ foo + bar }} text')
const text1 = ast.children[0] as TextNode
const text2 = ast.children[2] as TextNode
const text3 = ast.children[1] as TextNode
expect(text1).toStrictEqual({
type: NodeTypes.TEXT,
content: 'some ',
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 5, line: 1, column: 6 },
source: 'some ',
},
})
expect(text2).toStrictEqual({
type: NodeTypes.TEXT,
content: ' text',
loc: {
start: { offset: 20, line: 1, column: 21 },
end: { offset: 25, line: 1, column: 26 },
source: ' text',
},
})
expect(text3).toStrictEqual({
type: NodeTypes.INTERPOLATION,
content:{
"type": NodeTypes.SIMPLE_EXPRESSION,
"loc": {
"start": {"column": 9,"line": 1,"offset": 8},
"end": {"column": 18,"line": 1,"offset": 17},
"source": "foo + bar"
},
"content": "foo + bar",
"isStatic": false,
"constType": 0
}
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 20, line: 1, column: 21 },
source: "{{ foo + bar }}",
},
})
})
baseParser.ts 的整体解析流程已在情形一中介绍,下面重点分析 tokenizer.parse 对普通文本 + 插值的流式处理逻辑。
文本与插值解析一共设计 4 种状态:State.Text、State.InterpolationOpen、State.Interpolation、State.InterpolationClose,解析初始默认状态为 this.state = State.Text。Tokenizer 入口核心代码如下:
ts
export default class Tokenizer {
// tokenizer.ts 944~1093行
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
}
case State.InterpolationOpen: {
this.stateInterpolationOpen(c)
break
}
case State.Interpolation: {
this.stateInterpolation(c)
break
}
case State.InterpolationClose: {
this.stateInterpolationClose(c)
break
}
// 其他状态分支省略(标签、插值、属性等)
}
this.index++
}
this.cleanup() // 收尾处理剩余文本片段
this.finish()
}
}
针对示例源码 some {``{ foo + bar }} text: 在 index < 5 阶段,解析始终处于 State.Text 普通文本状态;直到读取到 { 字符时,状态切换为 State.InterpolationOpen,对应逻辑在 stateText 中实现:
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)
}
// 普通文本字符:无任何逻辑,Tokenizer.parse 继续遍历
}
切换至 State.InterpolationOpen 后,会先重置 this.delimiterIndex = 0,该变量用于插值分隔符匹配计数。状态切换后会直接调用 stateInterpolationOpen ,而不是等待下一轮循环,目的是立刻校验完整的插值起始分隔符 {``{。
ts
// tokenizer.ts 354~373行
private stateInterpolationOpen(c: number): void {
if (c === this.delimiterOpen[this.delimiterIndex]) {
// 完整匹配 {{ 分隔符
if (this.delimiterIndex === this.delimiterOpen.length - 1) {
const start = this.index + 1 - this.delimiterOpen.length
// 先提交前置普通文本
if (start > this.sectionStart) {
this.cbs.ontext(this.sectionStart, start)
}
// 切换状态,开始收集插值内容
this.state = State.Interpolation
this.sectionStart = start
} else {
this.delimiterIndex++
}
} else if (this.inRCDATA) {
// RCDATA 环境匹配失败,回退原始文本模式
this.state = State.InRCDATA
this.stateInRCDATA(c)
} else {
// 匹配失败,回退普通文本模式
this.state = State.Text
this.stateText(c)
}
}
{``{ 分隔符完整匹配流程梳理:
js
// 初始化:
this.delimiterIndex = 0
// ⬇
// 进入stateInterpolationOpen,匹配到第一个分隔符`{`:
this.delimiterIndex === this.delimiterOpen.length - 1 // this.delimiterOpen.length = 2
? this.state = State.Interpolation // 匹配到第二个分隔符`{`,状态切换为 State.Interpolation
: this.delimiterIndex++
补充:
this.delimiterOpen使用Uint8Array存储而非普通数组。 Uint8Array 属于 TypedArray,底层是连续的 ArrayBuffer 二进制内存,仅可存储 0~255 单字节整数,刚好对应 ASCII 字符码;数组长度固定,读写性能更高,非常适合字符码流式解析这类底层场景。
当成功匹配 {``{ 后,会通过 this.cbs.ontext(this.sectionStart, start) 将插值之前的普通文本生成文本节点,并入根节点的 children 数组,该回调的基础逻辑已在情形一中说明。 this.sectionStart 初始值为 0,用来标记当前正在收集的连续文本 / 节点区间起始下标,和不断自增的 index 配合截取源码区间,是 Vue3.4 + 流式 Tokenizer(不预存完整 token、边扫描边回调)的核心位置标记。提交文本节点后,更新 this.sectionStart = start,准备收集插值内部表达式。
ts
// tokenizer.ts 375~381行
private stateInterpolation(c: number): void {
if (c === this.delimiterClose[0]) {
this.state = State.InterpolationClose
this.delimiterIndex = 0
this.stateInterpolationClose(c)
}
}
State.Interpolation 阶段只监听插值结束分隔符第一个 },一旦命中就切换为 State.InterpolationClose,重置计数并立即执行 stateInterpolationClose,逻辑和前面 InterpolationOpen 一致。
ts
// tokenizer.ts 383~400行
private stateInterpolationClose(c: number) {
if (c === this.delimiterClose[this.delimiterIndex]) {
// 完整匹配 }},插值解析完成
if (this.delimiterIndex === this.delimiterClose.length - 1) {
this.cbs.oninterpolation(this.sectionStart, this.index + 1)
// 根据环境切换后续状态
this.state = this.inRCDATA ? State.InRCDATA : State.Text
this.sectionStart = this.index + 1
} else {
this.delimiterIndex++
}
} else {
// 匹配失败,回退插值内容解析状态
this.state = State.Interpolation
this.stateInterpolation(c)
}
}
直到完整匹配 }},代表当前插值解析结束,触发 oninterpolation 回调来生成插值 AST 节点。
ts
// parser.ts 111~137行
oninterpolation(start, end) {
// v-pre:不解析插值,直接当做普通文本处理
if (inVPre) {
return onText(getSlice(start, end), start, end)
}
// 剥离前后 {{ }},裁剪首尾空白
let innerStart = start + tokenizer.delimiterOpen.length
let innerEnd = end - tokenizer.delimiterClose.length
while (isWhitespace(currentInput.charCodeAt(innerStart))) innerStart++
while (isWhitespace(currentInput.charCodeAt(innerEnd - 1))) innerEnd--
let exp = getSlice(innerStart, innerEnd)
// 表达式内部HTML实体解码
if (exp.includes('&')) {
exp = __BROWSER__ ? currentOptions.decodeEntities!(exp, false) : decodeHTML(exp)
}
// 创建插值AST节点
addNode({
type: NodeTypes.INTERPOLATION,
content: createExp(exp, false, getLoc(innerStart, innerEnd)),
loc: getLoc(start, end),
})
},
// parser.ts 912~914行
function addNode(node: TemplateChildNode) {
;(stack[0] || currentRoot).children.push(node)
}
oninterpolation 回调会自动完成几项工作:v-pre 降级处理、剔除插值表达式首尾空白、HTML 实体解码,最后生成 INTERPOLATION 节点并推入根节点的 children。
插值节点内部的表达式节点由 createExp 创建:
ts
// parser.ts 971~1010行
function createExp(
content: SimpleExpressionNode['content'], // 原始表达式字符串,比如 "msg + 1"
isStatic: SimpleExpressionNode['isStatic'] = false, // 是否静态文本(不需要执行)
loc: SourceLocation, // 在模板源码中的位置信息(行列、offset,报错用)
constType: ConstantTypes = ConstantTypes.NOT_CONSTANT, // 是否常量
parseMode = ExpParseMode.Normal, // 表达式解析模式 Normal / Statements / Params
){
const exp = createSimpleExpression(content, isStatic, loc, constType)
// 判断是否需要走 Babel 解析生成 exp.ast
if (
!__BROWSER__ && // **非浏览器端编译**(打包时预编译,不是浏览器实时 compile;浏览器里不引入 babel,不做静态分析)
!isStatic && // 不是静态字符串,是可执行 JS 表达式
currentOptions.prefixIdentifiers && // 开启标识符前缀模式(module 模式,需要自动补`_ctx.xxx`)
parseMode !== ExpParseMode.Skip && // 非跳过解析模式
content.trim() // 表达式非空字符串
) {
// 代码省略
}
return exp
}
// ast.ts 697~710行
export function createSimpleExpression(
content: SimpleExpressionNode['content'],
isStatic: SimpleExpressionNode['isStatic'] = false,
loc: SourceLocation = locStub,
constType: ConstantTypes = ConstantTypes.NOT_CONSTANT,
): SimpleExpressionNode {
return {
type: NodeTypes.SIMPLE_EXPRESSION,
loc,
content,
isStatic,
constType: isStatic ? ConstantTypes.CAN_STRINGIFY : constType,
}
}
createExp 用于生成 SimpleExpressionNode 模板表达式 AST 节点。仅在非浏览器构建、开启 prefixIdentifiers(Module 模式,不使用 with)的场景下 ,才会借助 Babel 将模板 JS 表达式进一步解析成原生 JS AST(exp.ast),用于静态分析和自动补全_ctx.xxx标识符前缀;浏览器端实时编译会直接跳过 Babel 解析,不会生成 exp.ast 属性。
创建为插值节点后,最后会调用 this.cleanup() 收尾处理剩余文本片段并将文本节点插入到 root.children 中