1.文本模式及其对解析器的影响
解析器遇到不同的标签,会切换不同的模式,从而影响对文本解析的行为:
- title 标签、
<textarea>
标签,当解析器遇到这两个标签时,会切换到 RCDATA 模式 - style、
<xmp>
、<iframe>
、<noembed>
、<noframes>
、<noscript>
等标签,当解析器遇到这些标签时,会切换到 RAWTEXT 模式; - 当解析器遇到 < ![CDATA[ 字符串时,会进入 CDATA 模式。
对于vue.js的模板来说,模板中允许出现script标签,因为vue.js遇到script标签时也会切换到RAWTEXT模式。
不同模式及其特性
定义状态:
const TextModes = {
DATA: 'DATA',
RCDATA: 'RCDATA',
RAWTEXT: 'RAWTEXT',
CDATA: 'CDATA'
}
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
}
}
定义一个状态表,用来描述文本模式。parse函数是个解析器,内部定义了上下文对象context,用来维护解析程序执行中的状态。接着调用parseChildren函数进行解析,该函数计息后返回的子节点作为Root根节点的子节点。最后返回根节点,完成模板创建。parseChild人函数是解析器的核心功能。
假设有这样的模板:
<p>1</p>
<p>2</p>
parseChildren函数设计:
参数: 1.上下文对象context; 2.由父代节点构成的栈,用于维护节点间的父子级关系。
功能: //通过parseChildren函数解析后,希望得出如下的数据,作为根节点的childre
[
{ type: 'Element', tag: 'p', children: [/*...*/] },
{ type: 'Element', tag: 'p', children: [/*...*/] },
]
parseChildren函数本质也是一个状态机,该状态机有多少种状态取决于子节点的类型数量。在模板中,元素的子节点可以是以下几种:
- 标签节点,例如
<div>
。 - 文本插值节点,例如 {{ val }}。
- 普通文本节点,例如:text。
- 注释节点,例如
<!---->
。 - CDATA 节点,例如 <![CDATA[ xxx ]]>。
在标准的HTML中,节点类型很多,为了降低复杂度,目前仅考虑上述节点类型。
解析模板过程中的状态迁移:
解析中的基本规则如下:
当遇到字符 < 时,进入临时状态。
如果下一个字符匹配正则 /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('<!--')) {
// 注释
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('{{')) {
// 解析插值,例如<p>{{value}}</p>
node = parseInterpolation(context)
}
}
// node 不存在,说明处于其他模式,即非 DATA 模式且非 RCDATA 模式
// 这时一切内容都作为文本处理
if (!node) {
// 解析文本节点
node = parseText(context)
}
// 将节点添加到 nodes 数组中
nodes.push(node)
}
// 当 while 循环停止后,说明子节点解析完毕,返回子节点
return nodes
}
需要注意如下:
● parseChildren 函数的返回值是由子节点组成的数组,每次 while 循环都会解析一个或多个节点,这些节点会被添加到 nodes 数组中,并作为 parseChildren 函数的返回值返回。
● 解析过程中需要判断当前的文本模式。根据前面结论可知,只有处于 DATA 模式或 RCDATA 模式时,解析器才支持插值节点的解析。并且,只有处于 DATA 模式时,解析器才支持标签节点、注释节点和 CDATA 节点的解析。
● 在 16.1 节中我们介绍过,当遇到特定标签时,解析器会切换模式。一旦解析器切换到 DATA 模式和RCDATA 模式之外的模式时,一切字符都将作为文本节点被解析。当然,即使在 DATA 模式或RCDATA 模式下,如果无法匹配标签节点、注释节点、CDATA 节点、插值节点,那么也会作为文本节点解析。
假定有一个模板:
const template = `<div>
<p>Text1</p>
<p>Text2</p>
</div>`
这里需要强调的是,在解析模板时,我们不能忽略空白字符。这些空白字符包括:换行符(\n)、回车符(\r)、空格(’ ')、制表符(\t)以及换页符(\f)。如果我们用加号(+)代表换行符,用减号(-)代表空格字符。那么上面的模板可以表示为:
template = `<div>+--<p>Text1</p>+--<p>Text2</p>+</div>`
解析过程:
解析器一开始处于 DATA 模式。开始执行解析后,解析器遇到的第一个字符为 <,并且第二个字符能够匹配正则表达式 /a-z/i,所以解析器会进入标签节点状态,并调用 parseElement 函数进行解析。
parseElement 函数会做三件事:解析开始标签,解析子节点,解析结束标签,代码实现:
function parseElement() {
// 解析开始标签
const element = parseTag()
// 这里递归地调用 parseChildren 函数进行 <div> 标签子节点的解析
element.children = parseChildren()
// 解析结束标签
parseEndTag()
return element
}
如果一个标签不是自闭合标签,可以分为开始标签,子节点,结束标签三部分构成。在parseElement函数内,可以分别调用三个解析函数来处理这三部分内容。
开始标签:
parseTag 解析开始标签。parseTag 函数用于解析开始标签,包括开始标签上的属性和指令。因此,在 parseTag 解析函数执行完毕后,会消费字符串中的内容 <div>
,处理后的模板内容将变为:
template = `+--<p>Text1</p>+--<p>Text2</p>+</div>`
子节点:
递归地调用 parseChildren 函数解析子节点。parseElement 函数在解析开始标签时,会产生一个标签节点 element。在 parseElement 函数执行完毕后,剩下的模板内容应该作为 element 的子节点被解析,即 element.children。因此,我们要递归地调用 parseChildren 函数。在这个过程中,parseChildren 函数会消费字符串的内容:±-<p>
Text1 </p>
±-<p>
Text2 </p>
+。处理后的模板内容将变为:
template = `</div>`
结束标签:
parseEndTag 处理结束标签。可以看到,在经过 parseChildren 函数处理后,模板内容只剩下一个结束标签了。因此,只需要调用 parseEndTag 解析函数来消费它即可。
子节点的解析:
div这一层解析完了,里面的两个p标签就会进入递归的解析阶段。原理和过程同解析div的时候一样。
随着标签嵌套层次的增加,新的状态机会随着 parseChildren 函数被递归地调用而不断创建,这就是“递归下降”中“递归”二字的含义。而上级 parseChildren 函数的调用用于构造上级模板 AST 节点,被递归调用的下级parseChildren 函数则用于构造下级模板 AST 节点。最终,会构造出一棵树型结构的模板 AST,这就是“递归下降”中“下降”二字的含义。
3.状态机的开启与停止
parseChildren函数本质上是一个状态机,开启一个while循环使得状态机自动运行。状态机的停止条件依赖于isEnd()函数执行的结果。
状态机运行图示:
开启新的状态机
递归地开启新的状态机
状态机 2 停止运行
开启状态机 3
状态机 3 停止运行
状态机 1 停止
结论:
当解析器遇到开始标签时,会将该标签压入父级节点栈,同时开启新的状态机。当解析器遇到结束标签,并且父级节点栈中存在与该标签同名的开始标签节点时,会停止当前正在运行的状态机。
isEnd函数的实现
function isEnd(context, ancestors) {
// 当模板内容解析完毕后,停止
if (!context.source) return true
// 获取父级标签节点
const parent = ancestors[ancestors.length - 1]
// 如果遇到结束标签,并且该标签与父级标签节点同名,则停止
if (parent && context.source.startsWith(`</${parent.tag}`)) {
return true
}
}
●第一个停止时机是当模板内容被解析完毕时;
●第二个停止时机则是在遇到结束标签时,这时解析器会取得父级节点栈栈顶的节点作为父节点,检查该结束标签是否与父节点的标签同名,如果相同,则状态机停止运行。
当遇到了不符合预期的模板,例如:
<div><span></div></span>
如果按照原有逻辑去解析,流程为:
●“状态机 1”遇到 <div>
开始标签,调用 parseElement 解析函数,这会开启“状态机 2”来完成子节点的解析。
●“状态机 2”遇到 <span>
开始标签,调用 parseElement 解析函数,这会开启“状态机 3”来完成子节点的解析。
●“状态机 3”遇到 </div>
结束标签。由于此时父级节点栈栈顶的节点名称是 span,并不是 div,所以“状态机 3”不会停止运行。这时,“状态机 3”遭遇了不符合预期的状态,因为结束标签 </div>
缺少与之对应的开始标签,所以这时“状态机 3”会抛出错误:“无效的结束标签”。
如果换一种解析方式:
解析的结果:<div><span></div>
多余的内容:</span>
修改isEnd函数
function isEnd(context, ancestors) {
if (!context.source) return true
// 与父级节点栈内所有节点做比较
for (let i = ancestors.length - 1; i >= 0; --i) {
// 只要栈中存在与当前结束标签同名的节点,就停止状态机
if (context.source.startsWith(`</${ancestors[i].tag}`)) {
return true
}
}
}
流程:
●“状态机 1”遇到 <div>
开始标签,调用 parseElement 解析函数,并开启“状态机 2”解析子节点。
●“状态机 2”遇到 <span>
开始标签,调用 parseElement 解析函数,并开启“状态机 3”解析子节点。
●“状态机 3”遇到 </div>
结束标签,由于节点栈中存在名为 div 的标签节点,于是“状态机 3”停止了。
添加友好提示:
function parseElement(context, ancestors) {
const element = parseTag(context)
if (element.isSelfClosing) return element
ancestors.push(element)
element.children = parseChildren(context, ancestors)
ancestors.pop()
if (context.source.startsWith(`</${element.tag}`)) {
parseTag(context, 'end')
} else {
// 缺少闭合标签
console.error(`${element.tag} 标签缺少闭合标签`)
}
return element
}
4.解析标签节点
无论是开始标签还是闭合标签,都调用了parseTag函数,并且使用parseChildren函数来解析开始标签与闭合标签中间的部分
function parseElement(context, ancestors) {
// 调用 parseTag 函数解析开始标签
const element = parseTag(context)
if (element.isSelfClosing) return element
ancestors.push(element)
element.children = parseChildren(context, ancestors)
ancestors.pop()
if (context.source.startsWith(`</${element.tag}`)) {
// 再次调用 parseTag 函数解析结束标签,传递了第二个参数:'end'
parseTag(context, 'end')
} else {
console.error(`${element.tag} 标签缺少闭合标签`)
}
return element
}
标签节点的解析过程
由于开始标签和结束标签的格式非常类似,可以统一使用parseTag函数处理,并且通过该函数的第二个参数来指定具体的处理类型。当第二个参数值为字符串’end’时,意味着解析的是结束标签。无论是处理开始还是结束标签,parseTag都会消费对应的内容。为了实现模板内容的消费,需要新增两个工具函数:
function parse(str) {
// 上下文对象
const context = {
// 模板内容
source: str,
mode: TextModes.DATA,
// advanceBy 函数用来消费指定数量的字符,它接收一个数字作为参数
advanceBy(num) {
// 根据给定字符数 num,截取位置 num 后的模板内容,并替换当前模板内容
context.source = context.source.slice(num)
},
// 无论是开始标签还是结束标签,都可能存在无用的空白字符,例如 <div >
advanceSpaces() {
// 匹配空白字符
const match = /^[\t\r\n\f ]+/.exec(context.source)
if (match) {
// 调用 advanceBy 函数消费空白字符
context.advanceBy(match[0].length)
}
}
}
const nodes = parseChildren(context, [])
return {
type: 'Root',
children: nodes
}
}
为上下文对象增加了 advanceBy 函数和 advanceSpaces 函数。advanceBy 函数用来消费指定数量的字符。其实现原理很简单,即调用字符串的 slice 函数,根据指定位置截取剩余字符串,并使用截取后的结果作为新的模板内容。advanceSpaces 函数则用来消费无用的空白字符,因为标签中可能存在空白字符,例如在模板 <div---->
中减号(-)代表空白字符。
// 由于 parseTag 既用来处理开始标签,也用来处理结束标签,因此我们设计第二个参数 type,
// 用来代表当前处理的是开始标签还是结束标签,type 的默认值为 'start',即默认作为开始标签处理
function parseTag(context, type = 'start') {
// 从上下文对象中拿到 advanceBy 函数
const { advanceBy, advanceSpaces } = context
// 处理开始标签和结束标签的正则表达式不同
const match = type === 'start'
// 匹配开始标签
? /^<([a-z][^\t\r\n\f />]*)/i.exec(context.source)
// 匹配结束标签
: /^<\/([a-z][^\t\r\n\f />]*)/i.exec(context.source)
// 匹配成功后,正则表达式的第一个捕获组的值就是标签名称
const tag = match[1]
// 消费正则表达式匹配的全部内容,例如 '<div' 这段内容
advanceBy(match[0].length)
// 消费标签中无用的空白字符
advanceSpaces()
// 在消费匹配的内容后,如果字符串以 '/>' 开头,则说明这是一个自闭合标签
const isSelfClosing = context.source.startsWith('/>')
// 如果是自闭合标签,则消费 '/>', 否则消费 '>'
advanceBy(isSelfClosing ? 2 : 1)
// 返回标签节点
return {
type: 'Element',
// 标签名称
tag,
// 标签的属性暂时留空
props: [],
// 子节点留空
children: [],
// 是否自闭合
isSelfClosing
}
}
上面代码中的两个关键点:
●由于 parseTag 函数既用于解析开始标签,又用于解析结束标签,因此需要用一个参数来标识当前处理的标签类型,即 type。
●对于开始标签和结束标签,用于匹配它们的正则表达式只有一点不同:结束标签是以字符串 </ 开头的。图 16-16 给出了用于匹配开始标签的正则表达式的含义。
●对于字符串 ‘<div>
’,会匹配出字符串 ‘<div’,剩余 ‘>’。
●对于字符串 ‘<div/>
’,会匹配出字符串 ‘<div’,剩余 ‘/>’。
●对于字符串 ‘<div---->
’,其中减号(-)代表空白符,会匹配出字符串 ‘<div’,剩余 ‘---->’。
除了正则表达式外,parseTag 函数的另外几个关键点如下。
●在完成正则匹配后,需要调用 advanceBy 函数消费由正则匹配的全部内容。
●根据上面给出的第三个正则匹配例子可知,由于标签中可能存在无用的空白字符,例如 <div---->
,因此我们需要调用 advanceSpaces 函数消费空白字符。
●在消费由正则匹配的内容后,需要检查剩余模板内容是否以字符串 /> 开头。如果是,则说明当前解析的是一个自闭合标签,这时需要将标签节点的 isSelfClosing 属性设置为 true。
●最后,判断标签是否自闭合。如果是,则调用 advnaceBy 函数消费内容 />,否则只需要消费内容> 即可。
经过上面的流程,pareTag函数会返回一个标签节点。parseElement函数在得到由parseTag函数产生的标签节点后,需要根据节点的类型完成文本模式的切换
function parseElement(context, ancestors) {
const element = parseTag(context)
if (element.isSelfClosing) return element
// 切换到正确的文本模式
if (element.tag === 'textarea' || element.tag === 'title') {
// 如果由 parseTag 解析得到的标签是 <textarea> 或 <title>,则切换到 RCDATA 模式
context.mode = TextModes.RCDATA
} else if (/style|xmp|iframe|noembed|noframes|noscript/.test(element.tag)) {
// 如果由 parseTag 解析得到的标签是:
// <style>、<xmp>、<iframe>、<noembed>、<noframes>、<noscript>
// 则切换到 RAWTEXT 模式
context.mode = TextModes.RAWTEXT
} else {
// 否则切换到 DATA 模式
context.mode = TextModes.DATA
}
ancestors.push(element)
element.children = parseChildren(context, ancestors)
ancestors.pop()
if (context.source.startsWith(`</${element.tag}`)) {
parseTag(context, 'end')
} else {
console.error(`${element.tag} 标签缺少闭合标签`)
}
return element
}
5.解析属性
模板
<div id="foo" v-show="display"/>
上面模板中存在一个id属性和一个v-show指令,为了处理指令,需要在parseTag中增加parseAttributes解析函数
function parseTag(context, type = 'start') {
const { advanceBy, advanceSpaces } = context
const match = type === 'start'
? /^<([a-z][^\t\r\n\f />]*)/i.exec(context.source)
: /^<\/([a-z][^\t\r\n\f />]*)/i.exec(context.source)
const tag = match[1]
advanceBy(match[0].length)
advanceSpaces()
// 调用 parseAttributes 函数完成属性与指令的解析,并得到 props 数组,
// props 数组是由指令节点与属性节点共同组成的数组
const props = parseAttributes(context)
const isSelfClosing = context.source.startsWith('/>')
advanceBy(isSelfClosing ? 2 : 1)
return {
type: 'Element',
tag,
props, // 将 props 数组添加到标签节点上
children: [],
isSelfClosing
}
}
function parseAttributes(context) {
// 用来存储解析过程中产生的属性节点和指令节点
const props = []
// 开启 while 循环,不断地消费模板内容,直至遇到标签的“结束部分”为止
while (
!context.source.startsWith('>') &&
!context.source.startsWith('/>')
) {
// 解析属性或指令
}
// 将解析结果返回
return props
}
到了处理属性值的环节。模板中的属性值存在三种情况:
○属性值被双引号包裹:id=“foo”。
○属性值被单引号包裹:id=‘foo’。
○属性值没有引号包裹:id=foo。
parseAttributes函数的具体实现:
function parseAttributes(context) {
const { advanceBy, advanceSpaces } = context
const props = []
while (
!context.source.startsWith('>') &&
!context.source.startsWith('/>')
) {
// 该正则用于匹配属性名称
const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source)
// 得到属性名称
const name = match[0]
// 消费属性名称
advanceBy(name.length)
// 消费属性名称与等于号之间的空白字符
advanceSpaces()
// 消费等于号
advanceBy(1)
// 消费等于号与属性值之间的空白字符
advanceSpaces()
// 属性值
let value = ''
// 获取当前模板内容的第一个字符
const quote = context.source[0]
// 判断属性值是否被引号引用
const isQuoted = quote === '"' || quote === "'"
if (isQuoted) {
// 属性值被引号引用,消费引号
advanceBy(1)
// 获取下一个引号的索引
const endQuoteIndex = context.source.indexOf(quote)
if (endQuoteIndex > -1) {
// 获取下一个引号之前的内容作为属性值
value = context.source.slice(0, endQuoteIndex)
// 消费属性值
advanceBy(value.length)
// 消费引号
advanceBy(1)
} else {
// 缺少引号错误
console.error('缺少引号')
}
} else {
// 代码运行到这里,说明属性值没有被引号引用
// 下一个空白字符之前的内容全部作为属性值
const match = /^[^\t\r\n\f >]+/.exec(context.source)
// 获取属性值
value = match[0]
// 消费属性值
advanceBy(value.length)
}
// 消费属性值后面的空白字符
advanceSpaces()
// 使用属性名称 + 属性值创建一个属性节点,添加到 props 数组中
props.push({
type: 'Attribute',
name,
value
})
}
// 返回
return props
}
两个重要的正则表达式:
●/[\t\r\n\f />][^\t\r\n\f />=]*/,用来匹配属性名称;
●/[\t\r\n\f >]+/,用来匹配没有使用引号引用的属性值。
正则1:
●部分 A 用于匹配一个位置,这个位置不能是空白字符,也不能是字符 / 或字符 >,并且字符串要以该位置开头。
●部分 B 则用于匹配 0 个或多个位置,这些位置不能是空白字符,也不能是字符 /、>、=。注意,这些位置不允许出现等于号(=)字符,这就实现了只匹配等于号之前的内容,即属性名称。
正则2:
该正则表达式从字符串的开始位置进行匹配,并且会匹配一个或多个非空白字符、非字符 >。换句话说,该正则表达式会一直对字符串进行匹配,直到遇到空白字符或字符 > 为止,这就实现了属性值的提取。
模板1:
<div id="foo" v-show="display"></div>
解析结果:
const ast1 = {
type: 'Root',
children: [
{
type: 'Element',
tag: 'div',
props: [
// 属性
{ type: 'Attribute', name: 'id', value: 'foo' },
{ type: 'Attribute', name: 'v-show', value: 'display' }
]
}
]
}
模板2:
<div :id="dynamicId" @click="handler" v-on:mousedown="onMouseDown" ></div>
解析结果2:
const ast2 = {
type: 'Root',
children: [
{
type: 'Element',
tag: 'div',
props: [
// 属性
{ type: 'Attribute', name: ':id', value: 'dynamicId' },
{ type: 'Attribute', name: '@click', value: 'handler' },
{ type: 'Attribute', name: 'v-on:mousedown', value: 'onMouseDown' }
]
}
]
}