9Vue中的html-parser.md 13 KB

Vue 中的 html-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
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
const comment = /^<!--/

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

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 或者其他不可预期的问题。