前面提到了解析器本质是是一个状态机, 并且正则表达式也是状态机.
文本模式及其对解析器的影响
文本模式指解析器在工作时进入的一些特殊状态, 不同状态下解析器对文本的解析行为会有所不同. 遇到特殊的标签时, 会切换模式从而影响对文本的解析行为.
<title>、<textarea>解析器切换至RCDATA模式<style>、<xmp>、<iframe>、<noembed>、<noframes>、<noscriopt>等标签解析器切换至RAWTEXT模式- 遇到
<![CDATA[字符串, 解析器进入CDATA模式
解析器的行为与工作模式相关. 在初始的默认模式DATA模式下, 解析器遇到<时, 会切换到标签开始状态(该模式下, 可以解析标签元素). 遇到&字符时, 会切换到字符引用状态(也叫HTML字符实体状态, 即该模式下可以解析HTML字符实体).
当解析器处于RCDATA模式, 解析器遇到<时, 会切换到RCDATA less-than sign state状态. 此状态下如果遇到/时则切换到RCDATA end tag open state(结束标签)状态, 否则会将当前<作为普通字符处理. 所以textarea内可以将字符<作为普通文本, 不识别标签元素.
解析器处在RAWTEXT模式的工作方式与RCDATA模式类似. 唯一不同的是, 在RAWTEXT模式下, 解析器将不再支持HTML实体, 而是将其作为普通字符处理. vuejs的单文件组件的解析器遇到<script>标签时就会进入此模式.
CDATA模式下, 解析器把任何字符都当作普通字符处理, 直到遇到CDATA的结束标志.
不同的模式还会影响解析器对于终止解析的判断, 后面会继续讨论.
递归下降算法构造模板AST
// 根据文本模式的定义状态表
const TextModes = {
DATA: 'DATA',
RCDATA: 'RCDATA',
RAWTEXT: 'RAWTEXT',
CDATA: 'CDATA',
}
// 尝试实现一个更加完善的模板解析器, 结构如下
fucntion parse(str){
const context = {
source: str,
// 当前模式 初始为DATA
mode: TextModes.DATA
}
// 返回解析后的子节点
const nodes = parseChildren(context, [])
// 返回根节点
return {
type: 'Root',
children: nodes
}
}
// 模板解析
fucntion parseChildren(context, ancestors){
// 解析结果, 最终返回
let nodes = []
const { mode, source } = context
// 持续解析
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[')){
node = parseCDATA(context, ancestors) // CDATA
}
} else if(source[1] === '/'){
// 结束标签...
} else if(/a-z/i.test(source[1])){
node = parseElement(context, ancestors) // 标签
}
} else if(source.startsWith('{{')){
node = parseInterpolation(context, ancestors) // 解析插值
}
}
// node不存在, 处于其他模式. 作为普通文本处理
if(!node) {
node = parseText(context)
}
nodes.push(node)
}
return nodes
}
// 🌰模板解析
const template = `<div>
<p>Text1</p>
<p>Text2</p>
</div>`
// parseElement 伪代码
function parseElement(){
// 解析开始标签
const element = parseTag()
// 递归调用 parseChildren 解析 div 的子节点
element.children = parseChildren()
// 解析结束标签
parseEndTag()
return element
}
以上代码为例, 开始时解析器处于DATA模式. 遇到第一个字符<并且第二个字符匹配/a-z/i, 所以进入标签节点状态, 调用parseElement方法进行解析. (使用+代替换行符, -代替空格字符)
parseTag解析开始标签, 包括属性与指令. 处理后的模板内容变为+--<p>Text1</p>+--<p>Text2</p>+</div>- 递归调用
parseChildren解析子节点. 处理后的模板内容变为</div> parseEndTag解析结束标签
过程中递归调用parseChildren函数,
- 首先遇到
+--进入文本节点状态, 调用parseText进行解析. 处理后的模板内容为<p>Text1</p>+--<p>Text2</p>+ - 调用
parseElement, 处理后的模板内容为+--<p>Text2</p>+ - 处理后的模板内容为
<p>Text2</p>+ - 处理后的模板内容为
+ - 完毕
parseChildren函数是整个状态机的核心, 状态迁移操作都在该函数内完成. 运行过程中, 调用parseElement处理标签节点, 这会间接调用parseChildren产生新的状态机. 随着标签嵌套层次加深, 新的状态机会随着parseChildren递归调用不断创建, 上级parseChildren函数的调用用于构造上级模板AST节点, 被递归调用的下级parseChildren函数构造下级模板AST节点. 最终构造出一颗树形结构的模板AST, 这就是递归下降的含义.
状态机的开启与停止
当解析器遇到开始标签时, 会将该标签压入父级节点栈, 同时开启新的状态机. 当解析器遇到结束标签, 并且父级节点栈中存在与该标签同名的开始标签节点时, 会停止当前正在运行的状态机.
对于结构不规则的模板如<div><span></div></span>, 存在两种解释方式:
与当前父级节点栈顶节点比较
- 状态机1遇到开始标签
<div>, 调用parseElement开启新的状态机2来解析子节点 - 状态机2遇到
<span>标签, 调用parseElement开启状态机3来解析子节点 - 状态机3遇到
</div>结束标签, 但是此时栈顶的节点名称是span, 而不是div, 抛出“无效的结束标签”错误
与整个父级节点栈所有节点比较
- 状态机1遇到开始标签
<div>, 调用parseElement开启状态机2来解析子节点 - 状态机2遇到
<span>标签, 调用parseElement开启状态机3来解析子节点 - 状态机3遇到
</div>结束标签, 由于节点栈中存在同名标签节点, 所以状态机3停止. - 在这个过程中, 状态机2调用
parseElement解析函数时, 发现<span>缺少闭合标签, 打印相应错误信息.
// 第一种比较方式
function parseChildren(context, ancetors){
// ...
// 实际情况是当前结束标签不同名, 没有结束
while(!isEnd(context, ancetors)){
// ...
else if(context.source[1] === '/') {
console.error('无效的结束标签')
continue
}
// ...
}
// ...
}
// 第二种比较方式, 打印缺少闭合标签的错误信息
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.log(`${element.tag}缺少闭合标签`)
}
return element
}
// isEnd 的两种逻辑
function isEnd(){
if(!context.source) return true
// 判断栈顶节点是否同名
// const parent = ancestors[ancestors.length - 1]
// if(parent && context.source.startsWith(`</$parent.tag`)) return true
// 判断栈中是否有同名节点
for(let i = ancestors.length - 1; i >= 0; i--) {
if(context.source.startsWith(`</$ancestors[i].tag`)) return true
}
}
解析标签节点
正如上面parseElement函数的实现, 无论开始或结束标签节点, 都调用了parseTag函数, 标签中间的内容则调用parseChildren函数进行解析. 在parse函数定义的上下文对象context中, 新增advanceBy和advanceSpaces两个工具函数.
advanceBy(num){ context.source = context.source.slice(num) }消费指定数目的字符advanceSpaces(){ const match = /^[\t\r\n\f ]+/.exec(context.source); match && context.advanceBy(match[0].length ) }消费无用的空白字符
// parseTag实现 type参数控制要处理的标签类型
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]
// 消费匹配内容 如 '<div'
advanceBy(match[0].length)
// 消费空白字符
advanceSpaces()
// 处理属性与指令, 得到props数组
const props = parseAttributes(context)
const isSelfClosing = context.source.startsWith('/>')
// 如果是自闭合标签, 消费'/>', 否则消费'>'
advanceBy(isSelfClosing ? 2 : 1)
// 返回标签节点
return {
type: 'Element',
tag,
props,
children: [],
isSelfClosing
}
}
// parseTag 返回一个标签节点, 根据节点类型完成文本模式的切换
function parseElement(context, ancestors){
const element = parseTag(context)
if(element.isSelfClosing) return element
// 切换到 RCDATA 模式
if(element.tag === 'textarea' || element.tag === 'title'){
context.mode = TextModes.RCDATA
// 切换到 RAWTEXT 模式
} else if(/style|xmp|iframe|noembed|noframes|noscript/.test(element.tag)){
context.mode = TextModes.RAWTEXT
// 否则切换到 DATA 模式
} else {
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.log(`${element.tag}缺少闭合标签`)
}
return element
}
至此完成了对标签节点的解析, 下面继续节点中的指令与属性解析.
解析属性
// 解析指令与属性
function parseAttributes(context){
const { advanceBy, advanceSpaces } = context
const props = []
// 解析标签指令或属性, 直到遇到 > 或者 />
while(!context.source.startsWith('>') && !context.source.startsWith('/>')){
// 非空白字符 非/ 非> 的字符开头
// 非空白字符 非/ 非> 非= 的字符 0或多个
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
}
解析文本与解析HTML实体
文本是模板中的静态字符内容, HTML实体是以字符&开始的文本内容. 实体用来描述HTML中的保留字符和一些难以通过普通键盘输入的字符, 以及一些不可见的字符. 比如<表示字符<. HTML实体有命名字符引用(命名实体)和数字字符引用两类, <与<都表示字符<.
// 开始解析文本内容
function parseText(context){
// 默认模版剩余内容都是文本内容
let endIndex = context.source.length
// 查找 < 与 {{ 字符的位置
const ltIndex = context.source.indeOf('<')
const delimiterIndex = context.source.indeOf('{{')
// 优化文本内容
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)
}
}
// 解码模板中可能存在的HTML实体, 否则最终渲染的是 < 而非 <, 可能不符合预期
function decodeHtml(rawText, asAttr = false){
/**
命名字符引用和数字字符引用的数量很多, 感兴趣可以查阅相关规范
大概逻辑为: 消费文本内容中所有字符, 对其中的HTML实体进行解码.
如果遇到 &(命名字符)/&#(数字字符)/&#x(十六进制数字字符) 就尝试进行解码
尝试与引用表进行匹配, 如果符合将其替换为对应的字符(比如 < -> <)
*/
let decodedText = ''
// ...
return decodedText
}
解析插值与注释
// 解析插值符号
function parseInterpolation(context){
context.advanceBy('{{'.length)
closeIndex = context.source.indexOf('}}')
if(closeIndex < 0) console.error('插值缺少结束定界符')
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)
return {
type: 'Comment',
content
}
}
总结
- 文本模式指解析器进入工作时进入的一些特殊状态, 在不同模式下, 解析器对文本的解析行为会有所不同.
- 使用递归下降算法构建模板AST的重点在于
parseChildren函数, 为了处理标签节点, 会调用parseElement, 这会间接地调用parseChildren产生新的状态机. 状态机的结束实际有两个: 当模板内容解析完毕或者遇到结束标签时(解析器将结束标签与父级节点栈栈顶的节点相比较). - 文本节点的解析本身并不复杂, 复杂的是对HTML实体解码的工作.