解析器

我们初步讨论了解析器(parser)的工作原理,知道了解析器本质是一个状态机。但我们也曾提到,正则表达式其实也是一个状态机。因此在编写 parser 的时候,利用正则表达式能够让我们少写不少代码。本篇文章我们将更多地利用正则表达式来实现 HTML 解析器。

一个完善的 HTML 解析器远比想象中复杂。我们知道,浏览器会对 HTML 文本进行解析,那么它是如何做的呢?其实关于 HTML 文本的解析,是由规范可循的,即 WHATWG 关于 HTML 的解析规范,其中定义了完整的错误处理和状态机的状态迁移流程,还提及了一些特殊的状态,例如 DATACDATARCDATARAWTEXT 等。那么,这些状态有什么含义呢?它们对解析器有哪些影响呢?什么是 HTML 实体,以及 Vue.js 模板解析器需要如何处理 HTML 实体呢?

文本模式

文本模式指的是解析器在工作时所进入的一些特殊状态,在不同的特殊状态下,解析器对文本的解析行为会有所不同。具体来说,当解析器遇到一些特殊标签时,会切换模式,从而影响其对文本的解析行为。这些特殊标签是:

  • <title> 标签、<textarea> 标签,当解析器遇到这两个标签时,会切换到 RCDATA 模式;
  • <style><xmp><iframe><noembed>noframes><noscript> 等标签,当解析器遇到这些标签时,会切换到 RAWTEXT 模式;
  • 当解析器遇到 <![CDATA[ 字符串时,会进入 CDATA 模式。

解析器的初始模式是 DATA 模式。对于 Vue.js 的模板 DSL 来说,模板中不允许出现 <script> 标签,因此 Vue.js 模板解析器在遇到 <script> 标签时也会切换到 RAWTEXT 模式。

解析器的行为会因工作模式的不同而不同。WAHTWG 规范的第 13.2.5.1 节给出了初始模式下解析器的过程流程。

https://html.spec.whatwg.org/multipage/parsing.html#data-state

parser01.png

在默认的 DATA 模式下,解析器在遇到字符 < 时,会切换到标签开始状态(tag open state)。换句话说,在该模式下,解析器能够解析标签元素。当解析器遇到字符 & 时,会切换到字符引用状态(character reference state),也称 HTML 字符实体状态。也就是说,在 DATA 模式下,解析器能够处理 HTML 字符实体。

我们再来看看当解析器处理 RCDATA 状态时,它的工作状态如何。

parser02.png

由图可知,当解析器遇到字符 < 时,不会切换到标签开始状态,而回切换到 RCDATA less-than sign state 状态。下图给出了 RCDATA less-than sign state 状态下解析器的工作方式。

parser03.png

由图可知 RCDATA less-than sign state 状态下,如果解析器遇到字符 /,则直接切换到到 RCDATA 的结束标签状态,即 RCDATA end tag open state 。否则会将当前字符 < 作为普通字符处理,然后继续处理后面的字符。由此可以,在 RCDATA 状态下,解析器不能识别标签元素,这其实间接说明了在 <textarea> 内可以将字符 < 作为普通文本,解析器并不会认为字符 < 是标签开始的标志。

<textarea>
	<div>asdf</div>asdfasdf
</textarea>
html

在上面这段 HTML代码中,<textarea> 标签内存在一个 <div> 标签。但解析器并不会把 <div> 解析为标签元素,而是作为普通文本处理。但是,由 13.2.5.2 RCDATA state 可知,在 RCDATA 模式下,解析器仍然支持 HTML 实体。因为当解析器遇到字符 & 时,会切换到字符引用状态。如下面的代码所示:

<textarea>&copy;</textarea>
html

浏览器在渲染器这段 HTML 代码时,会在文本框内展示字符 ©

解析在 RAWTEXT 模式下的工作方式与在 RCDATA 模式下类似。唯一不同的是,在 RAWTEXT 模式下,解析器将不再支持 HTML 实体。下图给出了 WAHTWG 规范中第 13.2.5.3 节中所定义的 RAWTEXT 模式下状态机的工作方式。

parser04.png

对比 13.2.5.3 RAWTEXT state13.2.5.2 RCDATA state 可知,RAWTEXT 模式的确不支持 HTML 实体。在该模式下,解析器会将 HTML 尸体字符作为普通字符串处理。Vue.js 的单文件组件的解析器在遇到 <script> 标签时就会进入 RAWTEXT 模式,这时他会把 <script> 标签内的内容全部作为普通文本处理。

CDATA 模式在 RAWTEXT 模式的基础上更进一步。下图给出了 WHATWG 规范第 13.2.5.69 节中所定义的 CDATA 模式下状态机的工作方式。

parser05.png

CDATA 模式下,解析器会把任何字符都作为普通字符处理,直到遇到 CDATA 的结束标志为止。

实际上,在 WHATWG 规范中还定义了 PLAINTEXT 模式,该模式与 RAWTEXT 模式类似。不同的是,解析器一旦进入 PLAINTEXT 模式,将不会再退出。另外,Vue.js 的模板 DSL 解析器是用不到 PLAINTEXT 模式的,因此我们也不会过多介绍它。

下图汇总了不同的模式及各自的特性。

模式 能否解析标签 是否支持 HTML 实体
DATA
RCDATA
RAWTEXT
CDATA

除了表中列出的特性之外,不同的模式还会影响解析器对于终止解析的判断,后面会继续讨论。另外,后续编写解析器代码时,我们会将上述模式定义为状态表。

const TextModes = {
  DATA: 'DATA',
  RCDATA: 'RCDATA',
  RAETEXT: 'RAETEXT',
  CDATA: 'CDATA'
}
js

递归下降算法构造模板 AST

递归下降分析法是确定的自上而下分析法,这种分析法要求文法是LL(1)文法。 为每个非终结符编制一个递归下降分析函数,每个函数名是相应的非终结符,函数体则是根据规则右部符号串的结构和顺序编写。 子程序相互递归调用。

从本节开始,我们将着手实现一个更加完善的模板解析器。解析器的基本架构模型如下:

// 定义文本模式,作为状态表
const TextModes = {
  DATA: 'DATA',
  RCDATA: 'RCDATA',
  RAETEXT: 'RAETEXT',
  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
  }
}
js

上面这段代码中,我们首先定义了一个状态表 TextModes,它用来描述预定义的文本模式。然后,我们定义了 parse 函数,即解析器函数,在其中定义了上下文对象 context,用来维护解析程序执行程序中的各种状态。接着,调用 parseChildren 函数进行解析,该函数会返回解析后得到的子节点,并使用这些子节点作为 children 来创建 Root 根节点。最后,parse 函数返回根节点,完成模板 AST 的构建。

这段代码的思路与我们之前讲述的关于模板 AST 的构建思路有所不同。在之前的代码中,我们首先对模板内容进行标记化得到一系列 Token,然后根据这些 Token 构建模板 AST。实际上,创建 Token 与构造模板 AST 的过程可以同时进行,因为模板和模板 AST 具有同构特性。

另外,在上面这段代码中,parseChildren 函数是整个解析器的核心。后续我们会递归地调用它来不断地消费模板内容。parseChildren 函数会返回解析后得到的子节点。假设有如下模板。

<p>1</p>
<p>2</p>
html

上面这段模板有两个根节点,即两个 <p> 标签。parseChildren 函数在解析这段模板后,会得到由这两个 <p> 节点组成的数组:

[
  { type: 'Element', tag: 'p', children: [/*...*/] },
  { type: 'Element', tag: 'p', children: [/*...*/] },
]
js

之后,这个数组将作为 Root 根节点的 children。

  • 第一个参数:上下文对象 context;
  • 第二个参数:由父代节点构成的栈,用于维护节点间的父子级关系。

parseChildren 函数本质也是一个状态机,该状态机有多少种状态取决于子节点的类型数量。在模板中,元素的子节点可以是以下几种:

  • 标签节点,例如 <div>
  • 文本插值节点,例如 {{ val }}
  • 普通文本节点,例如:text。
  • 注释节点,例如 <!---->
  • CDATA 节点,例如 <![CDATA[ xxx ]]>

在标准的 HTML 中,节点的类型将会更多,例如 DOCTYPE 节点等。为了降低复杂度,我们仅考虑上述类型的节点。

parser06.png

我们可以把上图展示的状态迁移过程总结如下:

  • 当遇到 < 时,进行临时状态
    • 如果下一个字符匹配正则 /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 循环,只要满足条件就会一直对字符串进行解析
  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 模式且非 RCDATA 模式
    // 这时一切内容作为文本处理
    if (!node) {
      // 解析文本节点
      node = parseText(context)
    }

    // 将节点添加到 nodes 数组中
    nodes.push(node)
  }

  // 当 while 循环停止后,说明子节点解析完毕,返回子节点
  return nodes
}
js

