前言
我们在上一篇中已经初步实现了基于 htmlparser2 的 Vue3 模板解析器,我们讲解了有限状态机以及事件驱动在模板解析器中的应用,并且我们实现了简单插值({{message}})的解析,那么在本篇中我们将实现简单元素标签的解析,例如:<div>掘金签约作者:cobyte</div>。
虽然我们是实现简单元素标签的解析,但实际上我们也是在实现HTML解析器的状态机,而实现HTML解析器的状态机是一个复杂的任务,需要对HTML规范有深入的理解,才能够处理各种边界情况和错误。因为 htmlparser2 也是根据 HTML 规范实现的,所以我们首先就要对 HTML 的规范有比较深入的理解。
HTML 解析器的状态迁移
我们前文说了 Vue 模板本质上是一个类 HTML,所以我们在实现 Vue 模板的解析实际上是在实现 HTML 的解析,所以 Vue3 最新的模板解析器才能基于 htmlparser2 进行重构,因为 htmlparser2 是根据 HTML 规范实现的,所以我们首先就要对 HTML 的规范有比较深入的理解,只有对HTML规范有深入的理解,才能够处理各种边界情况和错误。
我们上文中谈到解析 HTML 字符串需要使用到有限状态机,在 WHATWG 发布的关于浏览器解析 HTML 的规范中,就详细阐述了如何解析 HTML 和以及解析 HTML 的状态机的状态应该如何迁移。
根据 WHATWG 发布的关于浏览器解析 HTML 的规范,HTML 解析器的状态机从初始状态开始(data state),在大多数状态下,状态机每次会消费一个字符,这个字符可能会产生各种副作用,并且可能触发状态机切换到新的状态来重新消费当前字符,或者切换到新状态来消费下一个字符,或者保持在同一状态以消费下一个字符。
以我们上述的例子为例 <div>掘金签约作者:Cobyte</div>,在这段 HTML 中第一个字符就是 <,这个字符就会触发状态机切换到新的状态(标签开始状态)来消费下一个字符,而到了 div 这部分,状态机就会保持在同一个状态来消费下一个字符。一些状态具有更复杂的行为,可能会消费多个字符之后才切换到另一个状态。例如,在解析字符引用(如 &)或标签时,状态机可能需要消费多个字符来识别完整的引用或标签。所以在消费 & 字符的时候会触发状态机切换到新的状态来重新消费当前的字符。
HTML 解析器具体的状态迁移 WHATWG 都有发布,以下是该规范中定义在初始状态"数据状态"下状态机的状态迁移过程。
数据状态下的状态迁移

读取下一个字符串:
- 读取到字符 &(U+0026 是 & 的 Unicode 编码)
设置返回状态为"数据状态"(data state),并切换到"字符引用状态"(character reference state)。在 HTML 或 XML 中,&通常用于开始一个字符引用,如
&表示 & 字符。
- 读取到字符 <(U+003C 是 < 的 Unicode 编码)
切换到"标签名状态"(tag open state)。在 HTML 或 XML 中,< 通常用于开始一个标签。
- 读取到空字符(U+0000 是 NULL 字符的 Unicode 编码)
这是一个"意外的空字符解析错误"(unexpected-null-character parse error)。在此情况下,将当前的输入字符(即 NULL 字符)作为字符令牌(character token)发出。在 HTML 或 XML 中,NULL 字符通常是不被允许的。
- 读取到文件的末尾(EOF)
发出一个"文件结束令牌"(end-of-file token)。这表示解析器已经完成了对输入文本的解析。
- 其他
对于任何其他非上述特定字符的输入(如 &、< 或 >),都将被当作普通的文本内容处理。
那么根据我们上面的例子 <div>掘金签约作者:Cobyte</div>,那么读取到第一个字符是 <,所以对应上述 HTML 规范中的第二步中的迁移状态,状态机将切换到"标签名状态"。
标签开始状态下的状态迁移

