1、文本模式及其对解析器的影响
文本模式:指的是解析器在工作时进入的一些特殊状态
解析器在遇到不同的标签时,会切换模式,影响对文本的解析行为,具体有如下标签
<!-- 解析器遇到这两个,会切换到RCDATA模式 -->
<title></title> <textarea></textarea>
<!-- 遇到这些会切换到RAWTEXT模式 -->
<style></style> <xmp></xmp> <iframe></iframe> <noembed></noembed> <nomed></nomed> <noframes></noframes>
解析器的初始模式是DATA模式。Vue.js的模板DSL,模板中不允许出现
不同模式及其特性
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
}
}
parseChildren是解析器的核心,parseChildren函数会返回解析后得到的子节点。
parseChildren函数接收两个参数:
1.上下文context
2.父结点构成的栈,用于维护节点间的父子级关系。
parseChildren函数本质上也是一个状态机,该状态机有多少种状态取决于子节点的类型和数量。
parseChildren函数在解析模板过程中的状态迁移过程图
迁移过程图转为代码如下所示
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('{{')) {
// 解析插值
node = parseInterpolation(context)
}
}
// node 不存在,说明处于其他模式,即非 DATA[…]”
// 这时一切内容都作为文本处理
if (!node) {
// 解析文本节点
node = parseText(context)
}
// 将节点添加到 nodes 数组中
nodes.push(node)
}
// 当 while 循环停止后,说明子节点解析完毕,返回子节点
return nodes
}
注意:
1.每次的while循环都会解析一个或多个节点,都会被添加到nodes数组中作为返回值
2.只有处于DATA模式,解析器才可以解析标签节点和注释节点
3.如果无法匹配标签节点、注释节点、CDATA节点、插值节点,那么也会作为文本节点被解析
3、状态机的开启与停止
状态机应该何时停止
举例:
状态机1遇到了
标签,会调用parseElement函数进行解析。递归调用parseChildren函数开启新的状态机,状态机2,此时会出现两个状态机。
此时状态机2拥有程序的执行权,持续解析模板,直到遇到结束标签
。因为是结束标签并且该结束标签同名的标签节点,使用状态机2停止运行,并弹出父级节点栈中处于栈顶的节点。此时状态机2停止运行,状态机1继续运行,直到再次遇到
,状态机1继续调用parseElement解析标签节点,因此又会执行压栈,并开启新的状态机3。
此时状态机3拥有程序的执行权,它继续解析模板,直到遇到结束标签
,因为是一个结束标签,并且在父级节点栈中存在与该结束标签同名的标签节点,所以状态机3会停止运行,并弹出父级节点栈中处于栈顶的节点。
当状态机3停止运行后,程序的执行权还给状态机1,状态机1继续解析模板,直到遇到最后的结束标签。此时状态机1发现父级节点栈中存在与结束标签同名的标签节点,于是将该节点弹出父级节点栈,并停止运行。
结论:当解析器遇到开始标签,会将该标签的压入父级节点栈,同时开启新的状态机。当解析器遇到结束标签,并且父级节点栈中存在的与该标签同名的开始标签节点时,会停止正在运行的状态机。
4、解析标签节点
由于开始标签和结束标签的格式非常类似,添加一个parseTag函数处理
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()
const props = parseAttributes(context)
const isSelfClosing = context.source.startsWith('/>')
advanceBy(isSelfClosing ? 2 : 1)
return {
type: 'Element',
tag,
props,
children: [],
isSelfClosing
}
}
5、解析属性
处理属性和指令,需要在parseTag函数中增加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.push({
type: 'Attribute',
name,
value
})
}
return props
}
这段代码实质就是正则表达式处理属性名称、等于号、属性值的过程
从字符串的开始位置进行匹配,并且会匹配一个或多个非空空白字符、非字符>,直到遇到空白字符或字符>为止,实现了属性值的提取
6、解析文本
状态机处于初始状态,读取到模板的第一个某个字符字母,既不是<,也不是插值符号{{,此时进入文本解析状态调用parseText函数,代码如下所示
function parseText(context) {
let endIndex = context.source.length
const ltIndex = context.source.indexOf('<')
const delimiterIndex = context.source.indexOf('{{')
if (ltIndex > -1 && ltIndex < endIndex) {
endIndex = ltIndex
}
if (delimiterIndex > -1 && delimiterIndex < endIndex) {
endIndex = delimiterIndex
}
const content = context.source.slice(0, endIndex)
context.advanceBy(content.length)
return {
type: 'Text',
content: decodeHtml(content)
}
}
7、解析插值与注释
插值以字符串开头{{,和}}结尾,进入开头时调用parseInterpolation方法进行插值解析
function parseInterpolation(context) {
context.advanceBy('{{'.length)
closeIndex = context.source.indexOf('}}')
const content = context.source.slice(0, closeIndex)
context.advanceBy(content.length)
context.advanceBy('}}'.length)
return {
type: 'Interpolation',
content: {
type: 'Expression',
content: decodeHtml(content)
}
}
}
解析注释
function parseComment(context) {
// 消费注释的开始部分
context.advanceBy('<!--'.length)
// 找到注释结束部分的位置索引
closeIndex = context.source.indexOf('-->')
// 截取注释节点的内容
const content = context.source.slice(0, closeIndex)
// 消费内容
context.advanceBy(content.length)
// 消费注释的结束部分
context.advanceBy('-->'.length)
// 返回类型为 Comment 的节点
return {
type: 'Comment',
content
}
}
总结
1、学习了解析器的文本模式及其对解析器的影响,进入一些特殊状态,如RCDATA模式、CDATA模式、RAWTEXT模式、以及初始的DATA模式等
2、递归下降算法构造模板AST,构造上级模板AST节点,被递归调用的下级parseChildren函数则构造下级模板AST节点,最终会构造一棵树型结构的模板AST。
3、字符解析和插值解析