上面这段代码完整地描述了图中所示的状态迁移过程,这里有几点需要注意。

  • parseChildren 函数的返回值是由子节点组成的数组,每次 while 循环都会解析一个或多个节点,这些节点会被添加到 nodes 数组中,并作为 parseChildren 函数的返回值返回;
  • 解析过程中需要判断当前的文本模式。只有处于 DATA 模式或 RCDATA 模式时,解析器才会支持插值节点的解析。并且,只有处于 DATA 模式时,解析器才支持标签节点、注释节点和 CDATA 节点的解析。
模式 能否解析标签 是否支持 HTML 实体
DATA
RCDATA
RAWTEXT
CDATA
  • 当遇到特定标签时,解析器会切换模式。一旦解析器切换到 DATA 模式和 RCDATA 模式之外的模式时,一切字符都将作为文本节点被解析。当然,即使在 DATA 模式或 RCDATA 模式下,如果无法匹配标签节点、注释节点、CDATA 节点、插值节点,那么也会作为文本节点解析。

你可能对这段代码仍然有疑问,例如 while 循环何时停止?isEnd 函数的作用是什么?这里给出简单解释,parseChildren 函数是用来解析子节点的,因为 while 循环一定要遇到父级节点的结束标签才会停止,这是正常的思路。

我们可以通过一个例子来更加直观地了解 parseChildren 函数,以及其他解析函数在解析模板时的工作职责和工作流程。

const template = `<div>
  <p>Text1</>
  <p>Text2</>
</div>`
js

这里需要强调的是,在解析模板时,我们不能会忽略空白字符。这些空白字符包括:换行符(\n),回车符(\r),空格(''),制表符(\t)以及换页符(\f)。如果我们用加号 (+) 代表换行符,用减号 (-) 代表空格字符。那么上面的模板可以表示为。

const template = `<div>+--<p>Text1</p>+--<p>Text2</p>+</div>`
js

接下来,我们以这段模板作为输入来执行解析过程。

解析器一开始处于 DATA 模式。开始执行解析后,解析器遇到的第一个字符为 <,并且第二个字符能够匹配正则表达式 /a-z/i,所以解析器会进入标签节点状态,并调用 parseElement 函数进行解析。

parseElement 函数会做三件事:解析开始标签、解析子节点、解析结束标签。

function parseElement() {
  // 解析开始标签
  const element = parseTag()
  // 递归调用 parseChildren 函数进行 <div> 标签子节点的解析
  element.children = parseChildren()
  // 解析结束标签
  parseEndTag()

  return element
}
js

如果一个标签不是闭合标签,则可以认为,一个完整的标签元素是由开始标签、子节点和结束标签这三部分构成的。因此,在 parseElement 函数内,我们分别调用三个解析函数来处理这三部分内容。

  • parseTag 解析开始标签。parseTag 函数用于解析开始标签,包括开始标签上的属性和指令。因此,在 parseTag 解析函数执行完毕后,会消费字符串中的内容 <div>,处理后的模板内容将变为:

    const template = `+--<p>Text1</p>+--<p>Text2</p>+</div>`
    js
  • 递归地调用 parseChildren 函数解析子节点。parseElement 函数在解析开始标签时,会产生一个标签节点 element。在 parseElement 函数执行完毕后,剩下的模板内容应该作为 element 的子节点被解析,即 element.children。因此,我们要递归地调用 parseChildren 函数。在这个过程中,parseChildren 函数会消费字符串中的内容:+--<p>Text1</p>+--<p>Text2</p>+。处理后的模板内容将变为:

    const template = `</div>`
    js
  • parseEngTag 处理结束标签。在经过 parseChildren 函数处理后,模板内容只剩下一个结束标签。因此,只需要调用 parseEndTag 解析函数来消费它即可。

经过这三个步骤的处理后,这段模板就被解析完毕了,最终得到模板 AST。但这里值得注意的是,为了解析标签的子节点,我们递归地调用了 parseChildren 函数。这意味着,一个新的状态机开始运行,我们可以将其称之为 “状态机2”。“状态机2” 所处理的模板内容为:

const template = `+--<p>Text1</p>+--<p>Text2</p>+`
js

接下来,我们解析分析 “状态机2” 的状态迁移过程。在 “状态机2” 开始运行时,模板的第一个字符是换行符(字符+ 代表换行符)。因此,解析器会进入文本节点状态,并调用 parseText 函数完成文本节点的解析。parseText 函数会将下一个 < 字符之前的所有字符都视为文本节点内容的内容。 parseText 会消费模板内容 +--,并产生一个文本节点。在 parseText 解析函数执行完毕后,剩下的模板内容为:

const template = `<p>Text1</p>+--<p>Text2</p>+`
js

接着,parseChildren 函数继续执行。此时模板的第一个字符为 <,并且下一个字符能够匹配正则 /a-z/i。于是解析器再次进入 parseElement 解析函数的执行阶段,这会消费模板内容 <p>Text1</p>。这一步过后,剩下的模板内容为:

const template = `+--<p>Text2</p>+`
js

可以看到,此时模板的第一个字符是换行符,于是调用 parseText 函数消费模板内容 +--。现在,模板中剩下的内容是:

const template = `<p>Text2</p>+`
js

解析器会再次调用 parseElement 函数处理标签节点。在这之后,剩下的模板内容为:

const template = `+`
js

可以看到,现在模板内容只剩下一个换行符。parseChildren 函数会继续执行并调用 parseText 函数消费剩下的内容,并产生一个文本节点。最终,模板被解析完毕,“状态机2” 停止运行。

在 “状态机2” 运行期间,为了处理标签节点,我们又调用了两次 parseElement 函数。第一次调用用于处理内容 <p>Text1</p>,第二次调用用于处理内容 <p>Text2</p>。我们知道,parseElement 函数会递归地调用 parseChildren 函数完成子节点的解析,这就意味着解析器会再开启两个新的状态机。

通过上述例子我们能够认识到,parseChildren 解析函数是整个状态机的核心,状态迁移操作都在该函数内完成。在 parseChildren 函数运行过程中,为了处理标签节点,会调用 parseElement 解析函数,这会间接地调用 parseChildren 函数,并产生一个新的状态机。随着标签嵌套层次的增加,新的状态机会随着 parseChildren 函数被递归地调用而不断创建,这就是 “递归下降” 中 “递归” 二字的含义。而上级 parseChildren 函数的调用用于构造上级模板 AST 节点,被递归调用的下级 parseChildren 函数则用于构造下级模板 AST 节点。最终,会构造出一棵树形结构的模板 AST,这就是 “递归下降” 中 “下降” 二字的含义。

状态机的开启与停止

我们已经讨论了递归下降算法的含义。parseChildren 函数本质上是一个状态机,它会开启一个 while 循环使得状态机自动运行。

function parseChildren(context, ancestors) {
  // 定义 nodes 数组存储子节点,它将作为最终的返回值
  let nodes = []
  // 从上下文对象中取得当前状态,包括模式 mode 和模板内容 source
  const { mode, source } = context

  // 开始 while 循环,只要满足条件就会一直对字符串进行解析
  while (!isEnd(context, ancestors)) {
		// ...
  }

  // 当 while 循环停止后,说明子节点解析完毕,返回子节点
  return nodes
}
js

这里的问题在于,while 玄幻应该何时停止运行?这涉及到 isEnd() 函数的判断逻辑。为了搞清楚这个问题,我们需要模拟状态机的运行过程。

我们知道,在调用 parseElement 函数解析标签节点时,会递归地的调用 parseChildren 函数,从而开启新的状态机。

parser07.png

为了便于描述,我们可以把图中所示的新的状态机称为 “状态机1”。“状态机1” 开始运行,继续解析模板,直到遇到下一个 <p> 标签。

parser08.png

因为遇到 <p> 标签,所以 “状态机1” 也会调用 parseElement 函数进行解析。于是有重复上述过程,即把当前解析的标签节点压入父级节点栈,然后递归地调用 parseChildren 函数开启新的状态机,即 "状态机2“。可以看到,此时有两个状态机在同时运行。

