情形三:文本及标签(source="<div>hello</div>")
本小节拆解 Vue3 编译器中普通 HTML 标签与内部文本节点的完整解析流程。以单元测试用例 __tests__/parse.spec.ts 441‑468 的 simple div 案例作为切入点,梳理三件核心事情:
- 开始标签如何创建节点、压入解析栈
- 子文本节点如何挂载到父元素节点
- 闭合标签触发时,如何做栈的出栈处理
测试用例预览
输入源码:<div>hello</div>,解析后生成的 AST 结构:
ts
// __tests__/parse.spec.ts 441~468行
test('simple div', () => {
const ast = baseParse('<div>hello</div>')
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'div',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [],
children: [
{
type: NodeTypes.TEXT,
content: 'hello',
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 10, line: 1, column: 11 },
source: 'hello',
},
},
],
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 16, line: 1, column: 17 },
source: '<div>hello</div>',
},
})
整体流程:Tokenizer 状态机逐字符扫描 → 触发各类回调 → Parser 接收回调构建 AST、维护节点栈 stack。 解析初始默认状态:this.state = State.Text。
Tokenizer 入口主循环,不停读取字符,根据当前 state 分发不同处理函数,同时记录换行用于行列位置 loc 计算。
ts
export default class Tokenizer {
public mode: ParseMode = ParseMode.BASE
// 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.InTagName: {
this.stateInTagName(c)
break
}
case State.InClosingTagName: {
this.stateInClosingTagName(c)
break
}
case State.BeforeTagName: {
this.stateBeforeTagName(c)
break
}
case State.BeforeClosingTagName: {
this.stateBeforeClosingTagName(c)
break
}
// 其他状态分支省略(标签、插值、属性等)
}
this.index++
}
this.cleanup() // 收尾处理剩余文本片段
this.finish()
}
}
步骤 1:State.Text 遇到 <,切换到标签前置状态 BeforeTagName
初始状态为 State.Text,进入 stateText。 当读到字符 <(CharCodes.Lt):
- 如果中间有文本片段,会调用
ontext回调输出文本;本案例<在索引 0,无前置文本,不触发。 - 将状态切换为
State.BeforeTagName,更新片段起始下标。
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
}
// 其他情况省略
}
State.BeforeTagName 是所有标签的总入口,根据<后面第一个字符区分:声明<!、处理指令<?、闭合标签</、普通开始标签。 本案例 < 之后是字母d,满足 isTagStartChar(a‑z/A‑Z);解析模式为默认 ParseMode.BASE,状态切换为 State.InTagName,进入标签名解析阶段。
ts
// tokenizer.ts 41~45行
export enum ParseMode {
BASE, // 解析普通 HTML 片段,非 SFC,按照浏览器 HTML 容错规则解析
HTML, // 解析完整 HTML 文档
SFC, // 解析 `.vue` 文件
}
// tokenizer.ts 559~595行
private stateBeforeTagName(c: number): void {
if (c === CharCodes.ExclamationMark) { // `!` 字符,对应 `<!`,例如 `<!DOCTYPE html>`、注释`<!-- -->`
this.state = State.BeforeDeclaration
this.sectionStart = this.index + 1
} else if (c === CharCodes.Questionmark) { // `?`,对应 `<?xml ...?>` XML 处理指令
this.state = State.InProcessingInstruction
this.sectionStart = this.index + 1
} else if (isTagStartChar(c)) { // isTagStartChar:工具函数,判断字符是否可以作为标签名首字符 (a-z,A-Z)。代表遇到开始标签
this.sectionStart = this.index
// 按解析模式分发标签类型
if (this.mode === ParseMode.BASE) {
this.state = State.InTagName
} else if (this.inSFCRoot) {
this.state = State.InSFCRootTagName
} else if (!this.inXML) {
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)
}
}
在上述代码中,当 isTagStartChar 结果为 true 时,根据 this.mode this.inSFCRoot this.inXML 三个属性来切换状态,三者的关系如下:
| 场景 | mode | inSFCRoot | inXML | 行为 |
|---|---|---|---|---|
| 解析普通 HTML 片段 | BASE | false | false | 普通标签解析 |
| 解析完整 HTML 文档 | HTML | false | false | 普通标签解析 |
| .vue 文件,顶层还没进入 template | SFC | true | false | 解析 SFC 根标签<template>/<script>/<style> |
| .vue 文件,template 标签内部 | SFC | false | false | 普通 HTML,可以识别<script>等特殊标签 |
.vue,<template lang="xml">内部 |
SFC | false | true | XML 严格模式,无特殊标签逻辑 |
步骤 2:InTagName 解析标签名,触发 onopentagname
isEndOfTagSection 判断:遇到空白、/、>,代表标签名结束。 读到 > 时,调用 handleTagName,触发回调 onopentagname。 Parser 的 onopentagname 会构建临时标签对象 currentOpenTag,此时节点还没有真正加入 AST。
ts
// tokenizer.ts 151~159行
export function isWhitespace(c: number): boolean {
return (
c === CharCodes.Space || // " "
c === CharCodes.NewLine || // "\n"
c === CharCodes.Tab || // "\t"
c === CharCodes.FormFeed || // "\f"
c === CharCodes.CarriageReturn // "\r"
)
}
// tokenizer.ts 161~163行
function isEndOfTagSection(c: number): boolean {
// "/" ">"
return c === CharCodes.Slash || c === CharCodes.Gt || isWhitespace(c)
}
// tokenizer.ts 596~615行
private stateInTagName(c: number): void {
if (isEndOfTagSection(c)) {
this.handleTagName(c)
}
}
private handleTagName(c: number) {
this.cbs.onopentagname(this.sectionStart, this.index)
this.sectionStart = -1
this.state = State.BeforeAttrName
this.stateBeforeAttrName(c)
}
// parser.ts 139~151行
onopentagname(start, end) {
const name = getSlice(start, end)
currentOpenTag = {
type: NodeTypes.ELEMENT,
tag: name,
ns: currentOptions.getNamespace(name, stack[0], currentOptions.ns),
tagType: ElementTypes.ELEMENT, // will be refined on tag close
props: [],
children: [],
loc: getLoc(start - 1, end),
codegenNode: undefined,
}
},
步骤 3:BeforeAttrName 遇到 >,onopentagend 完成开标签处理
本示例没有属性,stateBeforeAttrName 读到 >,触发 onopentagend 回调,状态切回 State.Text。
ts
// tokenizer.ts 648~656行
private stateBeforeAttrName(c: number): void {
if (c === CharCodes.Gt) { // ">" 字符
this.cbs.onopentagend(this.index)
if (this.inRCDATA) {
this.state = State.InRCDATA
} else {
this.state = State.Text
}
this.sectionStart = this.index + 1
}
// ...其他代码省略
}
onopentagend 调用 endOpenTag,核心两件事:
addNode:把currentOpenTag添加到父节点(此时根)的 children;- 非 void 标签,将节点
unshift压入解析栈stack,后续子节点就挂载到栈顶元素;void 标签不压栈。
ts
// parser.ts 153~155行
onopentagend(end) {
endOpenTag(end)
},
// parser.ts 912~914行
function addNode(node: TemplateChildNode) {
;(stack[0] || currentRoot).children.push(node)
}
// parser.ts 573~592行
function endOpenTag(end: number) {
// 代码省略...
// 将当前已经解析完成的openTag节点,添加到AST父节点的子节点列表中
addNode(currentOpenTag!)
// 代码省略...
// 判断:该标签是void空标签(HTML void标签:br、img、input等,无需闭合)
if (currentOptions.isVoidTag(tag)) {
// void标签直接执行关闭标签逻辑,不压入栈,不需要后续</xxx>闭合
onCloseTag(currentOpenTag!, end)
} else {
// 普通非void标签:把标签压入栈头部,维护嵌套层级,后续遇到闭合标签出栈
stack.unshift(currentOpenTag!)
// 如果是 SVG / MathML 命名空间,通知分词器切换为XML解析模式
// SVG/MathML遵循XML语法,和普通HTML容错解析规则不一样,打开inXML开关
if (ns === Namespaces.SVG || ns === Namespaces.MATH_ML) {
tokenizer.inXML = true
}
}
// 清空当前正在构建的标签对象,准备解析下一个节点
currentOpenTag = null
}
至此:<div> 解析完成,stack[0] = div 元素节点,后续解析到的子节点都会挂载到 div。状态回到 State.Text。
步骤 4:解析文本 hello,挂载到 div 的 children
Tokenizer 继续循环,处于 State.Text,读取普通字符 h e l l o。 直到再次读到 <(</div> 的小于号),此时 sectionStart ~ index 是文本范围,触发 ontext 回调。
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
}
// 普通文本字符:无任何逻辑,继续遍历
}
Parser 的 onText:取栈顶 stack[0] 作为父节点(就是 div),把 TEXT 节点 push 进父节点 children。
ts
// parser.ts 594~614行
function onText(content: string, start: number, end: number) {
// 有嵌套节点 div ,文本挂载到 div 上
const parent = stack[0] || currentRoot
const lastNode = parent.children[parent.children.length - 1]
// 代码省略 ...
parent.children.push({
type: NodeTypes.TEXT,
content,
loc: getLoc(start, end),
})
}
}
此时 AST:div 的 children 中已经存在 {type:TEXT, content:'hello'}。触发<后状态再次切回 State.BeforeTagName,准备解析闭合标签。
步骤 5:解析闭合标签 </div>,栈弹出
在 State.BeforeTagName 读到 /,切换状态到 State.BeforeClosingTagName。
ts
// tokenizer.ts 559~595行
private stateBeforeTagName(c: number): void {
if(){
// 代码省略...
} else if (c === CharCodes.Slash) { // `/`,对应 `</`,闭合标签
this.state = State.BeforeClosingTagName
} else {
//`<`后面跟的字符不属于合法标签开头,退回文本解析
this.state = State.Text
this.stateText(c)
}
}
State.BeforeClosingTagName 读到标签名字母d,切换为 State.InClosingTagName。
ts
// tokenizer.ts 144~149行
function isTagStartChar(c: number): boolean {
return (
(c >= CharCodes.LowerA && c <= CharCodes.LowerZ) ||
(c >= CharCodes.UpperA && c <= CharCodes.UpperZ)
)
}
// tokenizer.ts 616~632行
private stateBeforeClosingTagName(c: number): void {
if (isWhitespace(c)) {
// Ignore
} else if (c === CharCodes.Gt) {
if (__DEV__ || !__BROWSER__) {
this.cbs.onerr(ErrorCodes.MISSING_END_TAG_NAME, this.index)
}
this.state = State.Text
// Ignore
this.sectionStart = this.index + 1
} else {
this.state = isTagStartChar(c)
? State.InClosingTagName
: State.InSpecialComment
this.sectionStart = this.index
}
}
State.InClosingTagName 读到 >,触发 onclosetag 回调,进入 State.AfterClosingTagName。
ts
// tokenizer.ts 633~640行
private stateInClosingTagName(c: number): void {
if (c === CharCodes.Gt || isWhitespace(c)) {
this.cbs.onclosetag(this.sectionStart, this.index)
this.sectionStart = -1
this.state = State.AfterClosingTagName
this.stateAfterClosingTagName(c)
}
}
// tokenizer.ts 641~647行
private stateAfterClosingTagName(c: number): void {
// Skip everything until ">"
if (c === CharCodes.Gt) {
this.state = State.Text
this.sectionStart = this.index + 1
}
}
Parser onclosetag 回调:处理栈的弹出逻辑
- 取出闭合标签名;void 标签直接跳过;
- 从栈顶向下遍历,寻找匹配的开始标签;
- 如果匹配位置 i>0,说明中间有标签未闭合,抛出缺失结束标签错误;
- 将栈顶直到匹配项全部
shift弹出,调用onCloseTag; - 找不到匹配标签则抛出无效闭合标签错误。
ts
// parser.ts 157~179行
onclosetag(start, end) {
// 截取 </xxx> 中间的标签名字符串,例如 </div> → "div"
const name = getSlice(start, end)
// void标签(br/img/input) 不允许出现闭合标签 </br>,直接跳过处理
if (!currentOptions.isVoidTag(name)) {
let found = false // 标记:栈中是否匹配到对应的开启标签
// 从栈顶(最内层标签)开始向后遍历标签栈,寻找匹配的开启标签
for (let i = 0; i < stack.length; i++) {
const e = stack[i]
// 不区分大小写匹配标签名(HTML标签大小写不敏感)
if (e.tag.toLowerCase() === name.toLowerCase()) {
found = true
// i > 0:匹配到的标签**不是栈顶**,说明中间有标签没有闭合,存在标签嵌套错乱
// 例:<div><span></div> 遇到</div>,栈:[span, div],i=1,i>0,span未闭合
if (i > 0) {
// 报错 X_MISSING_END_TAG:存在缺失结束标签的元素,传入未闭合标签的起始位置
emitError(ErrorCodes.X_MISSING_END_TAG, stack[0].loc.start.offset)
}
// j从0循环到i:把从栈顶直到匹配标签为止的所有元素全部弹出
// 例子 stack=[span, div],i=1;j=0弹出span;j=1弹出div
for (let j = 0; j <= i; j++) {
// shift弹出栈头部,!非空断言,状态机保证一定有值
const el = stack.shift()!
// j < i 为true:代表这个元素是被强制提前关闭,本身没有遇到自己的</xxx>
onCloseTag(el, end, j < i)
}
break // 找到匹配标签,跳出外层for循环
}
}
// 遍历完整栈,没有找到任何匹配的开启标签
// 例:源码直接写 </div>,前面没有 <div>
if (!found) {
// X_INVALID_END_TAG:无效闭合标签;backTrack回溯找到 < 的位置用于报错定位
emitError(ErrorCodes.X_INVALID_END_TAG, backTrack(start, CharCodes.Lt))
}
}
},
本例:stack = [div],i=0,直接 shift 弹出 div;解析栈清空,整个<div>hello</div>解析完成。
整体流程总结
State.Text→ 遇到<→BeforeTagName→识别普通标签名→InTagName;- 遇到
>触发onopentagname+onopentagend,构建 element 节点,addNode 加入父节点,节点压入 stack;状态切回Text; - 读取中间文本,遇到下一个
<触发ontext,文本节点挂载到栈顶父元素; <后遇到/进入闭合标签流程,经过BeforeClosingTagName→InClosingTagName;- 遇到
>触发onclosetag,从解析栈弹出对应元素,完成整个标签解析。
关键要点回顾
- stack 栈:维护 DOM 嵌套层级,栈顶就是当前正在解析的父节点;
- 回调驱动:Tokenizer 只管字符扫描、状态流转;AST 节点创建、挂载、栈操作全部由 Parser 的回调完成;
- void 标签(br/img/input)解析完开标签直接关闭,不会压入 stack,不需要闭合标签。