文本模式指的是解析器在工作时所进人的一些特殊状态,在不同的特殊状态下,解析器对文本的解析行为会有所不同。
当解析器遇到一些特殊标签时,会切换模式,这些特殊标签是:
遇到<title>、<textarea>
,会切换到 RCDATA模式
遇到<style>、<xmp>、<iframe>、<noembed>、<noframes>、<noscript>
会切换到 RAWTEXT模式
遇到<![CDATA[
,会切换到 CDATA模式
解析器的初始模式则是DATA模式。对于 Vue.js 的模板 DSL来说,模板中不允许出现<script>
标签,因此 Vue.is 模板解析器在遇到<script>
标签时也会换到 RAWTEXT 模式
解析器的行为会因工作模式的不同而不同
在DATA模式下,解析器能够处理HTML字符实体。
当解析器处于 RCDATA 状态时,解析器不能识别标签元素。
解析器在RAWTEXT模式下的工作方式与在RCDATA模式下类似。唯一不同的是,在RAWTEXT模式下解析器将不再支持HTML实体。在该模式下,解所器将HTML实体字符作为普通字符处理。
CDATA模式在RAWTEXT模式的基础上更进一步。在 CDATA 模式下,解析器将把任何字符都作为普通字符处理,直到遇到 CDATA 的结束标志为止。
后续编写解析器代码时,我们会将上述模式定义为状态表,如下面的代码所示:
const TextModes ={
DATA:'DATA',
RCDATA:'RCDATA',
RAWTEXT:'RAWTEXT',
CDATA: 'CDATA'
}
递归下降算法构造模板 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
}
}
在上面这段代码中,parseChildren 函数是整个解析器的核心。parseChildren 函数会返回解析后得到的子节点,例如下面代码:
<p>1</p>
<p>2</p>
parseChildren 函数在解析这段模板后,会得到由这两个
节点组成的数组:
[
{ type: 'Element', tag: 'p', children: [/*...*/]},
{ type: 'Element', tag: 'p', children:[/*...*/] }
]
parseChildren 函数接收两个参数。
第一个参数: 上下文对象 context。
第二个参数: 由父代节点构成的栈,用于维护节点间的父子级关系
parseChildren 函数本质上也是一个状态机,该状态机有多少种状态取决于子节点的类型数量。在模板中,元素的子节点可以是以下几种。
- 标签节点,例如
<div>
- 文本插值节点,例如
{{val}}
- 普通文本节点,例如:text
- 注释节点,例如
<!---->
- CDATA节点,例如
<![CDATA[ xxx]]>
为了降低复杂度,目前仅考虑上述类型的节点。
parseChildren 的代码实现如下:
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.text(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
}
注意上面代码里,解析过程中需要判断当前的文本模式。只有处于 DATA 模式或 RCDATE模式时,解析器才支持插值节点的解析。并且,只有处于 DATA模式时,解析器才支持标签节点、注释节点和CDATA 节点的解析。
同时要注意的是while循环停止的条件,以及isEnd()函数的用处,这里简单解释下,parseChildren函数是用来解析自己诶单的,因此while循环一定要遇到父级节点的结束标签才停止,这是正常思路,但是这个思路现在有问题,这里先忽略,后面再详细讨论。
在parseChildren函数中,如果解析器一开始处于 DATA 模式。开始执行解析后,解析器遇到的第一个字符为<,并且第二字符能够匹配正则表达式 /a-z,解析器会进人标签节点状态,并调用 parseElement 函数进行解析。
parseElement 会做三件事: 解析开始标签,解析子节点,解析结束标签。
因此,在 parseElement 函数内,要分别调用三个解析函数来处这三部分内容。
- parseTag 解析开始标签
- 递归地调用 parseChildren 函数解析子节点。
- parseEndTag 处理结束标签。
经过上述三个步骤的处理后,这段模板就被解析完毕了,最终得到了模板AST。
但这里值得注意的是,为了解析标签的子节点,我们递归地调用了 parseChildren 函数。这意味着,一个新的状态机开始运行了,我们称其为“状态机 2”。
在“状态机2”运行期间,为了处理标签节点,我们又调用 parseElement 函数。parseElement 函数会递归地调用 parseChildren 函数完成子节点的解析,这就意味着解析器会再开启了两个新的状态机。
因此,parseChildren 解析函数是整个状态机的核心,状态迁移操作都在该函数内完成。
在 parseChildren 函数运行过程中,为了处理标签节点,会调用parseElement 解析函数,这会间接地调用 parseChildren 函数,并产生一个新的状态机。随着标签嵌套层次的增加,新的状态机会随着 parseChildren 函数被递归地调用而不断创建,这就是“递下降”中“递归”二字的含义。
而上级 parseChildren 函数的调用用于构造上级模板AST节点,被递归调用的下级 parseChildren 函数则用于构造下级模板AST节点。最终,会构造出一棵树型结构的模板AST,这就是“递归下降”中“下降”二字的含义