此时 ”状态机2“ 拥有程序执行权,它持续解析模板知道遇到结束标签 </p> 。因为这是一个结束标签,并且在父级节点中存在与该结束标签同名的标签节点,所以 ”状态机2“ 会停止运行,并弹出父级节点栈中处于栈顶的节点。

parser09.png

此时 “状态机2” 已经停止运行,“状态机1” 继续工作。于是回解析解析模板,直到遇到下一个 <p> 标签。这是 “状态机1” 会再次调用 parseElement 函数解析标签节点,因此又会执行压栈并开启新的 “状态机3”。

parser10.png

此时 “状态机3” 拥有程序的执行权,它会继续解析模板,直到遇到结束标签 </p>。因为这是一个结束标签,并且在父级节点栈中存在与该结束标签同名的标签节点,所以 “状态机3” 会停止运行,并弹出父级节点栈中处于栈顶的节点。

parser11.png

当 “状态机3” 停止运行后,程序的执行权会交还给 “状态机1”。“状态机1” 会继续解析模板,直到遇到最后的 </div> 结束标签。这时 “状态机1” 会发现节点栈中存在与结束标签同名的标签节点,于是将该节点弹出父级节点栈,并停止运行。

parser12.png

这时父级节点栈为空,状态机全部停止运行,模板解析完毕。

通过上面的描述,我们能够清晰地认识到,解析器会在何时开启新的状态机,以及状态机会在何时停止。结论是:当解析器遇到开始标签时,会将该标签压入父级节点栈中,同时开启新的状态机。当解析器遇到结束标签,并且父级节点栈中存在与该标签同名的开始标签节点时,会停止当前正在运行的状态机。根据上述规则,我们可以给出 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
  }
}
js

上面这段代码展示了状态机的停止时机:

  • 第一个停止时是当模板内容被解析完毕时;
  • 第二个停止时机则是在遇到结束标签时,这时解析器会取得父级节点栈栈顶的节点作为父节点,检查该结束标签是否与父节点的标签同名,如果相同,则状态机停止运行。

这里需要注意的是,在第二个停止时机中,我们直接比较结束标签的名称与栈顶节点的标签名称。这么做确实可行,但严格来说也是有问题的。例如下面的模板所示:

<div><span></div></span>
html

观察上述模板,它存在一个明显的问题。这段模板有两种解释方式,下图给出了第一种。

parser13.png

这种解释方式的流程如下:

  • “状态机1’’ 遇到 <div> 开始标签,调用 parseElement 解析函数,这会开启 ”状态机 2“ 来完成子节点的解析。
  • ”状态机2“ 遇到 <span> 开始标签,调用 parseElement 解析函数,这回开启 “状态机3” 来完成子节点的解析。
  • “状态机3” 遇到 </div> 结束标签。由于此时父级节点栈顶的节点名称是 span,并不是 div,所以 状态机3 不会停止运行。这时,“状态机3” 遇到了不符合预期的状态,因为结束标签 </div> 缺少与之对应的开始标签,所以这时 “状态3” 会抛出错误:”无效的结束标签“。

上述流程的思路与我们当前的实现相符,状态机会遭遇不符合预期的状态。parseChildren 函数的代码可以体现这一点。

function parseChildren(context, ancestors) {
  // 定义 nodes 数组存储子节点,它将作为最终的返回值
  let nodes = []
  // 从上下文对象中取得当前状态,包括模式 mode 和模板内容 source
  const { mode, source } = context

  // 开始 while 循环,只要满足条件就会一直对字符串进行解析
  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] === '/') {
          // 结束标签,这里需要抛出错误
          console.error('无效的结束标签')
          continue
        } else if (/[a-z]/i.test(source[1])) {
          // 标签
          node = parseElement(context, ancestors)
        }
      } else if (source.startsWith('{{')) {
        // 解析插值
        node = parseInterpolation(context)
      }
    }
		
    // ...
  }

  // 当 while 循环停止后,说明子节点解析完毕,返回子节点
  return nodes
}
js

换句话来说,按照我们当前的实现思路来解析上述例子中的模板,最终得到的错误信息是:“无效的结束标签”。但其实还有另外一个更好的解析方式。观察上例中给出的模板,其实存在一段完整的内容。

parser14.png

从图中可以看到,模板中存在一段完整的内容,我们希望解析器可以正常对其进行解析,这很可能也是符合用户意图的。但实际上,无论是哪一种解析方式,对程序的影响都不大。两者的区别体现在错误处理上。对于第一种解析方式,我们得到的错误信息是:“无效的结束标签”。而对于第二种解析方式,在 “完整的内容” 部分被解析完毕后,解析器就会打印错误信息,“<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
    }
  }
}
js

按照新的思路再次对以下模板进行解析:

<div><span></div></span>
html

其流程如下:

  • ”状态机1“ 遇到 <div> 开始标签,调用 parseElement 解析函数,并开启 “状态机2” 解析子节点。
  • “状态机2” 遇到 <span> 开始标签,调用 parseElement 解析函数,并开启 “状态机3” 解析子节点。
  • ”状态机3“ 遇到 </div> 结束标签,由于节点栈中存在名为 div 的标签节点,于是 ”状态机3“ 停止了。

在这个过程中,”状态机2“ 在调用 parseElement 解析函数时,parseElement 函数能够发现 <span> 缺少闭合标签,于是会打印错误信息 ”<span> 标签缺少闭合标签 “。


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
}
js

解析标签节点

在上一节给出的 parseElement 函数的实现中,无论是解析开始标签还是闭合标签,我们都调用了 parseTag 函数。同时,我们使用 parseChildren 函数来解析开始标签和闭合标签中间的部分。

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
}
js

标签节点的整个解析过程如图所示:

parser15.png

这里需要注意的是,由于开始标签与结束标签的格式非常类似,所以我们统一使用 parseTag 函数处理,并通过该函数的第二个参数来指定具体的处理类型。当第二个参数值为字符串 ‘end’ 时,意外这解析的是结束标签。另外,无论处理的是开始标签还是结束标签,parseTag 函数都会消费对应的内容。为了实现对模板内容的消费,我们需要在上下文对象中新增两个工具函数。

function parse(str) {
  // 定义上下文对象
  const context = {
    // source 是模板内容,用于解析过程中进行消费
    source: str,
    // 解析器当前处于文本模式,初始模式为 DATA
    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)
      }
    }
  }

  // 调用 parseChildren 函数开始解析,它返回解析后得到的子节点
  // parseChildren 函数接收两个参数
  // 第一个参数是上下文对象 context
  // 第二个参数是由父代节点构成的节点栈,初始时栈为空
  const nodes = parseChildren(context, [])

  // 解析器返回 Root 根节点
  return {
    type: 'Root',
    // 使用 nodes 作为根节点的 children
    children: nodes
  }
}
js

在上面这段代码中,我们为上下文对象增加了 advanceBy 函数和 advanceSpace 函数。其中 advanceBy 函数用来消费指定数量的字符串。其实现原理很简单,即调用字符串的 slice 函数,根据指定位置截取剩余字符串,并使用截取后的结果作为新的模板内容。advanceSpaces 函数则用来消费无用的空白字符,因为标签中可能存在空白字符,例如在模板 <div---> 中减号(-)代表空白字符。

有了 advanceByadvanceSpaces 函数后,我们就可以给出 parseTag 函数的实现了。

// 由于 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
  }
}
js

上面这段代码有两个关键点。

  • 由于 parseTag 函数即用来解析开始标签,又用来解析结束标签,因此需要一个参数来标识当前处理的标签类型,即 type。
  • 对于开始标签和结束标签,用于匹配它们的正则表达式只有一点不同:结束标签是以字符串 </ 开头的。

下图给出了用于匹配开始标签的正则表达式的含义。

parser16.png

下面给出了几个使用图中正则来匹配的开始标签的例子。

  • 对于字符串 ‘<div>’ ,会匹配出字符串 ‘<div’,剩余 ‘>’。
  • 对于字符串 ‘<div/>’ ,会匹配出字符串 ‘<div’,剩余 ‘/>’。
  • 对于字符串 ‘<div---->’ ,其中减号(-) 代表空白字符,会匹配出字符串 ‘<div’,剩余 ‘---->’。