那么我们接着看"标签开始状态"下状态机的状态迁移过程。
- 读取到一个感叹号(!)
这通常用于DOCTYPE声明或其他标记声明,因此,它切换到"标记声明开始状态"。
- 读取到一个斜杠(/)
这通常表示一个闭合标签的开始,于是,它切换到"闭合标签开始状态"。
- 读取到一个ASCII字母(即标签名的开始)
创建一个新的开始标签令牌,并设置其标签名为空字符串,并切换到"标签名状态"中重新消费该字符。
- 读取到一个问号(?)
这是不期望的,因此,解析器会报告一个"意外的问号代替标签名"的解析错误。接着,它会创建一个注释令牌(尽管这里并不真正是一个注释的开始),并设置其数据为空字符串。然后,它会重新消费这个问号字符,并进入"伪注释状态",这是一个错误处理状态,用于处理不规范的注释或类似情况。
- 遇到文件结束(EOF)
如果在尝试读取标签名的过程中遇到文件结束(EOF),这意味着在找到标签名之前就到达了文件的末尾。解析器会报告一个"在标签名前遇到EOF"的解析错误。然后,它会发出一个<(LESS-THAN SIGN)字符令牌(可能是为了指示之前可能有一个未被正确处理的"<"字符),并发出一个"文件结束"令牌来指示解析的结束。
- 其他
如果读取到的字符既不是预期的标签名开始字符,也不是文件结束,那么这个字符被视为无效的开始字符。解析器会报告一个"标签名第一个字符无效"的解析错误。然后,它会发出一个<字符令牌,并重新进入"数据状态"以继续解析文件的其余部分,可能是作为文本数据或寻找下一个标签的开始。
那么根据我们上面的例子 <div>掘金签约作者:Cobyte</div>,在上述的状态迁移的过程中它将会读取到 d,那么状态机将切换到 "标签名状态"。
标签名状态下的状态迁移

那么我们接着看"标签名状态"下状态机的状态迁移过程。
- 读取到制表符(tab)
- 读取到换行符(LF)
- 读取到换页符(FF)
- 读取到空格
当读取到上述字符时,将切换到"属性名称开始"的状态
- 读取到斜杠(/)
当在标签名之后遇到它时,它可能表示一个自闭合标签(如
<img />),因此,解析器会切换到"自闭合开始标签状态"。
- 读取到大于号(>)
切换到"数据状态",并且发射当前解析到的标签名。
- 读取到ASCII大写字母
应该将其转换为小写,这是因为HTML和XML标签名是不区分大小写的。
- 读取到 NULL字符
当解析器遇到它时,会报告一个"意外的NULL字符解析错误",并用一个替换字符(U+FFFD)来替换它。
- 读取到 EOF(文件结束符)
当解析器在标签名内部遇到它时,会报告一个"标签中的EOF解析错误",并发出一个"文件结束令牌"来通知应用程序解析已经完成。
- 其他
对于任何其他字符,解析器都会将其附加到当前的标签名中。
那么根据我们上面的例子 <div>掘金签约作者:Cobyte</div>,在上述的状态迁移的过程中它将会读取到 掘,那么状态机将切换到 "初始状态"。那么我们再回到上文中的"初始状态"的状态迁移情况中,将会切换到文本状态,直到读取到 <,又会切换到"标签开始状态",接着在"标签开始状态"中读取到 /,则切换到"结束标签开始状态"。
结束标签开始状态下的状态迁移

