vuejs设计与实现-解析器

482 阅读6分钟

前面提到了解析器本质是是一个状态机, 并且正则表达式也是状态机.

文本模式及其对解析器的影响

文本模式指解析器在工作时进入的一些特殊状态, 不同状态下解析器对文本的解析行为会有所不同. 遇到特殊的标签时, 会切换模式从而影响对文本的解析行为.

  • <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方法进行解析. (使用+代替换行符, -代替空格字符)

  1. parseTag解析开始标签, 包括属性与指令. 处理后的模板内容变为+--<p>Text1</p>+--<p>Text2</p>+</div>
  2. 递归调用parseChildren解析子节点. 处理后的模板内容变为</div>
  3. parseEndTag解析结束标签

过程中递归调用parseChildren函数,

  1. 首先遇到+--进入文本节点状态, 调用parseText进行解析. 处理后的模板内容为<p>Text1</p>+--<p>Text2</p>+
  2. 调用parseElement, 处理后的模板内容为+--<p>Text2</p>+
  3. 处理后的模板内容为<p>Text2</p>+
  4. 处理后的模板内容为+
  5. 完毕

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中, 新增advanceByadvanceSpaces两个工具函数.

  • 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中的保留字符和一些难以通过普通键盘输入的字符, 以及一些不可见的字符. 比如&lt表示字符<. HTML实体有命名字符引用(命名实体)和数字字符引用两类, &lt&#60都表示字符<.

// 开始解析文本内容
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实体, 否则最终渲染的是 &lt 而非 <, 可能不符合预期
function decodeHtml(rawText, asAttr = false){
    /**
        命名字符引用和数字字符引用的数量很多, 感兴趣可以查阅相关规范
        大概逻辑为: 消费文本内容中所有字符, 对其中的HTML实体进行解码.
            如果遇到 &(命名字符)/&#(数字字符)/&#x(十六进制数字字符) 就尝试进行解码
            尝试与引用表进行匹配, 如果符合将其替换为对应的字符(比如 &lt -> <)
    */ 
    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实体解码的工作.