console.log(/^<([a-z][^\t\r\n\f />]*)/i.exec('<div>'))
// [ '<div', 'div', index: 0, input: '<div>', groups: undefined ]
console.log(/^<([a-z][^\t\r\n\f />]*)/i.exec('<div/>'))
// [ '<div', 'div', index: 0, input: '<div/>', groups: undefined ]
console.log(/^<([a-z][^\t\r\n\f />]*)/i.exec('<div    >'))
// [ '<div', 'div', index: 0, input: '<div    >', groups: undefined ]
js

除了正则表达式外,parseTag 函数的另外几个关键点如下:

  • 在完成正则匹配后,需要调用 advanceBy 函数消费由正则匹配的全部内容;
  • 根据上面给出的第三个正则匹配例子可知,由于标签中可能存在无用的空白字符,例如 <div----> ,因此我们需要调用 advanceSpaces 函数消费空白字符;
  • 在消费由正则匹配的的内容后,需要检查剩余模板内容是否以 /> 开头。如果是,则说明当前解析的是一个自闭合标签,这时需要将标签节点的 isSelfClosing 属性设置为 true;
  • 最后,判断标签是否自闭合。如果是,则调用 advanceBy 函数消费内容 />,否则只需要消费内容 > 即可。

在经过上述处理后,parseTag 函数会返回一个标签节点。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.RAETEXT
  } 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.log(`${element.tag} 标签缺少闭合标签`)
  }

  return element
}
js

<noembed> 元素是个废除的和不标准的方式,用于向不支持 <embed> ,或者不支持作者希望的 嵌入式内容 的浏览器提供替代(或者 “后备”)内容。这个元素在 HTML 4.01 起废除,以支持 <object>。后备内容应该插在 <object> 的开始和结束标签之间。

至此,我们就实现了对标签节点的解析。目前的实现不包括节点属性和指令的解析,后面我们会继续讲解。

解析属性

上一节中介绍的 parseTag 解析函数会消费整个开始标签,这意味着该函数需要有能力处理开始标签中存在属性与指令,例如:

<div id="foo" v-show="display"></div>

上面这段模板中的 div 标签存在一个 id 属性和一个 v-show 属性。为了处理属性和指定,我们需要在 parseTag 函数中增加 parseAttributes 解析函数。

// 由于 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()
  // 调用 parseAttributes 函数完成属性与执行的解析,并得到 props 数组
  // props 数组是由指令节点与属性节点共同组成的数组
  const props = parseAttributes(context)

  // 在消费匹配的内容后,如果字符串以 '/>' 开头,则说明这是一个自闭合标签
  const isSelfClosing = context.source.startsWith('/>')
  // 如果是自闭和标签,则消费 '/>',否则消费 '>'
  advanceBy(isSelfClosing ? 2 : 1)

  // 返回标签节点
  return {
    type: 'Element',
    // 标签名称
    tag,
    // 标签的属性暂时留空
    props: [],
    // 子节点留空
    children: [],
    // 是否自闭合
    isSelfClosing
  }
}
js

我们需要在消费标签的 “开始部分” 和无用的空白字符之后,再调用 parseAttribute 函数。举个例子,假设标签的内容如下:

<div id="foo" v-show="display" ></div>

标签的 “开始部分” 指的是字符串 <div,所以当消耗标签的 “开始部分” 以及无用空白字符后,剩下的内容为:

id="foo" v-show="display" >
js

上面这段内容才是 parseAttributes 函数要处理的内容。由于该函数只用来解析属性和指令,因为它会不断地消费上面这段模板内容,直到遇到标签的 “结束部分” 位置。其中,结束部分指的是字符 > 或者字符串 />

function parseAttributes(context) {
  // 用来存储解析过程中产生的属性节点和指定节点
  const props = []

  // 开始 while 循环,不断地消费模板内容,直至遇到标签的 "结束部分" 为止
  while (
    !context.source.startsWith('>') && 
    !context.source.startsWith('/>')
  ) {
    // 解析属性或指令
  }

  // 将解析结果返回
  return props
}
js

实际上,parseAttributes 函数消费模板内容的过程,就是不断地解析属性名称、等于号、属性值的过程。

parser17.png

parseAttributes 函数会按照从左到右的顺序不断地消费字符串。该函数的解析过程如下:

首先,解析出第一个属性的名称 id,并消费字符串 ‘id’。此时升序模板内容为:

="foo" v-show="display" >
js

在解析属性名称时,除了要消费属性名称之外,还要消费属性名称后面可能存在的空白字符。比如下面这段模板中,属性名称和等于号之间存在空白字符。

id  =  "foo" v-show="display" >
js

但无论如何,在属性名称解析完毕之后,模板剩余内容一定是以等于号开头的,即

=  "foo" v-show="display" >
js

如果消费属性名称之后,模板内容不以等于号开头,说明模板内容不合法,我们可以选择性抛出错误。

接着,我们需要消费等于号字符。由于等于号和属性值之间也可能存在空白字符,所以我们也需要消费对应的空白字符。在这一步操作过后,模板的剩余内容如下:

"foo" v-show="display" >
js

接下来,到了处理属性值的环节。模板中的属性值存在三种情况。

  • 属性值被双引号包裹:id="foo"
  • 属性值被单引号包裹:id='foo'
  • 属性值没有引号包裹:id=foo

按照上述例子,此时模板的内容一定以双引号(“)开头。因此我们可以通过检查当前模板内容是否以引号开头来确定属性值是否被引用。如果属性值被引号引用,则消费引号。此时模板的剩余内容为:

foo" v-show="display" >
js

既然属性值被引号引用,就意味着在剩余模板内容中,下一个引号之前的内容都应该被解析为属性值。在这个例子中,属性值的内容是字符串 foo。于是,我们消费属性值及其后面的引号。当前,如果属性值没有被引号引用,那么在剩余模板内容中,下一个空白字符串之前的所有字符都应该作为属性值。

当属性值和引号被消费之后,由于属性值与下一个属性名称之间可能存在空白字符,所以我们还要消费对应的空白字符。在这一步处理过后,剩余模板内容为:

v-show="display" >
js

可以看到,经过上述操作之后,第一个属性值就处理完毕了。

此时,模板中还剩下一个指令,我们只需重新执行上述步骤,即可完成 v-show 指令的解析。当 v-show 指令解析完毕后,将会遇到标签的 “结束部分”,即字符 >。这时,parseAttributes 函数的 while 循环将会停止,完成属性和指令的解析。

下面的 parseAttributes 函数给出了上述逻辑的具体实现:

function parseAttributes(context) {
  const { advanceBy, advanceSpaces } = context
  // 用来存储解析过程中产生的属性节点和指定节点
  const props = []

  // 开始 while 循环,不断地消费模板内容,直至遇到标签的 "结束部分" 为止
  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 enQuoteIndex = context.source.indexOf(quote)
      if (enQuoteIndex > -1) {
        // 获取下一个引号之前的内容作为属性值
        value = context.source.slice(0, enQuoteIndex)
        // 消费属性值
        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
}
js

在上面这段代码中,有两个重要的正则表达式:

  • /^[^\t\r\n\f />][^\t\r\n\f />=]*/,用来匹配属性名称;
  • /^[^\t\r\n\f >]+/,用来匹配没有使用引号引用的属性值。

我们分别来看下这两个正则表达式是如何工作的。

/^[^\t\r\n\f />][^\t\r\n\f />=]*/
=>
A:/^[^\t\r\n\f />]
B:[^\t\r\n\f />=]*/
js

我们可以将这个正则表达式分为 A、B 两个部分来看。

  • 部分 A 用于匹配一个位置,这个位置不能是空白字符,也不能是字符 / 或字符 >,并且字符串要以该位置开头。
  • 部分 B 用于匹配 0 个或多个位置,这些位置不能是空白字符,也不能是 /、>、=。这些位置不允许出现等于号(=)字符,这就实现了只匹配等于号之前的内容,即属性名称。

我们再来看第二个正则表达式的匹配原理。

/^[^\t\r\n\f >]+/
js

该正则表达式从字符串的开始位置开始匹配,并且会匹配一个或多个非空白字符、非字符 >。换句话说,该正则表达式会一直对字符串进行匹配,直到遇到空白字符或字符 > 为止,这就实现了属性值的提取。

配合 parseAttributes 函数,假设给出如下模板:

<div id="foo" v-show="display"></div>

解析上面这段模板,将会得到如下 AST:

const ast = {
  type: 'Root'm
  children: [
  	{
  		type: 'Element',
  		tag: 'div',
  		props: [
  			{ type: 'Attribute', name: 'id', value: 'foo' },
        { type: 'Attribute', name: 'v-show', value: 'display' }
  		]
		}
  ]
}
js

可以看到,在 div 标签接的 props 中,包含两个类型为 Attribute 的节点,这两个点就是 parseAttributes 函数的解析结果。

我们可以增加更多在 Vue.js 中常见的属性和指令进行测试,如以下模板所示:

<div :id="dynamicId" @click="handler" v-on:mousedown="onMouseDown"></div>

上面这段模板经过解析后,得到如下 AST。

const ast = {
  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' }
  		]
		}
  ]
}
js

