解析器
我们初步讨论了解析器(parser)的工作原理,知道了解析器本质是一个状态机。但我们也曾提到,正则表达式其实也是一个状态机。因此在编写 parser
的时候,利用正则表达式能够让我们少写不少代码。本篇文章我们将更多地利用正则表达式来实现 HTML 解析器。
一个完善的 HTML 解析器远比想象中复杂。我们知道,浏览器会对 HTML 文本进行解析,那么它是如何做的呢?其实关于 HTML 文本的解析,是由规范可循的,即 WHATWG
关于 HTML 的解析规范,其中定义了完整的错误处理和状态机的状态迁移流程,还提及了一些特殊的状态,例如 DATA
、CDATA
、RCDATA
、RAWTEXT
等。那么,这些状态有什么含义呢?它们对解析器有哪些影响呢?什么是 HTML 实体,以及 Vue.js 模板解析器需要如何处理 HTML 实体呢?
文本模式
文本模式指的是解析器在工作时所进入的一些特殊状态,在不同的特殊状态下,解析器对文本的解析行为会有所不同。具体来说,当解析器遇到一些特殊标签时,会切换模式,从而影响其对文本的解析行为。这些特殊标签是:
<title>
标签、<textarea>
标签,当解析器遇到这两个标签时,会切换到RCDATA
模式;<style>
、<xmp>
、<iframe>
、<noembed>
、noframes>
、<noscript>
等标签,当解析器遇到这些标签时,会切换到RAWTEXT
模式;- 当解析器遇到
<![CDATA[
字符串时,会进入CDATA
模式。
解析器的初始模式是 DATA 模式。对于 Vue.js 的模板 DSL 来说,模板中不允许出现 <script>
标签,因此 Vue.js 模板解析器在遇到 <script>
标签时也会切换到 RAWTEXT
模式。
解析器的行为会因工作模式的不同而不同。WAHTWG
规范的第 13.2.5.1 节给出了初始模式下解析器的过程流程。
https://html.spec.whatwg.org/multipage/parsing.html#data-state
在默认的 DATA
模式下,解析器在遇到字符 < 时,会切换到标签开始状态(tag open state)
。换句话说,在该模式下,解析器能够解析标签元素。当解析器遇到字符 &
时,会切换到字符引用状态(character reference state)
,也称 HTML 字符实体状态。也就是说,在 DATA
模式下,解析器能够处理 HTML 字符实体。
我们再来看看当解析器处理 RCDATA
状态时,它的工作状态如何。
由图可知,当解析器遇到字符 < 时,不会切换到标签开始状态,而回切换到 RCDATA less-than sign state
状态。下图给出了 RCDATA less-than sign state
状态下解析器的工作方式。
由图可知 RCDATA less-than sign state
状态下,如果解析器遇到字符 /,则直接切换到到 RCDATA
的结束标签状态,即 RCDATA end tag open state
。否则会将当前字符 < 作为普通字符处理,然后继续处理后面的字符。由此可以,在 RCDATA
状态下,解析器不能识别标签元素,这其实间接说明了在 <textarea>
内可以将字符 < 作为普通文本,解析器并不会认为字符 < 是标签开始的标志。
<textarea>
<div>asdf</div>asdfasdf
</textarea>
html
在上面这段 HTML代码中,<textarea>
标签内存在一个 <div>
标签。但解析器并不会把 <div>
解析为标签元素,而是作为普通文本处理。但是,由 13.2.5.2 RCDATA state
可知,在 RCDATA
模式下,解析器仍然支持 HTML 实体。因为当解析器遇到字符 & 时,会切换到字符引用状态。如下面的代码所示:
<textarea>©</textarea>
html
浏览器在渲染器这段 HTML 代码时,会在文本框内展示字符 ©
。
解析在 RAWTEXT
模式下的工作方式与在 RCDATA
模式下类似。唯一不同的是,在 RAWTEXT
模式下,解析器将不再支持 HTML 实体。下图给出了 WAHTWG
规范中第 13.2.5.3
节中所定义的 RAWTEXT
模式下状态机的工作方式。
对比 13.2.5.3 RAWTEXT state
和 13.2.5.2 RCDATA state
可知,RAWTEXT
模式的确不支持 HTML 实体。在该模式下,解析器会将 HTML 尸体字符作为普通字符串处理。Vue.js 的单文件组件的解析器在遇到 <script>
标签时就会进入 RAWTEXT
模式,这时他会把 <script>
标签内的内容全部作为普通文本处理。
CDATA
模式在 RAWTEXT
模式的基础上更进一步。下图给出了 WHATWG
规范第 13.2.5.69
节中所定义的 CDATA
模式下状态机的工作方式。
在 CDATA
模式下,解析器会把任何字符都作为普通字符处理,直到遇到 CDATA
的结束标志为止。
实际上,在 WHATWG
规范中还定义了 PLAINTEXT
模式,该模式与 RAWTEXT
模式类似。不同的是,解析器一旦进入 PLAINTEXT
模式,将不会再退出。另外,Vue.js 的模板 DSL 解析器是用不到 PLAINTEXT
模式的,因此我们也不会过多介绍它。
下图汇总了不同的模式及各自的特性。
模式 | 能否解析标签 | 是否支持 HTML 实体 |
---|---|---|
DATA | 能 | 是 |
RCDATA | 否 | 是 |
RAWTEXT | 否 | 否 |
CDATA | 否 | 否 |
除了表中列出的特性之外,不同的模式还会影响解析器对于终止解析的判断,后面会继续讨论。另外,后续编写解析器代码时,我们会将上述模式定义为状态表。
const TextModes = {
DATA: 'DATA',
RCDATA: 'RCDATA',
RAETEXT: 'RAETEXT',
CDATA: 'CDATA'
}
js
递归下降算法构造模板 AST
递归下降分析法是确定的自上而下分析法,这种分析法要求文法是LL(1)文法。 为每个非终结符编制一个递归下降分析函数,每个函数名是相应的非终结符,函数体则是根据规则右部符号串的结构和顺序编写。 子程序相互递归调用。
从本节开始,我们将着手实现一个更加完善的模板解析器。解析器的基本架构模型如下:
// 定义文本模式,作为状态表
const TextModes = {
DATA: 'DATA',
RCDATA: 'RCDATA',
RAETEXT: 'RAETEXT',
CDATA: 'CDATA'
}
// 解析器函数,接收模板作为参数
function parse(str) {
// 定义上下文对象
const context = {
// source 是模板内容,用于解析过程中进行消费
source: str,
// 解析器当前处于文本模式,初始模式为 DATA
mode: TextModes.DATA
}
// 调用 parseChildren 函数开始解析,它返回解析后得到的子节点
// parseChildren 函数接收两个参数
// 第一个参数是上下文对象 context
// 第二个参数是由父代节点构成的节点栈,初始时栈为空
const nodes = parseChildren(context, [])
// 解析器返回 Root 根节点
return {
type: 'Root',
// 使用 nodes 作为根节点的 children
children: nodes
}
}
js
上面这段代码中,我们首先定义了一个状态表 TextModes
,它用来描述预定义的文本模式。然后,我们定义了 parse 函数,即解析器函数,在其中定义了上下文对象 context
,用来维护解析程序执行程序中的各种状态。接着,调用 parseChildren
函数进行解析,该函数会返回解析后得到的子节点,并使用这些子节点作为 children
来创建 Root 根节点。最后,parse 函数返回根节点,完成模板 AST 的构建。
这段代码的思路与我们之前讲述的关于模板 AST 的构建思路有所不同。在之前的代码中,我们首先对模板内容进行标记化得到一系列 Token,然后根据这些 Token 构建模板 AST。实际上,创建 Token 与构造模板 AST 的过程可以同时进行,因为模板和模板 AST 具有同构特性。
另外,在上面这段代码中,parseChildren
函数是整个解析器的核心。后续我们会递归地调用它来不断地消费模板内容。parseChildren
函数会返回解析后得到的子节点。假设有如下模板。
<p>1</p>
<p>2</p>
html
1
2
上面这段模板有两个根节点,即两个 <p>
标签。parseChildren
函数在解析这段模板后,会得到由这两个 <p>
节点组成的数组:
[
{ type: 'Element', tag: 'p', children: [/*...*/] },
{ type: 'Element', tag: 'p', children: [/*...*/] },
]
js
之后,这个数组将作为 Root 根节点的 children。
- 第一个参数:上下文对象 context;
- 第二个参数:由父代节点构成的栈,用于维护节点间的父子级关系。
parseChildren
函数本质也是一个状态机,该状态机有多少种状态取决于子节点的类型数量。在模板中,元素的子节点可以是以下几种:
- 标签节点,例如
<div>
。 - 文本插值节点,例如
{{ val }}
。 - 普通文本节点,例如:text。
- 注释节点,例如
<!---->
。 CDATA
节点,例如<![CDATA[ xxx ]]>
。
在标准的 HTML 中,节点的类型将会更多,例如 DOCTYPE
节点等。为了降低复杂度,我们仅考虑上述类型的节点。
我们可以把上图展示的状态迁移过程总结如下:
- 当遇到 < 时,进行临时状态
- 如果下一个字符匹配正则
/a-z/i
,则认为是一个标签节点,于是调用parseElement
函数完成标签解析。注意正则表达式/a-z/i
中的 i,这里是忽略大小写(case-insensitive)。 - 如果字符串以
<!--
开头,则认为是一个注释节点,于是调用parseComment
函数完成注释节点的界限。 - 如果字符串以
<![CDATA[
开头,则认为这是一个CDATA
节点,于是调用parseCDATA
函数完成CDATA
节点的解析。
- 如果下一个字符匹配正则
- 如果字符串以
{{
开头,则认为这是一个插值节点,于是调用parseInterpolation
函数完成插值节点的解析。 - 其他情况,都作为普通文本,调用
parseText
函数完成文本节点的解析。
function parseChildren(context, ancestors) {
// 定义 nodes 数组存储子节点,它将作为最终的返回值
let nodes = []
// 从上下文对象中取得当前状态,包括模式 mode 和模板内容 source
const { mode, source } = context
// 开始 while 循环,只要满足条件就会一直对字符串进行解析
while (!isEnd(context, ancestors)) {
let node
// 只有 DATA 模式和 RCDATA 模式才支持插值节点的解析
if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
// 只有 DATA 模式才支持标签节点的解析
if (mode === TextModes.DATA && source[0] === '<') {
if (source[1] === '!') {
if (source.startsWith('<!--')) {
// 注释
node = parseComment(context)
} else if (source.startsWith('<![CDATA[')) {
// CDATA
node = parseCDATA(context, ancestors)
}
} else if (source[1] === '/') {
// 结束标签,这里需要抛出错误
} else if (/[a-z]/i.test(source[1])) {
// 标签
node = parseElement(context, ancestors)
}
} else if (source.startsWith('{{')) {
// 解析插值
node = parseInterpolation(context)
}
}
// node 不存在,说明处理其他模式,即非 DATA 模式且非 RCDATA 模式
// 这时一切内容作为文本处理
if (!node) {
// 解析文本节点
node = parseText(context)
}
// 将节点添加到 nodes 数组中
nodes.push(node)
}
// 当 while 循环停止后,说明子节点解析完毕,返回子节点
return nodes
}
js
配合 parseComment
函数,解析如下模板内容:
const ast = parse('<div><!-- comments --></div>')
js
最终得到如下 AST:
{
type: 'Root',
children: [
{
type: 'Element',
tag: 'div',
props: [],
children: [
{ type: 'Comment', content: ' comments ' }
],
isSelfClosing: false
}
]
}
js
总结
本篇文章中,我们首先讨论了解析器的文本模式及其对解析器的影响。文本模式指的是解析器在工作时缩进如的一些特殊状态,如 RCDATA
模式、CDATA
模式、RAWTEXT
模式,以及初始的 DATA
模式等。在不同模式下,解析器对文本的解析行为会有所不同。
接着,我们讨论了如何使用递归下降算法构造模板 AST。在 parseChildren
函数运行的过程中,为了处理标签节点,会调用 parseElement
解析函数,这会间接地调用 parseChildren
函数,并产生一个新的状态机。随着标签嵌套层次的增加,新的状态机也会随着 parseChildren
函数被递归地调用而不断创建,这就是 “递归下降” 中 “递归” 二字的含义。而上级 parseChildren
函数的调用用于构造上级模板 AST 节点,被递归调用的下级 parseChildren
函数则用于构造下级模板 AST 节点。最终会构造出一棵树形结构的模板 AST,这就是 “递归下降” 中 “下降” 两字的含义。
在解析模板构建 AST 的过程中,parseChildren
函数是核心。每次调用 parseChildren
函数,就意味着新状态机的开始。状态机的结束时机有两个。
- 第一个停止时机是当模板内容解析完毕时。
- 第二个停止时机是遇到结束标签时,这时解析器会取得父级节点栈栈顶的节点作为父节点,检查该结束标签是否与父节点的标签同名,如果相同,则状态机停止运行。
我们还讨论了文本节点的解析。解析文本节点本身并不复杂,它的复杂点在于,我们需要对解析后的文本内容进行 HTML 实体的解码工作。WHATWG 规范中也定义了解码 HTML 实体过程中的状态迁移流程。HTML 实体类型有两种,分别是命名字符引用和数字字符引用。命名字符引用的解码方案可以总结为两种。
- 当存在分号时:执行完整匹配。
- 当省略分号时:执行最短匹配。
对于数字字符引用,则需要按照 WHATWG 规范中定义的规则逐步实现。