《Vuejs设计与实现》第 16 章(解析器) 上 - 教程
目录
16.1 文本模式及其对解析器的影响
16.2 递归下降算法构造模板 AST
16.3 状态机的开启与停止
解析器本质是一个状态机,正则也是一个状态机,本章使用正则完成一个 html 解析器。
16.1 文本模式及其对解析器的影响
文本模式是解析器工作时的特殊状态。
在这些状态下,解析器的解析行为会有所不同。解析器会根据遇到的特殊标签切换工作模式,从而改变对文本的解析方式:
- 当遇到
<title>
标签、<textarea>
标签,当解析器遇到这两个标签时,会切换到RCDATA
模式。 - 当遇到
<style>
、<xmp>
、<iframe>
、<noembed>
、<noframes>
、<noscript>
等标签,当解析器遇到这些标签时,会切换到RAWTEXT
模式。 - 当解析器遇到
<![CDATA[
字符串时,会进入 CDATA 模式。
默认情况下,解析器的初始模式是 DATA 模式。
Vue.js 的模板 DSL 中不允许出现 <script>
标签,当 Vue.js 模板解析器在遇到 <script>
标签时会切换到 RAWTEXT
模式。
解析器根据当前的工作模式,采用不同的解析行为。
例如,在默认的 DATA 模式下,解析器会在遇到 < 字符时,切换到标签开始状态(tag open state),从而能够解析标签元素。
在该模式下,如果遇到 & 字符,解析器会切换到字符引用状态(character reference state),也就是可以处理 HTML 字符实体的状态。
然而,在 RCDATA 模式下,解析器的行为会有所不同。在这种模式下,解析器遇到 < 字符时,将会切换到 RCDATA less-than sign state 状态,而不是标签开始状态。
在 RCDATA less-than sign state 状态下,如果遇到 / 字符,会直接切换到 RCDATA 的结束标签状态(RCDATA end tag open state);否则,会将 < 字符视为普通字符处理,然后继续处理后续字符。这就是为什么在 <textarea>
标签内,可以将 < 字符作为普通文本,解析器并不会将其解析为标签开始的标志。
以下是一个示例:
helloworld
在上述 HTML 代码中,虽然 <textarea>
标签内存在 <div>
标签,但解析器并不会将 <div>
解析为标签元素,而是作为普通文本处理。
然而,需要注意的是,尽管在 RCDATA 模式下,解析器不能识别标签元素,但它仍然支持 HTML 字符实体。当解析器遇到 & 字符时,会切换到字符引用状态。例如:
©
浏览器在渲染这段 HTML 代码时,会在文本框内展示字符 ©。
解析器在 RAWTEXT 模式下的工作方式与 RCDATA 模式类似,不同之处在于 RAWTEXT 模式下不再支持 HTML 实体,而是将其作为普通字符处理。
Vue.js的单文件组件的解析器在遇到 <script>
标签时会进入 RAWTEXT 模式,将<script>
标签内的内容作为普通文本处理。CDATA
模式在 RAWTEXT
模式的基础上更进一步,在 CDATA
模式下,解析器将把任何字符都作为普通字符处理,直到遇到 CDATA
的结束标志为止。
PLAINTEXT 模式与 RAWTEXT 模式类似,但解析器一旦进入 PLAINTEXT 模式,将不会再退出。
不同的模式及各其特性:
不同解析器,还会影响对终止解析的判断,后文具体讨论,我们先将上述模式定义为状态表:
const TextModes = {
DATA: 'DATA',
RCDATA: 'RCDATA',
RAWTEXT: 'RAWTEXT',
CDATA: 'CDATA',
}
16.2 递归下降算法构造模板 AST
这节我们实现一个更完善的模板解析器,基本架构模型如下:
// 定义文本模式,作为一个状态表
const TextModes = {
DATA: 'DATA',
RCDATA: 'RCDATA',
RAWTEXT: 'RAWTEXT',
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,
}
}
上述代码,首先定义 TextModes 描述预定义的文本模式。
然后我们定义 parse 解析函数,其中定义上下文对象 context 用来维护执行程序时产生的各种状态。
接着,调用 parseChildren 函数进解析返回解析后得到的子节点,并使用这些子节点作为 children 来创建 Root 根节点。
这段代码与第 15 章不同,在第 15 章中,我们首先对模板内容进行标记化得到一系列 Token,然后根据这些 Token 构建模板 AST。
实际上,创建 Token 与构造模板 AST 的过程可以同时进行,因为模板和模板 AST 具有同构的特性。
上面代码 parseChildren 函数是核心,后续会不断调用它来消费模板内容,它会返回解析后得到的子节点,举个例子,假如有以下模板:
1
2
parseChildren 函数在解析这段模板后,会得到由这两个 <p>
节点组成的数组:
[
{ type: 'Element', tag: 'p', children: [/*...*/] },
{ type: 'Element', tag: 'p', children: [/*...*/] },
]
之后,这个数组将作为 Root 根节点的 children。
parseChildren 函数接收两个参数。
- 第一个参数:上下文对象 context。
- 第二个参数:由父代节点构成的栈,用于维护节点间的父子级关系。
parseChildren 函数本质上也是一个状态机,该状态机有多少种状态取决于子节点的类型数量。在模板中,元素的子节点有几种:
- 标签节点,例如
<div>
。 - 文本插值节点,例如 {{ val }}。
- 普通文本节点,例如:text。
- 注释节点,例如
<!---->
。 - CDATA 节点,例如
<![CDATA[ xxx ]]>
。
在标准的 HTML 中,节点的类型将会更多,例如 DOCTYPE 节点等。为了降低复杂度,我们仅考虑上述类型的节点。
parseChildren 函数在解析模板过程中的状态迁移过程:
- 当遇到字符
<
时,进入临时状态。 - 如果下一个字符匹配正则 /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 循环,只要满足条件就会一直对字符串进行解析
// 关于 isEnd() 后文会详细讲解
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('