可以看到,在类型为 Attribute 的属性节点中,其 name 字段完整地保留着模板中编写的属性名称。我们可以对属性名称做进一步分析,从而得到更具体的信息。例如,属性名称以字符 @ 开头,认为它是一个 v-on 指令绑定。我们甚至可以把 v- 开头的属性看作指令绑定,从而为它赋予不同的节点类型。例如:

// 指令,类型为 Directive
{ type: 'Directive', name: 'v-on:mousedown', value: 'onMouseDown' }
{ type: 'Directive', name: '@click', value: 'handler' }
// 普通属性
{ type: 'Attribute', name: 'id', value: 'foo' }
js

不仅如此,为了得到更加具体的信息,我们甚至可以进一步分析指令节点的数据,也可以设计更多语法规则,这完全取决于框架设计者在语法层面的设计,以及为框架赋予的能力。

代码地址

解析文本与解码 HTML 实体

解析文本

本节我们将讨论文本节点的解析。

const template = '<div>Text</div>'
js

解析器在解析上面这段模板时,会先经过 parseTag 函数的处理,这回消费标签的开始部分 ‘<div>’。处理完毕后,剩余模板内容为:

const template = 'Text</div>'
js

紧接着,解析器会调用 parseChildren 函数,开启一个新的状态机来处理这段模板。

parser18.png