那么我们接着看"结束标签开始状态"下状态机的状态迁移过程。
- 读取到ASCII字母
如果输入字符是一个ASCII字母(a-z或A-Z),则创建一个新的结束标签令牌(token),并将其标签名设置为空字符串。然后,解析器会重新进入"标签名状态"以便继续读取标签名。
- 读取到大于号(>)
如果输入字符是大于号(>),这表示结束标签没有提供标签名,这是一个"missing-end-tag-name"解析错误。在这种情况下,解析器会切换到"数据状态",意味着它将开始解析页面内容而不是标签。
- 读取到文件结束符(BOF)
如果在读取标签名之前遇到文件结束符(EOF),则这是一个"eof-before-tag-name"解析错误。在这种情况下,解析器会发出一个U+003C LESS-THAN SIGN(<)字符令牌、一个U+002F SOLIDUS(/)字符令牌和一个end-of-file(文件结束)令牌。这些令牌会告知应用程序或浏览器此处存在错误,并且文件已结束。
- 其他字符
如果输入字符不是ASCII字母、大于号或文件结束符,这表示它不是一个有效的标签名起始字符,因此是一个"invalid-first-character-of-tag-name"解析错误。在这种情况下,解析器会创建一个注释令牌(comment token),其数据为空字符串。然后,它会重新进入"bogus comment state"(伪注释状态),这通常意味着解析器将尝试从当前位置开始解析一个注释,即使这可能不是一个真正的注释。
那么根据我们上面的例子 <div>掘金签约作者:Cobyte</div>,在读取到 / 字符后,切换到"结束标签开放状态",那么就将会读取到 d,也就是重新进入"标签名状态"。进去"标签名状态"后,状态就迁移到第 10,然后消费完 div 三个字符后就读取到 >,然后就切换到初始状态"数据状态"。
至此我们根据 HTML 规范对解析简单 HTML 元素的状态迁移的过程就进行了详细的分析了,我们了解了相关的状态迁移规范后,就可以很容易编写对应的状态机实现代码了。
实现 HTML 解析器的有限状态机
在 HTML 规范中对应解析 HTML 的时候的状态机的状态如何迁移都有对应的说明,我们只需要根据规范就可以很容易编写对应的有限状态机代码。如果有了解过 Vue3.4 之前版本的编译模块就知道在 Vue3.4 之前的代码并没有显式地使用有限状态机(FSM)的框架或术语,并且它也不是严格按照上文的 HTML 解析规范进行解析的,但它的实现原理与有限状态机是一致的,它根据模板源代码的前缀来判断当前应该处于哪个状态,并执行相应的解析动作。这个过程一直持续到达到模板的末尾,最终返回一个包含所有解析出节点的数组。
而在 vue3.4 之后编译模块底层架构基于 htmlparser2 进行了重构,而 htmlparser2 是通过显式实现有限状态机架构的。即需要定义状态机的状态,通过上篇文章我们可以知道我们的实现的解析方案(也就是 htmlparser2)是使用 while 循环遍历模板每个字符,然后在循环中,使用 charCodeAt 方法获取当前字符的 Unicode 编码,并根据当前状态调用相应的状态处理方法。那么根据这方案和上文中的 HTML 解析规范我们可以定义出解析 <div>掘金签约作者:Cobyte</div> 的以下状态:
-
标签名前状态:表示解析器当前位于一个标签名的前面,但还未开始处理标签名(也就是读取到字符
<时)。 -
标签名状态:表示解析器当前正在处理一个标签的名称。
-
属性名前状态:表示解析器当前位于一个属性名的前面,但还未开始处理属性名(虽然我们例子中的标签并没有属性,但我们还是要有这个状态)。
-
结束标签名前状态:表示解析器当前位于一个结束标签名的前面,但还未开始处理结束标签名。
-
结束标签名状态:表示解析器当前正在处理一个结束标签的名称。
-
结束标签名后状态:表示解析器已经处理完一个结束标签的名称,并且已经离开了该标签。
为什么我们这么设置状态呢?因为我们是通过一个一个字符进行处理的,比如我们读取到了 < 字符,我们并不能理所当然地认为下一个状态就是标签名了,因为有可能下一个字符是 / 亦或其他字符,所以我们就要设置一个 标签名前状态 来判断下一个字符是不是就是标签名,如果下一个字符是 / 那么我们就要进入 结束标签名前状态,因为我们同样要判断下一个字符是不是结束标签名。
那么我们就可以在上一篇的代码基础上定义上述新的状态代码:
diff
const State = {
'Text': 1, // 文本状态
'InterpolationOpen': 2, // 插值开始状态
'Interpolation': 3, // 插值状态
'InterpolationClose': 4, // 插值结束状态
+ 'BeforeTagName': 5, // 标签名前状态
+ 'InTagName': 6, // 标签名状态
+ 'BeforeAttrName': 7, // 属性名前状态
+ 'BeforeClosingTagName': 8, // 结束标签名前状态
+ 'InClosingTagName': 9, // 结束标签名状态
+ 'AfterClosingTagName': 10 // 结束标签名后状态
}
接着我们在 Tokenizer 类的 parse 方法中添加对应的状态处理方法。
diff
class Tokenizer {
parse(input) {
this.buffer = input
// 使用 while 循环遍历 buffer 中的每个字符,直到达到字符串的末尾
while (this.index < this.buffer.length) {
// 使用 charCodeAt 方法获取当前索引 index 处的字符的 Unicode 编码
const c = this.buffer.charCodeAt(this.index)
// 根据当前状态 state 使用 switch 语句进行不同的处理
switch (this.state) {
// 文本状态
case State.Text: {
this.stateText(c)
break
}
+ // 标签名前状态
+ case State.BeforeTagName: {
+ this.stateBeforeTagName(c)
+ break
+ }
+ // 标签名状态
+ case State.InTagName: {
+ this.stateInTagName(c)
+ break
+ }
+ // 结束标签名前状态
+ case State.BeforeClosingTagName: {
+ this.stateBeforeClosingTagName(c)
+ break
+ }
+ // 结束标签名状态
+ case State.InClosingTagName: {
+ this.stateInClosingTagName(c)
+ break
+ }
}
// 在每次循环结束时,无论状态是否改变,都将 index 增加 1,以移动到字符串中的下一个字符
this.index++
}
}
}
我们的有限状态机初始状态是文本状态,所以我们要在文本状态的处理方法中进行判断是否读取到了 < 字符,然后并更新状态为 标签名前状态,准备解析标签名。代码如下:
diff
// 文本状态处理方法
stateText(c) {
+ // 判断是否是 <
+ if (c === CharCodes.Lt) {
+ // 进入标签名前状态
+ this.state = State.BeforeTagName
+ // 设置当前状态的初始字符位置
+ this.sectionStart = this.index
+ } else if (c === this.delimiterOpen[0]) { // 判断是否是插值开始状态
// 进入插值开始状态
this.state = State.InterpolationOpen
this.delimiterIndex++
}
}
我们上述代码中有一个 CharCodes 的变量,这个变量定义了一些常用的字符编码,用于在解析过程中识别特定的字符,例如上述代码中的 < 字符。
javascript
// 定义一些常用的字符编码,用于在解析过程中识别特定的字符
const CharCodes = {
'Tab': 0x9, // "\t"
'NewLine': 0xa, // "\n"
'FormFeed': 0xc, // "\f"
'CarriageReturn': 0xd, // "\r"
'Lt': 0x3c, // "<"
'Gt': 0x3e, // ">"
'Space': 0x20, // " "
'Slash': 0x2f, // "/"
'UpperA': 0x41, // "A"
'LowerA': 0x61, // "a"
'UpperZ': 0x5a, // "Z"
'LowerZ': 0x7a, // "z"
}
我们在文本处理方法里将状态更改为 标签名前状态,那么我们下一个字符就进入 标签名前状态 的 stateBeforeTagName 方法中进行处理了,检查当前字符是否是标签名的开始字符(HTML只允许ASCII字母字符(a-z和A-Z)作为标签名的开头)或 '/'(表示可能是闭合标签),然后根据情况更新状态。
stateBeforeTagName 方法的代码如下:
javascript
// 标签名前状态
stateBeforeTagName(c) {
// 判断是否标签名
if (isTagStartChar(c)) {
// 设置当前状态的初始字符位置
this.sectionStart = this.index
// 更新状态为标签名状态
this.state = State.InTagName
} else if (c === CharCodes.Slash) { // '/'表示可能是闭合标签
// 更新状态为结束标签名前状态
this.state = State.BeforeClosingTagName
}
}
isTagStartChar 函数实现如下:
javascript
// HTML只允许ASCII字母字符(a-z和A-Z)作为标签名的开头
function isTagStartChar(c) {
return (
(c >= CharCodes.LowerA && c <= CharCodes.LowerZ) ||
(c >= CharCodes.UpperA && c <= CharCodes.UpperZ)
)
}
那么 <div>掘金签约作者:Cobyte</div> 标签字符在解析完 < 之后,很明显下一个状态就进入 标签名状态 了。在 标签名状态中 我们需要做的一个重要事情就是判断是否标签名结束了,当遇到 >、/ 或空白字符则结束标签名状态了。
标签名状态 处理方法 stateInTagName 的实现如下:
javascript
// 标签名状态
stateInTagName(c) {
// 判断是否结束标签名状态
if (isEndOfTagSection(c)) {
this.handleTagName(c)
}
}
判断是否结束标签名状态 isEndOfTagSection 方法实现如下:
javascript
// 是否空白
function isWhitespace(c) {
return (
c === CharCodes.Space || // 空白
c === CharCodes.NewLine || // "\n" 换行
c === CharCodes.Tab || // "\t" tab 键空白
c === CharCodes.FormFeed || // "\f" 换页符
c === CharCodes.CarriageReturn // "\r" 回车符
)
}
function isEndOfTagSection(c) {
// 遇到 / 或者 > 或者空白
return c === CharCodes.Slash || c === CharCodes.Gt || isWhitespace(c)
}
如果标签名状态结束了,比如 <div> 在标签名状态下读取完 div 之后遇到 > 则是标签名状态结束了,则调用 handleTagName 方法处理标签名结束时的逻辑,包括触发回调和更新状态。代码实现如下:
javascript
// 处理标签名结束时的逻辑
handleTagName(c) {
// 触发标签名处理事件回调函数,输出标签名的 AST 数据节点
this.cbs.onopentagname(this.sectionStart, this.index)
// 重置当前状态的初始字符位置
this.sectionStart = -1
// 更改状态为属性名前状态
this.state = State.BeforeAttrName
// 将根据当前状态(即属性名称之前的状态)和传入的参数来继续解析
this.stateBeforeAttrName(c)
}
在 handleTagName 方法中会触发标签名处理事件回调函数,输出标签名的 AST 数据节点,同时 this.sectionStart 重置起始位置标志,更改状态为 属性名前状态,将根据当前状态(即属性名称之前的状态)和传入的参数来继续解析。从这里我们可以印证 HTML 规范中说到的HTML解析器的状态机实现过程中字符消费与状态转换的规则,在大多数状态下,状态机会消费一个字符,这个字符可能会产生各种副作用,并且可能触发状态机切换到新的状态来重新消费当前字符。这里就是在 标签名状态 中消费到 > 字符后会切换到 属性名前状态 继续消费 > 字符。
接着我们来看看 this.cbs.onopentagname 方法的实现。
javascript
// 开始标签临时存储变量
let currentOpenTag = null
const tokenizer = new Tokenizer({
// 省略...
onopentagname(start, end) {
// 获取标签名称,start 和 end 这两个参数表示标签名称在 Tokenizer 的缓冲区中的起始和结束位置
const name = tokenizer.buffer.slice(start, end)
// 开始标签名 AST 先存储到一个临时变量中
currentOpenTag = {
type: NodeTypes.ELEMENT,
tag: name, // 标签名
tagType: ElementTypes.ELEMENT, // 标签的类型
props: [], // 存储属性
children: [] // 存储子元素
}
},
})
onopentagname 方法是一个回调事件函数,在标签名状态结束的时候生成标签名 AST 节点数据,并将其存储在 currentOpenTag 变量中,以便后续处理标签的属性或子元素。
根据上述 handleTagName 方法最后状态将切换到 属性名前状态,也就是 stateBeforeAttrName 方法,具体逻辑就是 <div> 在标签名状态下读取完 div 之后遇到 > 字符则是标签名状态结束了,则调用 handleTagName 方法处理标签名结束时的逻辑,即开始标签名的 AST 节点先存储到一个临时变量中,并且把状态设置为 属性名前状态,并切换到 属性名前状态 方法中重复消费 > 字符,stateBeforeAttrName 方法代码如下:
javascript
// 属性名前状态
stateBeforeAttrName(c) {
// 如果当前字符是 >
if (c === CharCodes.Gt) {
// 触发回调事件处理标签名
this.cbs.onopentagend()
// 切换到初始状态文本状态
this.state = State.Text
// 设置当前状态的初始字符位置
this.sectionStart = this.index + 1
}
}
如果当前字符是 > 则触发回调事件处理标签名,切换到初始状态文本状态,设置起始位置标志。onopentagend 方法的实现如下:
javascript
// 元素栈
const stack = []
const tokenizer = new Tokenizer({
// 省略...
// 处理标签名
onopentagend() {
// 将当前标签(currentOpenTag)添加到当前 AST 节点数据中
addNode(currentOpenTag)
// 将当前标签添加到stack元素栈的开始位置,stack数组用于存储元素栈,以便在后续需要时能够正确地嵌套标签。
stack.unshift(currentOpenTag)
// 重置
currentOpenTag = null
},
})
// 添加 AST 节点
function addNode(node) {
// 判断 stack 数组的第一个元素是否存在,如果存在,就将新节点添加到这个元素的children数组中;如果不存在,就将新节点添加到currentRoot的children数组中
;(stack[0] || currentRoot).children.push(node)
}
在处理标签名 AST 节点的时候主要做了两件事,一是将当前标签(currentOpenTag)添加到当前 AST 节点数据中,二是将当前标签 AST 节点数据添加到stack元素栈的开始位置。
关于元素栈
某些状态的确切行为依赖于当前的插入模式 和元素栈 的状态。例如,当解析器遇到如 </script> 这样的结束标签时,它需要根据当前的插入模式和元素栈来确定是否应该关闭该元素。例如,如果当前不在脚本元素的内部(即元素栈中不包含 <script>),那么 </script> 标签可能会被忽略或作为普通文本处理。这种机制确保了HTML文档的解析既灵活又健壮,能够处理各种复杂的文档结构和潜在的错误。上述代码中的 stack 变量则存储元素栈。
关于插入模式
在HTML解析的上下文中,插入模式(Insertion Mode)是指HTML解析器在处理文档时所处的特定状态或模式。这个模式决定了解析器在遇到不同类型的HTML元素或标记时应该如何处理它们。不同的插入模式会影响解析器对标签的识别、元素的嵌套规则、以及如何处理某些特定的结束标签(如未明确关闭的标签)。
HTML5规范定义了多种插入模式,包括但不限于:
-
初始模式(Initial mode):这是解析器开始解析文档时的模式。在读取到第一个声明、HTML元素、或XML声明(对于XHTML文档)之后,解析器会切换到其他模式。
-
之前头部模式(Before head mode):在解析到
<head>元素之前,但已经确定了文档类型后的模式。这个模式允许处理如<title>或<meta>这样的头部元素。 -
头部模式(In head mode):在
<head>元素内部时的模式。这个模式允许处理头部特有的元素,如<link>、<style>等。 -
主体模式(In body mode):在
<body>元素内部时的模式。这是最常见的模式,因为大多数HTML内容都位于<body>标签内。这个模式处理文档的主体内容,包括文本、块级元素、内联元素等。 -
框架集模式(In frameset mode):这是一个较少使用的模式,用于处理
<frameset>元素。在现代Web开发中,<frameset>已被CSS布局技术所取代。 -
之后主体模式(After body mode):在解析完
<body>元素的内容,但尚未遇到</html>结束标签时的模式。这个模式主要用于处理在</body>之后但仍在</html>之前的任何内容。 -
在表格内部模式(Inside table mode)、在表格行内部模式(In table row mode)、在表格单元格内部模式(In table cell mode)等:这些模式用于处理表格元素(如
<table>、<tr>、<td>)的嵌套和结束。
在 属性名前状态 的处理方法 stateBeforeAttrName 中设置了标签名 AST 节点数据后,解析器的状态便又切换到了初始状态即 文本状态,在文本状态中就会一直消费 <div>掘金签约作者:Cobyte</div> 这段 HTML 中的 掘金签约作者:Cobyte 字符,直到遇到 </div> 中的 < 字符。
那么我们就要在 文本状态 的 stateText 处理方法中处理 掘金签约作者:Cobyte 字符,具体实现如下:
diff
// 文本状态处理方法
stateText(c) {
// 判断是否是 <
if (c === CharCodes.Lt) {
+ // 当前字符的索引位置比当前状态的初始字符位置要大的
+ if (this.index > this.sectionStart) {
+ // 调用文本回调事件处理文本 AST 节点数据
+ this.cbs.ontext(this.sectionStart, this.index)
+ }
// 更改状态为标签名前状态
this.state = State.BeforeTagName
// 设置当前状态的初始字符位置
this.sectionStart = this.index
}
}
在 stateText 方法再次遇到 < 字符,则需要判断如果当前字符的索引位置比当前状态的初始字符位置要大,就调用文本回调事件处理文本 AST 节点数据。文本回调事件函数 ontext 的实现如下:
javascript
const tokenizer = new Tokenizer({
// 省略...
// 处理文本
ontext(start, end) {
onText(getSlice(start, end))
},
})
// 根据索引截取字符
function getSlice(start, end) {
return currentInput.slice(start, end)
}
// 添加文本 AST 节点数据
function onText(content) {
// 首先确定文本的父节点,这个父节点是当前元素栈 stack 的第一个元素,如果没有元素栈,则使用 currentRoot 作为父节点
const parent = stack[0] || currentRoot
parent.children.push({
type: NodeTypes.TEXT,
content
})
}
在 ontext 方法内部,调用 onText 函数,并将通过 getSlice 函数获取到的文本片段作为参数传递,在 onText 函数中添加文本 AST 节点数据,首先确定文本的父节点,这个父节点是当前解析栈 stack 的第一个元素,如果没有解析栈,则使用 currentRoot 作为父节点。然后,它创建一个新的 AST 节点,节点类型是 NodeTypes.TEXT,内容是 content,并将这个节点添加到父节点的 children 数组中。
从文本状态处理方法 stateText 中我们可以知道当读取到 < 符号后有限状态机的状态就切换到 标签名前状态,然后在 标签名前状态 的处理方法 stateBeforeTagName 中读取到 / 状态就进入 结束标签名前状态 中,因为我们的例子中的 </div> 标签的 < 符号的下一个字符就是 /。stateBeforeTagName 方法我们上面已经实现了,下面我们实现结束标签名前状态的 stateBeforeClosingTagName 方法,代码如下:
javascript
// 结束标签名前状态
stateBeforeClosingTagName(c) {
// 判断是否标签名
if (isTagStartChar(c)) {
// 切换到结束标签名状态
this.state = State.InClosingTagName
// 设置当前状态的初始字符位置
this.sectionStart = this.index
}
}
我们可以看到结束标签名前状态的 stateBeforeClosingTagName 方法实现很简单,判断当前字符如果是符合标签名那么就切换到 结束标签名状态,并设置当前状态的初始字符位置。接着我们来看结束标签名状态 stateInClosingTagName 方法的实现,代码如下:
javascript
// 结束标签名状态
stateInClosingTagName(c) {
// 判断当前字符是 > 或者是空白
if (c === CharCodes.Gt || isWhitespace(c)) {
// 执行结束标签回调事件处理函数
this.cbs.onclosetag(this.sectionStart, this.index)
// 重置当前状态的初始字符位置
this.sectionStart = -1
// 切换到结束标签名后状态
this.state = State.AfterClosingTagName
// 在结束标签名后状态中继续消费当前字符
this.stateAfterClosingTagName(c)
}
}
在结束标签名状态函数 stateInClosingTagName 中判断当前字符如果是 > 或者是空白则执行结束标签回调事件处理函数 onclosetag,主要判断当前解析到的标签是否有效,然后重置当前状态的初始字符位置,接着切换到结束标签名后状态,执行 stateAfterClosingTagName 函数,即在结束标签名后状态中继续消费当前字符。
接着我们先来看看 onclosetag 回调函数:
javascript
const tokenizer = new Tokenizer({
// 省略...
// 处理关闭标签函数
onclosetag(start, end) {
// 获取关闭标签的名称
const name = getSlice(start, end)
// 是否找到和当前关闭标签一样的标签
let found = false
// 遍历 stack
for (let i = 0; i < stack.length; i++) {
const e = stack[i]
// 查找与当前关闭标签相匹配的标签
if (e.tag.toLowerCase() === name.toLowerCase()) {
// 如果找到匹配的标签,设置found为true
found = true
if (i > 0) {
// 如果找到匹配的标签,并且这个标签不是栈中的第一个标签(意味着在它之前还有其他未闭合的标签),则发出一个错误,表示缺少结束标签
throw new Error(`缺少结束标签:${name}`)
}
for (let j = 0; j <= i; j++) {
// 从栈中移除所有直到(包括)当前匹配的标签
const el = stack.shift()
}
break
}
}
// 如果在栈中没有找到匹配的标签,则发出一个错误,表示无效的结束标签
if (!found) {
throw new Error(`无效标签:${name}`)
}
}
})
onclosetag 回调函数的主要目的是处理关闭标签的逻辑,确保标签的正确闭合,以及处理可能的错误。详细解析如下:
首先使用 getSlice(start, end) 函数获取从开始位置 start 到结束位置 end 之间的字符串,即标签名。然后遍历 stack(元素栈),查找与当前关闭标签相匹配的标签。如果找到匹配的标签,设置 found 为 true。如果找到匹配的标签,并且这个标签不是元素栈中的第一个标签(意味着在它之前还有其他未闭合的标签),则发出一个错误,表示缺少结束标签。接着从元素栈中移除所有直到(包括)当前匹配的标签。最后如果在元素栈中没有找到匹配的标签,则发出一个错误,表示无效的结束标签。
在上述结束标签名状态函数 stateInClosingTagName 中最后会执行 stateAfterClosingTagName 函数,即在结束标签名后状态中继续消费当前字符,而 stateAfterClosingTagName 函数的需要做的事情很简单,代码如下:
javascript
// 结束标签名后状态
stateAfterClosingTagName(c) {
// 如果当前字符是 >
if (c === CharCodes.Gt) {
// 恢复初始状态
this.state = State.Text
// 设置当前状态的初始字符位置,这是为了标记新文本节的开始
this.sectionStart = this.index + 1
}
}
通过代码我们可以知道 stateAfterClosingTagName 函数主要处理结束标签后的状态转换,确保解析器能够正确地解析接下来的文本或其他标签。
三种联合类型解析
至此我们还需要进行以下修改:
diff
const tokenizer = new Tokenizer({
// 省略...
// 处理插值
oninterpolation(start, end) {
// 省略...
// 将新创建的插值节点添加到 currentRoot.children 数组中
- currentRoot.children.push(node)
+ addNode(node)
}
})
以及处理插值前面还有文本的情况,例如:掘金签约作者:{{cobyte}}:
diff
class Tokenizer {
// 省略...
// 插值开始状态处理方法
stateInterpolationOpen(c) {
// 检查当前字符是否与 delimiterOpen 中对应的字符匹配
if (c === this.delimiterOpen[this.delimiterIndex]) {
// 已经匹配了 delimiterOpen 中的所有字符
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 {
// 继续增加 delimiterIndex 以匹配更多的字符
this.delimiterIndex++
}
}
}
}
这样我们就可以测试编译 <div>掘金签约作者:{{cobyte}}</div> 三种联合类型了,标签、文本、插值。最终编译结果打印如下:

总结
本篇围绕 Vue3 模板解析器中简单元素标签的解析展开,但本质上是深入 HTML 解析规范与有限状态机的落地实践。我们依照 WHATWG 规范,从"数据状态"到"标签开始状态""标签名状态"再到"结束标签名状态",细致梳理了状态迁移的完整路径,为后续的代码实现奠定了坚实理论基础。
在实现层面,我们在原有插值解析器基础上扩展了状态定义和处理函数,将标签名前、标签名中、属性名前、结束标签名前等多个状态逐一落地,并结合 onopentagname、ontext、onclosetag 等事件回调构建 AST 节点。同时引入元素栈(stack)管理标签嵌套关系,确保父子层级正确,错误处理机制也开始显露雏形。
最终,三种联合类型------标签、文本与插值------在同一模板中协同解析,编译结果如预期输出,证明我们的状态机设计具备良好扩展性。这不仅验证了"Vue 模板是类 HTML"这一前提,也展示了基于规范驱动开发的稳健性。