13-解析器

130 阅读5分钟

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、字符解析和插值解析