我们来回顾一下状态机的状态迁移过程。状态机始于 “状态1”。在 ”状态1“ 下,读取模板的第一个字符 T,由于该字符既不是字符 <,有人不是插值定界符 {{,因此状态机会进入 “状态7”,即调用 parseText 函数处理文本内容。此时解析器会在模板中寻找下一个 < 字符或插值定界符 {{ 的位置索引,记为索引 I。然后,解析器会从模板的头部到索引 I 的位置截取位置,这段截取出来的字符串将作为文本节点的内容。以下面的模板内容为例:

const template = 'Text</div>'
js

parseText 函数会尝试在这段模板内容中找到第一个出现的字符 < 的位置索引。在这个例子中,字符 < 的索引值为 4。然后,parseText 函数会截取介于索引 [0, 4) 的内容作为文本内容。在这个例子中,文本内容就是字符串 ‘Text’。

假设模板存在插值,如下面的模板所示:

const template = 'Text-{{ val }}</div'
js

在处理这段模板时,parseText 函数会找到第一个插值定界符 {{ 出现的位置索引。在这个例子中,定界符的索引为 5。于是,parseText 函数会截取介于索引 [0, 5) 的内容作为文本内容。在这个例子中,文本内容就是字符串 ‘Text-’。

下面是 parseText 函数具体实现:

function parseText(context) {
  // endIndex 为文本内容得结尾索引,默认将整个模板剩余内容都作为文本内容
  let endIndex = context.source.length
  // 寻找字符 < 的位置索引
  const ltIndex = context.source.indexOf('<')
  // 寻找定界符 {{ 的位置索引
  const delimiterIndex = context.source.indexOf('{{')

  // 取 ltIndex 和当前 endIndex 中较小的一个作为新的结尾索引
  if (ltIndex > -1 && ltIndex < endIndex) {
    endIndex = ltIndex
  }
  // 取 delimiterIndex 和当前 endIndex 中较小的一个作为新的结尾索引
  if (delimiterIndex > -1 && delimiterIndex < endIndex) {
    endIndex = delimiterIndex
  }

  // 此时 endIndex 是最终的文本内容的结尾索引,调用 slice 函数截取文本内容
  const content = context.source.slice(0, endIndex)
  // 消耗文本内容
  context.advanceBy(content.length)

  // 返回文本节点
  return {
    // 节点类型
    type: 'Text',
    // 文本内容
    content
  }
}
js

如上面的代码所示,由于字符 < 与定界符 {{ 的出现顺序是未知的,所以我们需要取两者中较小的一个作为文本截取的终点。有了截取终点后,只需要调用字符串的 slice 函数对字符串进行截取即可,截取出来的内容就是文本节点的文本内容。最后,我们创建一个类型为 Text 的文本节点,将其作为 parseText 函数的返回值。

配合上述 parseText 函数解析如下模板:

const ast = parse('<div>Text</div>')
js

可以得到如下 AST:

const ast = {
  type: 'Root',
  children: [
    {
      type: 'Element',
      tag: 'div',
      props: [],
      isSelfConfig: false,
      children: [
        // 文本节点
        { type: 'Text', content: 'Text' }
      ]
    }
  ]
}
js

这样,我们就实现了对文本节点的解析。解析文本节点本身并不复杂,复杂点在于,我们需要对解析后的文本内容进行 HTML 实体的解码工作,为此,我们有必要先了解什么是 HTML 实体。

代码地址

解码命名字符引用

HTML 实体是一段以字符 & 开始的文本内容。实体用来描述 HTML 中的保留字符和一些难以通过普通键盘输入的字符,以及一些不可见的字符。例如,在 HTML 中,字符 < 具有特殊含义,如果希望以普通文本的方式显示字符 <,需要通过实体来表达。

<div>A&lt;B</div>
html

其中字符 A&lt;B 就是一个字符实体,用来表达字符 <。如果我们不用 HTML 实体,而是直接使用字符 <,那么将会产生非法的 HTML 内容:

<div>A<B</div>
html

这会导致浏览器的解析结果不符合预期。

HTML 实体总是以字符 & 开头,以字符 ; 结尾。在 Web 诞生初期,HTML 实体的数量较少,因此允许省略其中的尾分号。但随着 HTML 字符集越来越大,HTML 实体出现了包含的情况,例如 &lt&ltcc 都是合法的实体,如果不加分号,浏览器将无法区分它们。因此,WHATWG 规范中明确规定,如果不为实体加很好,将会产生解析错误。但考虑到历史原因,现在浏览器都能够解析早期规范中定义的那些可以省略分号的 HTML 实体。

HTML 实体有两类,一类叫做命名字符引用(named character reference),也叫做命名实体(named entity),顾名思义,这类实体具有特定的名称,例如上文中的 &ltWHATWG 规范中给出了全部的命名字符引用,有 2000 多个,可以通过命名字符引用表查询。

https://html.spec.whatwg.org/entities.json

除了命名字符引用之外,还有一类字符引用没有特定的名称,只能用数字表示,这类实体叫做数字字符引用(numeric character reference)。与命名字符引用不同,数字字符引用以字符串 &# 开头,比命名字符引用的开头部分多出了字符 #,例如 &#60; 。实际上,&#60; 对应的字符也是 <,换句话说,&#60;&lt 是等价的。数字字符引用即可以用十进制来表示,也可以使用十六进制来表示。例如,十进制数字的 60 对应的十六进制值为 3c,因此实体 &#60; 也可以表示为 &#x3c; 。可以看到,当使用十六进制表示实体时,需要以字符串 &#x 开头。

理解了 HTML 实体后,我们再来讨论为什么 Vue.js 模板的解析器要对文本节点中的 HTML 实体进行解码。为了理解这个问题,我们需要先明白一个大前提:在 Vue.js 模板中,文本节点所包含的 HTML 实体不会被浏览器解析。这是因为模板中的文本节点最终将通过 el.textContent 等文本操作方法设置到页面,而通过 el.textContent 设置的文本内容是不会经过 HTML 实体解码的。

el.textContent = '&lt';
js

最终 el 的文本内容将会原封不动地呈现为字符串 '&lt;',而不会呈现字符 <。这就意味着,如果用户在 Vue.js 模板中编写了 HTML 实体,而模板解析器不对其进行解码,那么最终渲染到页面的内容将不符合用户的预期。因此,我们应该在解析阶段对文本节点中存在的 HTML 实体进行解码。

模板解析器的解码行为应该与浏览器的行为一致。因此,我们应该按照 WHATWG 规范实现解码逻辑。规范中明确定义了解码 HTML 实体状态机的状态迁移流程。下图给出了简化版的状态状态迁移流程,我们会在后文中对其进行补充。

parser19.png

假定状态机当前处于初始的 DATA 模式。由图可知,当解析器遇到字符 & 时,会进入 “字符引用状态”,并消费字符 & ,接着解析下一个字符。如果下一个字符是 ASCII 字母或数字 ASCII alphanumeric ,则进入 “命名字符引用状态”,其中 ASCII 字母或数字指的是 0~9 这十个数字以及字符集合 a~z 再加上字符集合 A~z。当然,如果下一个字符是 #,则进入 “数字字符引用状态”。

一旦状态机进入命名字符引用状态,解析器会执行比较复杂的匹配流程。我们通过几个例子来直观地感受一个过程。假设文本内容为:

a&ltb
js

上面这段文本会被解析为:

a<b
js

我们可以来分析整个解析过程。

  • 首先,当解析器遇到字符 & 时,会进入字符引用状态。接着,解析下一个字符 l,这会使得解析器进入命名字符引用状态,并在命名引用表(简称 “引用表”)中查找以字符 l 开头的项。由于引用表中存在诸多以字符 l 开头的项,例如 ltlgle 等,因此解析器认为此时是 “匹配的”。
  • 于是开始解析下一个字符 t,并尝试去引用表中查找以 lt 开头的项。由于引用表中也存在多个以 lt 开头的项,例如 ltltcc;ltri; 等,因此解析器认为此时是 “匹配的”。
  • 于是又开始解析下一个字符 b,并尝试引用表中的查找以 ltb 开头的项,结果发现引用表中不存在符合条件的项,至此匹配结束。

当匹配结束时,解析器会检查最后一个匹配的字符。如果该字符是分号(;),则会产生一个合法的匹配,并渲染对应字符。但在上例中,最后一个匹配的字符是字符 t,并不是分号 (;),因此会生成一个解析错误,但由于历史原因,浏览器仍然能够解析它。在这种情况下,浏览器的解析规则是:最短原则。其中 “最短” 指的是命名字符引用的名称最短。举个例子,假设文本内容为:

a&ltcc;
js

我们知道 &ltcc; 是一个合法的命名字符引用,因此上述文本会被渲染为:a⪦。但如果去掉上述文本中的很好,即:

a&ltcc
js

解析器在处理这段文本中的实体时,最后匹配的字符将不再是分号,而是字符 c。按照 “最短原则”,解析器只会渲染名称更短的字符引用。在字符串 &ltcc 中,&lt 的名称要短于 &ltcc,因此最终会将 &lt 作为合法的字符引用来渲染,而字符串 cc 将作为普通字符来渲染。所以上面的文本最终渲染为:a<cc

需要说明的是,上述解析过程仅限于不用做属性值的普通文本。换句话说,用作属性值的文本会有不同的解析规则。举例来说,给出如下 HTML 文本:

<a href="foo.com?a=1&lt=2">foo.com?a=1&lt=2</a>
html

可以看到,a 标签和 href 属性值与它的文本子节点具有同样的内容,但它们被解析之后的结果不同。其中属性值中出现的 &lt 将原封不动地展示,而文本子节点中出现的 &lt 将会被解析为字符 <。这也是符合期望的,很明显,&lt=2 将构成链接中的查询参数,如果将其中的 &lt 解码为字符 <,将会破坏用户的 URL。实际上,WHATWG 规范中对此也有完整的定义,处于历史原因的考虑,对于属性值中的字符引用,如果最后一个匹配的字符不是分号,并且该匹配的字符的下一个字符是等于号、ASCII 字母或数字,那么该匹配项将作为普通文本被解析。

明白了原理,我们就着手实现。我们面临的第一个问题是,如何处理省略分号的情况?关于字符引用中的分号,我们可以总结如下:

  • 当存在分号时,执行完整匹配。
  • 当省略分号时,执行最短匹配。

为此,我们需要精心设计命名字符引用表。由于命名字符引用的数量非常多,因此我们这里只取一部分作为命名字符引用表的内容。

const namedCharacterReferences = {
  "gt": ">",
  "gt;": ">",
  "lt": "<",
  "lt;": "<",
  "ltcc;": "⪦"
}
js

上面这张表是经过精心设计的。观察 namedCharacterReferences 对象可以发现,相同的字符对应的实体会有多个,即带分号的版本和不带分号的版本,例如 “gt” 和 “gt;”。另外一些实体规则只有带分号的版本,因为这些实体不允许省略分号,例如 “ltcc;”。我们可以根据这张表来实现实体的解码逻辑。假设我们有如下文本内容:

a&ltccbbb
js

在解码这段文本时,我们首先根据字符 & 将文本分为两部分。

  • 一部分是普通文本 a
  • 一部分则是:&ltccbbb

对于普通文本,由于它不需要被解码,因此索引原封不动地保留。而对于可能是字符引用的部分,执行解码工作:

  • 第一步:计算出命名字符引用表中的实体名称的最大长度。由于在 namedCharacterReferences 对象中,名称最长的实体是 ltcc;,它具有 5 个字符,因此最大长度是 5。

  • 第二步:根据最大长度截取字符串 “ltccbbb”,即 'ltccbbb'.slice(0, 5),最终结果是:ltccb

  • 第三步:用截取后的字符串 ltccb 作为键去字符引用表中查询对应的值,即解码。由于引用表 namedCharacterReferences 中不存在键值为 ltccb 的项,因此不匹配。

  • 第四步:当发现不匹配时,我们将最大长度减 1,并重新执行第二步,直到知道匹配项位置。在上面这个例子中,最终的匹配项将会是 lt。因此,上述文本会被解码为:

    a<ccbbb
    js

这样,我们就实现了当字符引用省略分号时按照 “最短原则” 进行解码。

下面的 decodeHtml 函数给出了上述逻辑的具体实现:

// 第一个参数是要被解码的文本内容
// 第二个参数是一个布尔值,代表文本内容是否作为属性值
function decodeHtml(rawText, asAttr = false) {
  let offset = 0
  const end = rawText.length
  // 经过解码后的文本将作为返回值被返回
  let decodedText = ''
  // 引用表中的实体名称的最大长度
  let maxCRNameLength = 0

  // advance 函数用于消费指定长度的文本
  function advance(length) {
    offset += length
    rawText = rawText.slice(length)
  }

  // 消费字符串,知道处理完毕为止
  while (offset < end) {
    // 用于匹配字符引用的开始部分,如果匹配成功,那么 head[0] 的值会有三种可能
    // 1. head[0] === '&',这说明该字符引用是命名字符引用
    // 2. head[0] === '&#,这说明该字符引用是用十进制表示的数字字符引用
    // 3. head[0] === '&#x,这说明该字符引用是用十六进制表示的数字字符引用
    const head = /&(?:#x?)?/i.exec(rawText)
    // 如果没有匹配,说明已经没有需要解码内容
    if (!head) {
      // 计算剩余内容长度
      const remaining = end - offset
      // 将剩余内容加到 decodedText 上
      decodedText += rawText.slice(0, remaining)
      // 消费剩余内容
      advance(remaining)
      break
    }

    // head.index 为匹配的字符 & 在 rawText 中的位置索引
    // 截取字符 & 之前的内容加到 decodedText 上
    decodedText += rawText.slice(0, head.index)
    // 消费字符 & 之前的内容
    advance(head.index)

    // 如果满足条件,则说明是命名字符引用,否则为数字字符引用
    if (head[0] === '&') {
      let name = ''
      let value
      // 字符 & 的下一个字符必须是 ASCII 字母或数字,这样才是合法的命名字符引用
      if (/[0-9a-z]/i.test(rawText[1])) {
        // 根据引用表计算实体名称的最大长度
        if (!maxCRNameLength) {
          maxCRNameLength = Object.keys(namedCharacterReferences).reduce(
            (max, name) => Math.max(max, name.length),
            0
          )
        }
        // 从最大长度开始对文本进行截取,并试图去引用表中找到对应的项
        for (let length = maxCRNameLength; !value && length > 0; --length) {
          // 截取字符 & 到最大长度之间的字符作为实体名称
          name = rawText.substring(1, length)
          // 使用实体名称去索引表中查找对应项的值
          value = (namedCharacterReferences)[name]
        }
        // 如果找到对应项的值,说明解码成功
        if (value) {
          // 检查实体名称的最后一个匹配字符是否是分号
          const semi = name.endsWith(';')
          // 如果解码的文本作为属性值,最后一个匹配的字符不是分号
          // 并且最后一个匹配字符的下一个字符是等于号(=)、ASCII 字母或数字
          // 由于历史原因,将字符 & 和实体名称 name 作为普通文本
          if (
            asAttr &&
            !semi &&
            /[-a-z0-9]/i.test(rawText[name.length + 1] || '')
          ) {
            decodedText += '&' + name
            advance(1 + name.length)
          } else {
            // 其他情况下,正常使用解码后的内容拼接到 decodedText 上
            decodedText += value
            advance(1 + name.length)
          }
        } else {
          // 如果没有找到对应的值,说明解码失败
          decodedText += '&' + name
          advance(1 + name.length)
        }
      } else {
        // 如果字符 & 的下一个字符不是 ASCII 字母或数字,将字符 & 作为普通文本
        decodedText += '&'
        advance(1)
      }
    }
  }
  return decodedText
}
js

有了 decodeHtml 函数之后,我们就可以在解析文本节点时通过它对文本内容进行解码:

function parseText(context) {
 	// ...
  
  // 返回文本节点
  return {
    // 节点类型
    type: 'Text',
    // 文本内容
    content: decodeHtml(content) // 调用 decodeHtml 函数解码内容
  }
}
js

代码地址

解码数字字符引用

上一节中,我们使用下面的正则表达式来匹配一个文本引用的开始部分:

const head = /&(?:#x?)?/i.exec(rawText)
js

我们可以根据该正则的匹配结果,来判断字符引用的类型。

  • 如果 head[0] === '&',则说明匹配的是命名字符引用。
  • 如果 head[0] === '&#',则说明匹配的是以十进制表示的数字字符引用。
  • 如果 head[0] === '&#x',则说明匹配的是以十六进制表示的数字字符引用。

数字字符引用的格式是:前缀 + Unicode 码点。解码数字字符引用的关键在于,如何提取字符引用中的 Unicode 码点。考虑到数字字符引用的前缀可以是以十进制表示(&#),也可以是以十六进制表示(&#x),所以我们使用下面的代码来完成码点的提取。

// 判断是以十进制表示还是以十六进制表示
const hex = head[0] === '&#x'
// 根据不同进制表示法,选用不同的正则
const pattern = hex ? /^&#x([0-9a-f]+);?/i : /^&#([0-9]+);?/
// 最终,body[1] 的值就是 Unicode 码点
const body = pattern.exec(rawText)
js

有了 Unicode 码点之后,只需要调用 String.fromCodePoint 函数即可将其解码为对应的字符:

if (body) {
  // 根据对应的进制,将码点字符串转换为数字
  const cp = parseInt(body[1], hex : 16 : 10)
  // 解码
  const char = String.fromCodePoint(cp)
}
js

不过,在真正进行解码之前,需要对码点的值进行合法性检查。WHATWG 规范中对此也有明确的定义。

  • 如果码点为 0x00,即十进制的数字为 0,它在 Unicode 中代表空字符(NULL),这将是一个解析错误,解析器会将码点值替换成 0xFFFD
  • 如果码点值大于 0x10FFFF (0x10FFFF 为 Unicode 的最大值),这也是一个解析错误,解析器会将码点值替换为 0xFFFD
  • 如果码点值处于 代理对(surrogate pair) 范围内,这也是一个解析错误,解析器会将码点值替换为 0xFFFD,其中 surrogate pair 是预留给 UFT-16 的的码位,其范围是:[0xD800, 0xDFFF]
  • 如果码点值是 noncharacter,这也是一个解析错误,但什么都不需要做。这里的 noncharacter 代表 Unicode 永久保留的码点,用于 Unicode 内部,它的取值范围是:[0xFDD0, 0xFDEF],还包括:0xFFFE0xFFFF0x1FFFE0x1FFFF0x2FFFE0x3FFFE0x3FFFF0x4FFFE0x4FFFF0x5FFFE0x5FFFF0x6FFFE0x6FFFF0x7FFFE0x7FFFF0x8FFFE0x8FFFF0x9FFFE0x9FFFF0x10FFFE0x10FFFF0xAFFFE0xAFFFF0xBFFFE0xBFFFF0xCFFFE0xCFFFF0xDFFFE0xDFFFF0xEFFFE0xEFFFF0xFFFFE0xFFFFF
  • 如果码点对应的字符是回车符(0x0D),或者码点值为 控制字符集(control character) 中的非 ASCII 空白符(ASCII whitespace),则是一个解析错误。这时需要将码点作为索引,在下表中查找对应的替换码点:
const CCR_REPLACEMENTS = {
  0x80: 0x20ac,
  0x82: 0x201a,
  0x83: 0x0192,
  0x84: 0x201e,
  0x85: 0x2026,
  0x86: 0x2020,
  0x87: 0x2021,
  0x88: 0x02c6,
  0x89: 0x2030,
  0x8a: 0x0160,
  0x8b: 0x2039,
  0x8c: 0x0152,
  0x8e: 0x017d,
  0x91: 0x2018,
  0x92: 0x2019,
  0x93: 0x201c,
  0x94: 0x201d,
  0x95: 0x2022,
  0x96: 0x2013,
  0x97: 0x2014,
  0x98: 0x02dc,
  0x99: 0x2122,
  0x9a: 0x0161,
  0x9b: 0x203a,
  0x9c: 0x0153,
  0x9e: 0x017e,
  0x9f: 0x0178
}
js

如果存在对应的替换码点,则渲染该替换码点对应的字符,否则直接渲染原码点对应的字符。

上述关于码点合法性检查的代码具体实现如下:

if (body) {
  // 根据对应的进制,将码点字符串转换为数字
  let cp = parseInt(body[1], hex ? 16 : 10)

  // 检查码点的合法性
  if (cp === 0) {
    // 如果码点值为 0x00,替换为 0xfffd
    cp = 0xfffd
  } else if (cp > 0x10ffff) {
    // 如果码点值超过 Unicode 的最大值,替换为 0xfffd
    cp = 0xfffd
  } else if (cp >= 0xd800 && cp <= 0xdfff) {
    // 如果码点值处于 surrogdate pair 范围内,替换为 0xfffd
    cp = 0xfffd
  } else if ((cp >= 0xfdd0 && cp <= 0xfdef) || (cp & 0xfffe) === 0xfffe) {
    // 如果码点值处于 noncharacter 范围内,则什么都不做,交给平台处理
    // noop
  } else if (
    // 控制字符集的范围是:[0x01, 0x1f] 加上 [0x7f, 0x9f]
    // 去掉 ASICC 空白符:0x09(TAB)、0x0A(LF)、0x0c(FF)
    // 0x0D(CR) 虽然也是 ASICC 空白符,但需要包含
    (cp >= 0x01 && cp <= 0x08) || 
    cp === 0x0b || 
    (cp >= 0x0d && cp <= 0x1f) || 
    (cp >= 0x7f && cp <= 0x9f)
    ) {
      // 在 CCR_REPLACEMENTS 表中查找替换码点,如果找不到,则使用原码点
      cp = CCR_REPLACEMENTS[cp] || cp
    }
  }
  
  // 解码
  const char = String.fromCodePoint(cp)
} 
js

在这段代码中,我们完整地还原了码点合法性检查的逻辑,它有如下几个关键点。

  • 其中控制字符集(control character)的码点范围是:[0x01, 0x1f][0x7f, 0x9f]。这个码点范围包含了 ASCII 空白符:0x09(TAB)0x0A(LF)0x0C(FF)0x0D(CR) ,但 WHATWG 规范中要求包含 0x0D(CR)
  • 码点 0xfffd 对应的符号是 �。你一定在出现 “乱码” 的情况下见过这个字符,它是 Unicode 中的替换字符,通常表示在解码过程中出现 “错误”,例如使用了错误的解码方式等。

最后,我们将上述代码整合到 decodeHtml 函数中,这样就实现了一个完善的 HTML 文本解码函数。

// 第一个参数是要被解码的文本内容
// 第二个参数是一个布尔值,代表文本内容是否作为属性值
function decodeHtml(rawText, asAttr = false) {
  let offset = 0
  const end = rawText.length
  // 经过解码后的文本将作为返回值被返回
  let decodedText = ''
  // 引用表中的实体名称的最大长度
  let maxCRNameLength = 0

  // advance 函数用于消费指定长度的文本
  function advance(length) {
    offset += length
    rawText = rawText.slice(length)
  }

  // 消费字符串,知道处理完毕为止
  while (offset < end) {
    // ...

    // 如果满足条件,则说明是命名字符引用,否则为数字字符引用
    if (head[0] === '&') {
     	// ...
    } else {
      // 数字字符引用

      // 判断是以十进制表示还是以十六进制表示
      const hex = head[0] === '&#x'
      // 根据不同进制表示法,选用不同的正则
      const pattern = hex ? /^&#x([0-9a-f]+);?/i : /^&#([0-9]+);?/
      // 最终,body[1] 的值就是 Unicode 码点
      const body = pattern.exec(rawText)

      if (body) {
        // 根据对应的进制,将码点字符串转换为数字
        let cp = parseInt(body[1], hex ? 16 : 10)

        // 检查码点的合法性
        if (cp === 0) {
          // 如果码点值为 0x00,替换为 0xfffd
          cp = 0xfffd
        } else if (cp > 0x10ffff) {
          // 如果码点值超过 Unicode 的最大值,替换为 0xfffd
          cp = 0xfffd
        } else if (cp >= 0xd800 && cp <= 0xdfff) {
          // 如果码点值处于 surrogdate pair 范围内,替换为 0xfffd
          cp = 0xfffd
        } else if ((cp >= 0xfdd0 && cp <= 0xfdef) || (cp & 0xfffe) === 0xfffe) {
          // 如果码点值处于 noncharacter 范围内,则什么都不做,交给平台处理
          // noop
        } else if (
          // 控制字符集的范围是:[0x01, 0x1f] 加上 [0x7f, 0x9f]
          // 去掉 ASICC 空白符:0x09(TAB)、0x0A(LF)、0x0c(FF)
          // 0x0D(CR) 虽然也是 ASICC 空白符,但需要包含
          (cp >= 0x01 && cp <= 0x08) || 
          cp === 0x0b || 
          (cp >= 0x0d && cp <= 0x1f) || 
          (cp >= 0x7f && cp <= 0x9f)
          ) {
            // 在 CCR_REPLACEMENTS 表中查找替换码点,如果找不到,则使用原码点
            cp = CCR_REPLACEMENTS[cp] || cp
          }
  
          // 解码后追加到 decodedText 上
          decodedText += String.fromCodePoint(cp)
          // 消费整个数字字符引用的内容
          advance(body[0].length)
      } else{
        // 如果没有匹配,则不进行解码操作,只是把 head[0] 追加到 decodedText 上并消费
        decodedText += head[0]
        advance(head[0].length)
      }
    }
  }
  return decodedText
}
js

代码地址

解析插值

文本插值是 Vue.js 模板中用来渲染动态数据的常用方法:

{{ count }}

默认情况下,插值以字符串 {{ 开头,并以字符串 }} 结尾。我们通常将这个两个特殊的字符串称为定界符。定界符中间的内容可以是任意合法的 JavaScript 表达式,例如:

{{ obj.foo }}

{{ obj.fn() }}

解析器在遇到文本插值的起始定界符 ({{) 时,会进入文本 “插值状态6”,并调用 parseInterpolation 函数来解析插值内容。

parser18.png

解析器在解析插值时,只需要将文本插值的开始定界符和结束定界符之间的内容提取出来作为 JavaScript 表达式即可。

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)

  // 返回类型为 Interpolation 的节点,代表插值节点
  return {
    type: 'Interpolation',
    // 插值节点的 content 是一个 Expression 的表达式节点
    content: {
      type: 'Expression',
      // 表达式节点的内容则是经过 HTML 解码后的插值表达式
      content: decodeHtml(content)
    }
  }
}
js

配合上面的 parseInterpolation 函数,解析如下模板内容:

const ast = parse(`<div>foo {{ bar }}</div>`)
js

最终得到如下 AST:

{
  type: 'Root',
  children: [
    {
      type: 'Element',
      tag: 'div',
      props: [],
      children: [
        { type: 'Text', content: 'foo ' },
        {
          type: 'Interpolation',
          content: { type: 'Expression', content: ' bar ' }
        },
        { type: 'Text', content: ' baz' }
      ],
      isSelfClosing: false
    }
  ]
}
js

实现上述效果,我们还需要修改 parseChildren 的一处代码。我们需要实时获取 source 的值,不然在某些场景下,程序会陷入死循环。比如 '<div>foo {{ bar }} baz</div>,当我们消费 <div>foo 后,如果不实时获取 souce ,得到的值永远是 foo {{ bar }},就永远不会走到 parseInterpolation 解析插值逻辑。

function parseChildren(context, ancestors) {
  // 定义 nodes 数组存储子节点,它将作为最终的返回值
  let nodes = []
  
- // 从上下文对象中取得当前状态,包括模式 mode 和模板内容 source
- const { mode, source } = context

  // 开始 while 循环,只要满足条件就会一直对字符串进行解析
  while (!isEnd(context, ancestors)) {
    let node

+ 	// 从上下文对象中取得当前状态,包括模式 mode 和模板内容 source
+   const { mode, source } = context
    
    // 只有 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] === '/') {
          // 结束标签,这里需要抛出错误
          console.error('无效的结束标签')
          continue
        } else if (/[a-z]/i.test(source[1])) {
          // 标签
          node = parseElement(context, ancestors)
        }
      } else if (source.startsWith('{{')) {
        // 解析插值
        node = parseInterpolation(context)
      }
    }

    // node 不存在,说明处理其他模式,即非 DATA 模式且非 RCDATA 模式
    // 这时一切内容作为文本处理
    if (!node) {
      // 解析文本节点
      node = parseText(context)
    }

    // 将节点添加到 nodes 数组中
    nodes.push(node)
  }

  // 当 while 循环停止后,说明子节点解析完毕,返回子节点
  return nodes
}
diff

代码地址

解析注释

解析注释的思路和解析插值非常相似,如下面的 parseComment 函数所示:

// 解析注释
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 是一个 Expression 的表达式节点
    content
  }
}
js

配合 parseComment 函数,解析如下模板内容:

const ast = parse('<div><!-- comments --></div>')
js

最终得到如下 AST:

{
  type: 'Root',
  children: [
    {
      type: 'Element',
      tag: 'div',
      props: [],
      children: [
        { type: 'Comment', content: ' comments ' }
      ],
      isSelfClosing: false
    }
  ]
}
js

代码地址

总结

本篇文章中,我们首先讨论了解析器的文本模式及其对解析器的影响。文本模式指的是解析器在工作时缩进如的一些特殊状态,如 RCDATA 模式、CDATA 模式、RAWTEXT 模式,以及初始的 DATA 模式等。在不同模式下,解析器对文本的解析行为会有所不同。

接着,我们讨论了如何使用递归下降算法构造模板 AST。在 parseChildren 函数运行的过程中,为了处理标签节点,会调用 parseElement 解析函数,这会间接地调用 parseChildren 函数,并产生一个新的状态机。随着标签嵌套层次的增加,新的状态机也会随着 parseChildren 函数被递归地调用而不断创建,这就是 “递归下降” 中 “递归” 二字的含义。而上级 parseChildren 函数的调用用于构造上级模板 AST 节点,被递归调用的下级 parseChildren 函数则用于构造下级模板 AST 节点。最终会构造出一棵树形结构的模板 AST,这就是 “递归下降” 中 “下降” 两字的含义。

在解析模板构建 AST 的过程中,parseChildren 函数是核心。每次调用 parseChildren 函数,就意味着新状态机的开始。状态机的结束时机有两个。

  • 第一个停止时机是当模板内容解析完毕时。
  • 第二个停止时机是遇到结束标签时,这时解析器会取得父级节点栈栈顶的节点作为父节点,检查该结束标签是否与父节点的标签同名,如果相同,则状态机停止运行。

我们还讨论了文本节点的解析。解析文本节点本身并不复杂,它的复杂点在于,我们需要对解析后的文本内容进行 HTML 实体的解码工作。WHATWG 规范中也定义了解码 HTML 实体过程中的状态迁移流程。HTML 实体类型有两种,分别是命名字符引用和数字字符引用。命名字符引用的解码方案可以总结为两种。

  • 当存在分号时:执行完整匹配。
  • 当省略分号时:执行最短匹配。

对于数字字符引用,则需要按照 WHATWG 规范中定义的规则逐步实现。