9Vue中的html-parser.md 30 KB

Vue 中的 html-parser

本节中大量出现 `parse` 以及 `parser` 这两个单词,不要混淆这两个单词,`parse` 是动词,代表“解析”的过程,`parser` 是名词,代表“解析器”。

打开 src/compiler/parser/html-parser.js 文件,该文件的开头是一段注释:

/**
 * Not type-checking this file because it's mostly vendor code.
 */

/*!
 * HTML Parser By John Resig (ejohn.org)
 * Modified by Juriy "kangax" Zaytsev
 * Original code by Erik Arvidsson, Mozilla Public License
 * http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
 */

通过这段注释我们可以了解到,Vuehtml parser 的灵感来自于 John Resig 所写的一个开源项目:http://erik.eae.net/simplehtmlparser/simplehtmlparser.js,实际上,我们上一小节所讲的小例子就是在这个项目的基础上所做的修改。Vue 在此基础上做了很多完善的工作,下面我们就探究一下 Vue 中的 html parser 都做了哪些事情。

正则分析

代码正文的一开始,是两句 import 语句,以及定义的一些正则常量:

import { makeMap, no } from 'shared/util'
import { isNonPhrasingTag } from 'web/compiler/util'

// Regular Expressions for parsing tags and attributes
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// could use https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
// but for Vue templates we can enforce a simple charset
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
// #7298: escape - to avoid being pased as HTML comment when inlined in page
const comment = /^<!\--/
const conditionalComment = /^<!\[/

下面我们依次来看一下这些正则:

attribute

这与上之前我们讲解的小例子中所定义的正则的作用基本是一致的,只不过 Vue 所定义的正则更加严谨和完善,我们一起看一下这些正则的作用。首先是 attribute 常量:

// Regular Expressions for parsing tags and attributes
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/

attribute 顾名思义,这个正则的作用是用来匹配标签的属性(attributes)的,如下图所示:

我们在观察一个复杂表达式的时候,主要就是要观察它有几个分组(准确的说应该是有几个捕获的分组),通过上图我们能够清晰的看到,这个表达式有五个捕获组,第一个捕获组用来匹配属性名,第二个捕获组用来匹配等于号,第三、第四、第五个捕获组都是用来匹配属性值的,这是因为在 html 标签中有三种写属性值的方式:

  • 1、使用双引号把值引起来:class="some-class"
  • 2、使用单引号把值引起来:class='some-class'
  • 3、不适用引号:class=some-class

正因如此,需要三个正则分组分别匹配三种情况,我们可以对这个正则做一个测试,如下:

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
console.log('class="some-class"'.match(attribute))  // 测试双引号
console.log("class='some-class'".match(attribute))  // 测试单引号
console.log('class=some-class'.match(attribute))  // 测试无引号

对于双引号的情况,我们将得到以下结果:

[
    'class="some-class"',
    'class',
    '=',
    'some-class',
    undefined,
    undefined
]

数组共有从 05 六个元素,第 0 个元素是被整个正则所匹配的结果,从第 1 至第 5 个元素分别对应五个捕获组的匹配结果,我们可以看到,第 1 个元素对应第一个捕获组,匹配到了属性名(class);第 2 个元素对应第二个捕获组,匹配到了等号(=);第 3 个元素对应第三个捕获组,匹配到了带双引号的属性值;而第 4 和第 5 个元素分别对应第四和第五个捕获组,由于没有匹配到所以都是 undefined

所以通过以上结果我们很容易想到当属性值被单引号起来和不使用引号的情况,所得到的匹配结果是什么,变化主要就在匹配结果数组的第 345 个元素,匹配到哪种情况,那么对应的位置就是属性值,其他位置则是 undefined,如下:

// 对于单引号的情况
[
    'class="some-class"',
    'class',
    '=',
    undefined,
    'some-class',
    undefined
]
// 对于没有引号
[
    'class="some-class"',
    'class',
    '=',
    undefined,
    undefined,
    'some-class'
]
ncname

接下来一句代码如下:

// could use https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
// but for Vue templates we can enforce a simple charset
const ncname = '[a-zA-Z_][\\w\\-\\.]*'

首先给大家解释几个概念并说明一些问题:

  • 一、合法的 XML 名称是什么样的?

首先在 XML 中,标签是用户自己定义的,比如:<bug></bug>

正因为这样,所以不同的文档中如果定义了相同的元素(标签),就会产生冲突,为此,XML 允许用户为标签指定前缀:<k:bug></k:bug>,前缀是字母 k

除了前缀还可以使用命名空间,即使用标签的 xmlns 属性,为前缀赋予与指定命名空间相关联的限定名称:

<k:bug xmlns:k="http://www.xxx.com/xxx"></k:bug>

综上所述,一个合法的XML标签名应该是由 前缀冒号(:) 以及 标签名称 组成的:<前缀:标签名称>

  • 二、什么是 ncname

ncname 的全称是 An XML name that does not contain a colon (:) 即:不包含冒号(:)的 XML 名称。也就是说 ncname 就是不包含前缀的XML标签名称。大家可以在这里找到关于 ncname 的概念。

  • 三、什么是 qname

我们可以在 Vue 的源码中看到其给出了一个连接:https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName,其实 qname 就是:<前缀:标签名称>,也就是合法的XML标签。

了解了这些,我们再来看 ncname 的正则表达式,它定了 ncname 的合法组成,这个正则所匹配的内容很简单:*字母、数字或下划线开头,后面可以跟任意数量的字符、中横线和 .*。

qnameCapture

下一个正则是 qnameCaptureqnameCapture 同样是普通字符串,只不过将来会用在 new RegExp() 中:

const qnameCapture = `((?:${ncname}\\:)?${ncname})`

我们知道 qname 实际上就是合法的标签名称,它是有可选项的 前缀冒号 以及 名称 组成,观察 qnameCapture 可知它有一个捕获分组,捕获的内容就是整个 qname 名称,即整个标签的名称。

startTagOpen

startTagOpen 是一个真正使用 new RegExp() 创建出来的正则表达式:

const startTagOpen = new RegExp(`^<${qnameCapture}`)

用来匹配开始标签的一部分,这部分包括:< 以及后面的 标签名称,这个表达式的创建用到了上面定义的 qnameCapture 字符串,所以 qnameCapture 这个字符串中所设置的捕获分组,在这里同样适用,也就是说 startTagOpen 这个正则表达式也会有一个捕获的分组,用来捕获匹配的标签名称。

startTagClose
const startTagClose = /^\s*(\/?)>/

startTagOpen 用来匹配开始标签的 < 以及标签的名字,但是并不包过开始标签的闭合部分,即:> 或者 />,由于标签可能是一元标签,所以开始标签的闭合部分有可能是 />,比如:<br />,如果不是一元标签,此时就应该是:>

观察 startTagClose 可知,这个正则拥有一个捕获分组,用来捕获开始标签结束部分的斜杠:/

endTag
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)

endTag 这个正则用来匹配结束标签,由于该正则同样使用了字符串 qnameCapture,所以这个正则也拥有了一个捕获组,用来捕获标签名称。

doctype
const doctype = /^<!DOCTYPE [^>]+>/i

这个正则用来匹配文档的 DOCTYPE 标签,没有捕获组。

comment
// #7298: escape - to avoid being pased as HTML comment when inlined in page
const comment = /^<!\--/

这个正则用来匹配注释节点,没有捕获组。大家注意这句代码上方的注释,所以是:#7298。有兴趣的同学可以去 Vueissue 中搜索一下相关问题。在这之前实际上 comment 常量的值是 <!-- 而并不是 <!\--,之所以改成 <!\-- 是为了允许把 Vue 代码内联到 html 中,否则 <!-- 会被认为是注释节点。

conditionalComment
const conditionalComment = /^<!\[/

这个正则用来匹配条件注释节点,没有捕获组。

最后很重要的地点是,大家主要,这些正则表达式有一个共同的特点,即:*他们都是从一个字符串的开头位置开始匹配的,因为有 ^ 的存在*。

在这些正则常量的下面,有着这样一段代码:

let IS_REGEX_CAPTURING_BROKEN = false
'x'.replace(/x(.)?/g, function (m, g) {
  IS_REGEX_CAPTURING_BROKEN = g === ''
})

首先定义了变量 IS_REGEX_CAPTURING_BROKEN 且初始值为 false,接着使用一个字符串 'x'replace 函数用一个正则去获取捕获组的值,即:变量 g。我们观察字符串 'x' 和正则 /x(.)?/ 可以发现,该正则中的捕获组应该捕获不到任何内容,所以此时 g 的值应该是 undefined,但是在老版本的火狐浏览器中存在一个问题,此时的 g 是一个空字符串 '',并不是 undefined。所以变量 IS_REGEX_CAPTURING_BROKEN 的作用就是用来标识当前宿主环境是否存在该问题。这个变量我们后面会用到,其作用到时候再说。

常量分析

在这些正则的下面,定义了一些常量,如下:

// Special Elements (can contain anything)
export const isPlainTextElement = makeMap('script,style,textarea', true)
const reCache = {}

const decodingMap = {
  '&lt;': '<',
  '&gt;': '>',
  '&quot;': '"',
  '&amp;': '&',
  '&#10;': '\n',
  '&#9;': '\t'
}
const encodedAttr = /&(?:lt|gt|quot|amp);/g
const encodedAttrWithNewLines = /&(?:lt|gt|quot|amp|#10|#9);/g

上面这段代码中,包含 5 个常量,我们逐个来看。

首先是 isPlainTextElement 常量是一个函数,它是通过 makeMap 函数生成的,用来检测给定的标签名字是不是纯文本标签(包括:scriptstyletextarea)。

然后定义了 reCache 常量,它被初始化为一个空的 JSON 对象字面量。

再往下定义了 decodingMap 常量,它也是一个 JOSN 对象字面量,其中 key 是一些特殊的 html 实体,值则是这些实体对应的字符。在 decodingMap 常量下面的是两个正则常量:encodedAttrencodedAttrWithNewLines。可以发现正则 encodedAttrWithNewLines 会比 encodedAttr 多匹配两个 html 实体字符,分别是 &#10;&#9;。对于 decodingMap 以及下面两个正则的作用不知道大家能不能猜得到,其实我们 创建编译器 一节中有讲到 shouldDecodeNewlinesshouldDecodeNewlinesForHref 这两个编译器选项,当时我们就有针对这两个选项作用的讲解,可以再附录 platforms/web/util 目录下的工具方法全解 中查看。

所以这里的常量 decodingMap 以及两个正则 encodedAttrencodedAttrWithNewLines 的作用就是用来完成对 html 实体进行解码的。

再往下是这样一段代码:

// #5992
const isIgnoreNewlineTag = makeMap('pre,textarea', true)
const shouldIgnoreFirstNewline = (tag, html) => tag && isIgnoreNewlineTag(tag) && html[0] === '\n'

定义了两个常量,其中 isIgnoreNewlineTag 是一个通过 makeMap 函数生成的函数,用来检测给定的标签是否是 <pre> 标签或者 <textarea> 标签。这个函数被用在了 shouldIgnoreFirstNewline 函数里,shouldIgnoreFirstNewline 函数的作用是用来检测是否应该忽略元素内容的第一个换行符。什么意思呢?大家注意这两段代码上方的注释:// #5992,感兴趣的同学可以去 vueissue 中搜一下,大家就会发现,这两句代码的作用是用来解决一个问题,该问题是由于历史原因造成的,即一些元素会受到额外的限制,比如 <pre> 标签和 <textarea> 会忽略其内容的第一个换行符,所以下面这段代码是等价的:

<pre>内容</pre>

等价于:

<pre>
内容</pre>

以上是浏览器的行为,所以 Vue 的编译器也要实现这个行为,否则就会出现 issue #5992 或者其他不可预期的问题。明白了这些我们再看 shouldIgnoreFirstNewline 函数就很容易理解,这个函数就是用来判断是否应该忽略标签内容的第一个换行符的,如果满足:标签是 pre 或者 textarea 且 *标签内容的第一个字符是换行符*,则返回 true,否则为 false

isIgnoreNewlineTag 函数将被用于后面的 parse 过程,所以我们到时候再看,接着往下看代码,接下来定义了一个函数 decodeAttr,其源码如下:

function decodeAttr (value, shouldDecodeNewlines) {
  const re = shouldDecodeNewlines ? encodedAttrWithNewLines : encodedAttr
  return value.replace(re, match => decodingMap[match])
}

decodeAttr 函数是用来解码 html 实体的。它的原理是利用前面我们讲过的正则 encodedAttrWithNewLinesencodedAttr 以及 html 实体与字符一一对应的 decodingMap 对象来实现将 html 实体转为对应的字符。该函数将会在后面 parse 的过程中使用到。

parseHTML

接下来,将进入真正的 parse 阶段,这个阶段我们将看到如何将 html 字符串作为字符输入流,并且按照一定的规则将其逐步消化分解。这也是我们本节的重点,同时接下来我们要分析的函数也是 compiler/parser/html-parser.js 文件所导出的函数,即 parseHTML 函数,这个函数的内容非常多,但其实它还是很有条理的,下面就是对 parseHTML 函数的简化和注释,这能够让你更好的把握 parseHTML 函数的意图:

export function parseHTML (html, options) {
  // 定义一些常量和变量
  const stack = []
  const expectHTML = options.expectHTML
  const isUnaryTag = options.isUnaryTag || no
  const canBeLeftOpenTag = options.canBeLeftOpenTag || no
  let index = 0
  let last, lastTag

  // 开启一个 while 循环,循环结束的条件是 html 为空,即 html 被 parse 完毕
  while (html) {
    last = html
    
    if (!lastTag || !isPlainTextElement(lastTag)) {
      // 确保即将 parse 的内容不是在纯文本标签里 (script,style,textarea)
    } else {
      // 即将 parse 的内容是在纯文本标签里 (script,style,textarea)
    }

    // 将整个字符串作为文本对待
    if (html === last) {
      options.chars && options.chars(html)
      if (process.env.NODE_ENV !== 'production' && !stack.length && options.warn) {
        options.warn(`Mal-formatted tag at end of template: "${html}"`)
      }
      break
    }
  }

  // 调用 parseEndTag 函数
  parseEndTag()

  // advance 函数
  function advance (n) {
    // ...
  }

  // parseStartTag 函数用来 parse 开始标签
  function parseStartTag () {
    // ...
  }
  // handleStartTag 函数用来处理 parseStartTag 的结果
  function handleStartTag (match) {
    // ...
  }
  // parseEndTag 函数用来 parse 结束标签
  function parseEndTag (tagName, start, end) {
    // ...
  }
}

首先我们注意到 parseHTML 函数接收两个参数:htmloptions,其中 html 是要被 parse 的字符串,而 options 则是 parser 选项。

总体上说,我们可以把 parseHTML 函数分为三个部分,第一部分即函数开头定义的一些常量和变量,第二部分是一个 while 循环,第三部分则是 while 循环之后定义的一些函数。我们分别来看,首先是第一部分,也就是 parseHTML 函数开头所定义的常量和变量,如下:

const stack = []
const expectHTML = options.expectHTML
const isUnaryTag = options.isUnaryTag || no
const canBeLeftOpenTag = options.canBeLeftOpenTag || no
let index = 0
let last, lastTag

第一个常量是 stack,它被初始化为一个空数组,在 while 循环中处理 html 字符流的时候每当遇到一个非一元标签,都会将该开始标签 push 到该数组。那么它的作用是什么呢?大家思考一个问题:在一个 html 字符串中,如何判断一个非一元标签是否缺少结束标签?

假设我们有如下 html 字符串:

<article><section><div></section></article>

parse 这个字符串的时候,首先会遇到 article 开始标签,并将该标签入栈(pushstack 数组),然后会遇到 section 开始标签,并将该标签 push 到栈顶,接下来会遇到 div 开始标签,同样被压入栈顶,注意此时 stack 数组内包含三个元素,如下:

再然后便会遇到 section 结束标签,我们知道:最先遇到的结束标签,应该最后被压入 stack 栈,也就是说此时 stack 栈顶的元素应该是 section,但是我们发现事实上 stack 栈顶并不是 section 而是 div,这说明 div 元素缺少闭合标签。这就是检测 html 字符串中是否缺少闭合标签的原理。

讲完了 stack 常量,接下来第二个常量是 expectHTML,它的值被初始化为 options.expectHTML,也就是 parser 选项中的 expectHTML。它是一个布尔值,后面遇到的时候再讲解其作用。

第三个常量是 isUnaryTag,如果 options.isUnaryTag 存在则它的值被初始化为 options.isUnaryTag ,否则初始化为 no,即一个始终返回 false 的函数。其中 options.isUnaryTag 也是一个 parser 选项,用来检测一个标签是否是一元标签。

第四个常量是 canBeLeftOpenTag,它的值被初始化为 options.canBeLeftOpenTag(如果存在的话,否则初始化为 no)。其中 options.canBeLeftOpenTag 也是 parser 选项,用来检测一个标签是否是可以省略闭合标签的非一元标签。

上面提到的一些常量的值,初始化的时候其实是使用 parser 选项进行初始化的,这里的 parser 选项其实大部分与编译器选项相同,在前面的章节中我们是有讲过的。

除了常量,还定义了三个变量,分别是 index = 0last 以及 lastTag。其中 index 被初始化为 0,它标识着当前字符流的读入位置。变量 last 存储剩余还未 parsehtml 字符串,变量 lastTag 则始终存储着位于 stack 栈顶的元素。

接下来将进入第二部分,即开启一个 while 循环,循环的终止条件是 html 字符串为空,即 html 字符串全部 parse 完毕。while 循环的结构如下:

while (html) {
  last = html
  
  if (!lastTag || !isPlainTextElement(lastTag)) {
    // 确保即将 parse 的内容不是在纯文本标签里 (script,style,textarea)
  } else {
    // 即将 parse 的内容是在纯文本标签里 (script,style,textarea)
  }

  // 将整个字符串作为文本对待
  if (html === last) {
    options.chars && options.chars(html)
    if (process.env.NODE_ENV !== 'production' && !stack.length && options.warn) {
      options.warn(`Mal-formatted tag at end of template: "${html}"`)
    }
    break
  }
}

首先将在每次循环开始时将 html 的值赋给变量 last

last = html

我们可以发现,在 while 循环即将结束的时候,有一个对 lasthtml 这两个变量的比较:

if (html === last)

如果两者相等,则说明字符串 html 在经历循环体的代码之后没有任何改变,此时会把 html 字符串作为纯文本对待。接下来我们就着重讲解循环体中间的代码是如何 parse html 字符串的。循环体中间的代码都被包含在一个 if...else 语句块中:

if (!lastTag || !isPlainTextElement(lastTag)) {
  // 确保即将 parse 的内容不是在纯文本标签里 (script,style,textarea)
} else {
  // 即将 parse 的内容是在纯文本标签里 (script,style,textarea)
}

我们观察 if 语句块的判断条件:

!lastTag || !isPlainTextElement(lastTag)

如果上面的条件为真,则走 if 分支,否则将执行 else 分支。不过这句判断条件看上去有些难懂,没关系我们换一个角度,如果对该条件进行取反的话,则是:

lastTag && isPlainTextElement(lastTag)

取反后的条件就好理解多了,我们知道 lastTag 存储着 stack 栈顶的元素,而 stack 栈顶的元素应该就是最近一次遇到的一元标签的开始标签,所以以上条件为真等价于:最近一次遇到的非一元标签是纯文本标签(即:script,style,textarea 标签)。也就是说:当前我们正在处理的是纯文本标签里面的内容。那么现在就清晰多了,当处理纯文本标签里面的内容时,就会执行 else 分支,其他情况将执行 if 分支。

接下来我们就先从 if 分支开始说起,下面的代码是对 if 语句块的简化:

if (!lastTag || !isPlainTextElement(lastTag)) {
  let textEnd = html.indexOf('<')

  if (textEnd === 0) {
    // textEnd === 0 的情况
  }

  let text, rest, next
  if (textEnd >= 0) {
    // textEnd >= 0 的情况
  }

  if (textEnd < 0) {
    // textEnd < 0 的情况
  }

  if (options.chars && text) {
    options.chars(text)
  }
} else {
  // 省略 ...
}

简化后的代码看去上结构非常清晰,在 if 语句块的一开始定义了 textEnd 变量,它的值是html 字符串中左尖括号(<)第一次出现的位置,接着开始了对 textEnd 变量的一些列判断:

if (textEnd === 0) {
  // textEnd === 0 的情况
}

let text, rest, next
if (textEnd >= 0) {
  // textEnd >= 0 的情况
}

if (textEnd < 0) {
  // textEnd < 0 的情况
}

textEnd === 0 时,说明 html 字符串的第一个字符就是左尖括号,比如 html 字符串为:<div>asdf</div>,那么这个字符串的第一个字符就是左尖括号(<)。现在我们采用深度优先的方式去分析,所以我们暂时不关系 textEnd >= 0 以及 textEnd < 0 的情况,我们查看一下当 textEnd === 0 时的 if 语句块内的代码,如下:

if (textEnd === 0) {
  // Comment:
  if (comment.test(html)) {
    // 有可能是注释节点
  }

  if (conditionalComment.test(html)) {
    // 有可能是条件注释节点
  }

  // Doctype:
  const doctypeMatch = html.match(doctype)
  if (doctypeMatch) {
    // doctype 节点
  }

  // End tag:
  const endTagMatch = html.match(endTag)
  if (endTagMatch) {
    // 结束标签
  }

  // Start tag:
  const startTagMatch = parseStartTag()
  if (startTagMatch) {
    // 开始标签
  }
}

以上同样是对源码的简化,这样看上去更加清晰,我们知道当 textEnd === 0 时说明 html 字符串的第一个字符就是左尖括号(<),那么大家思考一下左尖括号开头的字符串,它可能是什么?其实通过上面代码中的一系列 if 判断分支大家应该能猜到:

  • 1、可能是注释节点:<!-- -->
  • 2、可能是条件注释节点:<![ ]>
  • 3、可能是 doctype<!DOCTYPE >
  • 4、可能是结束标签:</xxx>
  • 5、可能是开始标签:<xxx>
  • 6、可能只是一个单纯的字符串:<abcdefg
parse 注释节点

针对以上六中情况我们逐个来看,首先判断是否是注释节点:

// Comment:
if (comment.test(html)) {
  const commentEnd = html.indexOf('-->')

  if (commentEnd >= 0) {
    if (options.shouldKeepComment) {
      options.comment(html.substring(4, commentEnd))
    }
    advance(commentEnd + 3)
    continue
  }
}

对于注释节点的判断方法是使用正则常量 comment 进行判断,即:comment.test(html),对于 comment 正则常量我们在前面分析正则的部分已经讲过了,当时我提醒过大家一件事情,即这些正则常量有一个共同的特点:都是从字符串的开头位置开始匹配的,也就是说只有当 html 字符串的第一个字符是左尖括号(<)时才有意义。而现在我们分析的情况恰好是当 textEnd === 0,也就是说 html 字符串的第一个字符确实是左尖括号(<)。

所以如果 comment.test(html) 条件为真,则说明可能是注释节点,大家要注意关键字:可能是,为什么这么说呢?大家知道完整的注释节点不仅仅要以 <!-- 开头,还要以 --> 结尾,如果只以 <!-- 开头而没有 --> 结尾,那显然不是一个注释节点,所以首先要检查 html 字符串中 --> 的位置:

const commentEnd = html.indexOf('-->')

如果找到了 -->,则说明这确实是一个注释节点,那么就处理之,否则什么事情都不做。处理的代码如下:

if (options.shouldKeepComment) {
  options.comment(html.substring(4, commentEnd))
}
advance(commentEnd + 3)
continue

首先判断 parser 选项 options.shouldKeepComment 是否为真,如果为真则调用同为 parser 选项的 options.comment 函数,并将注释节点的内容作为参数传递。在 Vue 官方文档中可以找到一个叫做 comments 的选项,实际上这里的 options.shouldKeepComment 的值就是 Vue 选项 comments 的值,这一点当我们讲到生成抽象语法树(AST)的时候即可看到。

回过头来我们再次查看以上代码,我们看看这里是如何获取注释内容的:

html.substring(4, commentEnd)

通过调用字符串的 substring 方法截取注释内容,其中其实位置是 4,结束位置是 commentEnd 的值,用一张图表示将会更加清晰:

可以看到,最终获取到的内容是不包含注释节点的起始(<!--)和结束(-->)的。

这样一个注释节点就 parse 完毕了,那么完毕之后应该做什么呢?要做的很关键的一件事就是:将已经 parse 完毕的字符串剔除,也就是接下来调用的 advance 函数:

advance(commentEnd + 3)

该函数定义在 while 循环的下方,源码如下:

function advance (n) {
  index += n
  html = html.substring(n)
}

advance 函数接收一个 Number 类型的参数 n,我们刚刚说到:已经 parse 完毕的部分要从 html 字符串中剔除,而剔除的方式很简单,就是找到已经 parse 完毕的字符串的结束位置,然后执行 html = html.substring(n) 即可,这里的 n 就是所谓的结束位置。除此之外,我们发现 advance 函数还对 index 变量做了赋值:index += n,前面我们介绍变量的时候说到过,index 变量存储着字符流的读入位置,该位置是相对于原始 html 字符串的,所以每次都要更新。

那么对于注释节点,其执行的代码为:

advance(commentEnd + 3)

n 的值是 commentEnd + 3,还是用一张图来表示:

可以很容易的看到,经过 advance 函数后,新的 html 字符串将从 commentEnd + 3 的位置开始,而不再包含已经 parse 过的注释节点了。

最后还有一个很重要的步骤,即调用完 advance 函数之后,要执行 continue 跳过此次循环,由于此时 html 字符串已经是去掉了 parse 过的部分的新字符串了,所以开启下一次循环,重新开始 parse 过程。

parse 条件注释节点

如果没有命中注释节点,则什么都不会做,继续判断是否命中条件注释节点:

// http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
if (conditionalComment.test(html)) {
  const conditionalEnd = html.indexOf(']>')

  if (conditionalEnd >= 0) {
    advance(conditionalEnd + 2)
    continue
  }
}

类似对注释节点的判断一样,对于条件注释节点使用 conditionalComment 正则常量。但是如果条件 conditionalComment.test(html) 为真,只能说明可能是条件注释节点,因为条件注释节点除了要以 <![ 开头还必须以 ]> 结尾,所以在 if 语句块内第一句代码就是查找字符串 ]> 的位置:

const conditionalEnd = html.indexOf(']>')

如果没有找到,说明这不是一个条件注释节点,什么都不做。否则会作为条件注释节点对待,不过与注释节点不同,注释节点拥有 parser 选项 options.comment,在调用 advance 函数之前,会先将注释节点的内容传递给 options.comment 函数。而对于条件注释节点则没有相应的 parser 钩子,也就是说 Vue 模板永远都不会保留条件注释节点的内容,所以直接调用 advance 函数以及执行 continue 语句结束此次循环。

其中传递给 advance 函数的参数是 conditionalEnd 常量,它保存着条件注释结束部分在字符串中的位置,道理与 parse 注释节点时相同。