# 词法分析 - 为生成AST做准备 在 [Vue的编译器初探](./80vue-compiler-start.md) 这一章节中,我们对 `Vue` 如何创建编译器,以及在这个过程中经历过的几个重要的函数做了分析,比如 `compileToFunctions` 函数以及 `compile` 函数,并且我们知道真正对模板进行编译工作的实际是 `baseCompile` 函数,而接下来我们的任务就是搞清楚 `baseCompile` 函数的内容。 `baseCompile` 函数是在 `src/compiler/index.js` 中作为 `createCompilerCreator` 函数的参数使用的,代码如下: ```js // `createCompilerCreator` allows creating compilers that use alternative // parser/optimizer/codegen, e.g the SSR optimizing compiler. // Here we just export a default compiler using the default parts. export const createCompiler = createCompilerCreator(function baseCompile ( template: string, options: CompilerOptions ): CompiledResult { const ast = parse(template.trim(), options) optimize(ast, options) const code = generate(ast, options) return { ast, render: code.render, staticRenderFns: code.staticRenderFns } }) ``` 可以看到 `baseCompile` 函数接收两个参数,分别是字符串模板(`template`)和选项参数(`options`),其中选项参数 `options` 我们已经分析过了,并且我们有对应的附录专门整理编译器的选项参数,可以在 [编译器选项整理](../appendix/compiler-options.md) 中查看。 `baseCompile` 函数很简短,由三句代码和一个 `return` 语句组成,这三句代码的作用如下: ```js // 调用 parse 函数将字符串模板解析成抽象语法树(AST) const ast = parse(template.trim(), options) // 调用 optimize 函数优化 ast optimize(ast, options) // 调用 generate 函数将 ast 编译成渲染函数 const code = generate(ast, options) ``` 最终 `baseCompile` 的返回值如下: ```js return { ast, render: code.render, staticRenderFns: code.staticRenderFns } ``` 可以看到,其最终返回了抽象语法树(`ast`),渲染函数(`render`),静态渲染函数(`staticRenderFns`),且 `render` 的值为 `code.render`,`staticRenderFns` 的值为 `code.staticRenderFns`,也就是说通过 `generate` 处理 `ast` 之后得到的返回值 `code` 是一个对象,该对象的属性中包含了渲染函数(**注意以上提到的渲染函数,都以字符串的形式存在,因为真正变成函数的过程是在 `compileToFunctions` 中使用 `new Function()` 来完成的**)。 而接下来我们将会花费很大的篇幅来聚焦在一句代码上,即下面这句代码: ```js const ast = parse(template.trim(), options) ``` 也就是 `Vue` 的 `parser`,它是如何将字符串模板解析为抽象语法树(`AST`)的。 ## 对 parser 的简单介绍 在说 `parser` 之前,我们先了解一下编译器的概念,简单的讲编译器就是将 `源代码` 转换成 `目标代码` 的工具。详细一点如下(引用自维基百科): > 它主要的目的是将便于人编写、阅读、维护的高级计算机语言所写作的 `源代码` 程序,翻译为计算机能解读、运行的低阶机器语言的程序。`源代码` 一般为高阶语言(High-level language),如Pascal、C、C++、C# 、Java等,而目标语言则是汇编语言或目标机器的目标代码(Object code)。 编译器所包含的概念很多,比如 词法分析(`lexical analysis`),句法分析(`parsing`),类型检查/推导,代码优化,代码生成...等等,且大学中已有专门的课程,而我们这里要讲的 `parser` 就是编译器中的一部分,准确的说,`parser` 是编译器对源代码处理的第一步。 `parser` 是把某种特定格式的文本转换成某种数据结构的程序,其中“特定格式的文本”可以理解为普通的字符串,而 `parser` 的作用就是将这个字符串转换成一种数据结构(通常是一个对象),并且这个数据结构是编译器能够理解的,因为编译器的后续步骤,比如上面提到的 句法分析,类型检查/推导,代码优化,代码生成 等等都依赖于该数据结构,正因如此我们才说 `parser` 是编译器处理源代码的第一步,并且这种数据结构是抽象的,我们常称其为抽象语法树,即 `AST`。 `Vue` 的编译器也不例外,大致也分为三个阶段,即:词法分析 -> 句法分析 -> 代码生成。在词法分析阶段 `Vue` 会把字符串模板解析成一个个的令牌(`token`),该令牌将用于句法分析阶段,在句法分析阶段会根据令牌生成一棵 `AST`,最后再根据该 `AST` 生成最终的渲染函数,这样就完成了代码的生成。按照顺序我们需要先了解的是词法分析阶段,看一看 `Vue` 是如何对字符串模板进行拆解的。 ## Vue 中的 html-parser 本节中大量出现 `parse` 以及 `parser` 这两个单词,不要混淆这两个单词,`parse` 是动词,代表“解析”的过程,`parser` 是名词,代表“解析器”。 回到 `baseCompile` 函数中的这句代码: ```js const ast = parse(template.trim(), options) ``` 由这句代码可知 `parse` 函数就是用来解析模板字符串的,最终生成 `AST`,根据文件头部的引用关系可知 `parse` 函数位于 `src/compiler/parser/index.js` 文件,打开该文件可以发现其的确导出了一个名字为 `parse` 的函数,如下: ```js export function parse ( template: string, options: CompilerOptions ): ASTElement | void { // 省略... parseHTML(template, { warn, expectHTML: options.expectHTML, isUnaryTag: options.isUnaryTag, canBeLeftOpenTag: options.canBeLeftOpenTag, shouldDecodeNewlines: options.shouldDecodeNewlines, shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref, shouldKeepComment: options.comments, start (tag, attrs, unary) { // 省略... }, end () { // 省略... }, chars (text: string) { // 省略... }, comment (text: string) { // 省略... } }) return root } ``` 同时我们注意到在 `parse` 函数内部主要通过调用 `parseHTML` 函数对模板字符串进行解析,实际上 `parseHTML` 函数的作用就是用来做词法分析的,而 `parse` 函数的作用则是在词法分析的基础上做句法分析从而生成一棵 `AST`。本节我们主要分析一下 `Vue` 是如何对模板字符串进行词法分析的,也就是 `parseHTML` 函数的实现。 根据文件头部的引用关系可知 `parseHTML` 函数来自 `src/compiler/parser/html-parser.js` 文件,实际上整个 `html-parser.js` 文件所做的事情都是在做词法分析,接下来我们就研究一下它是如何实现的。打开该文件,其开头是一段注释: ```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 */ ``` 通过这段注释我们可以了解到,`Vue` 的 `html parser` 是 `fork` 自 [John Resig 所写的一个开源项目:http://erik.eae.net/simplehtmlparser/simplehtmlparser.js](http://erik.eae.net/simplehtmlparser/simplehtmlparser.js),`Vue` 在此基础上做了很多完善的工作,下面我们就探究一下 `Vue` 中的 `html parser` 都做了哪些事情。 ## 正则分析 代码正文的一开始,是两句 `import` 语句,以及定义的一些正则常量: ```js 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 = /^]+>/i // #7298: escape - to avoid being pased as HTML comment when inlined in page const comment = /^\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ ``` `attribute` 顾名思义,这个正则的作用是用来匹配标签的属性(`attributes`)的,如下图所示: ![](http://ovjvjtt4l.bkt.clouddn.com/2017-12-04-111.jpg) 我们在观察一个复杂的正则表达式时,主要就是要观察它有几个分组(准确地说应该是有几个捕获的分组),通过上图我们能够清晰地看到,这个表达式有五个捕获组,第一个捕获组用来匹配属性名,第二个捕获组用来匹配等于号,第三、第四、第五个捕获组都是用来匹配属性值的,同时 `?` 表明第三、四、五个分组是可选的。 这是因为在 `html` 标签中有4种写属性值的方式: * 1、使用双引号把值引起来:`class="some-class"` * 2、使用单引号把值引起来:`class='some-class'` * 3、不使用引号:`class=some-class` * 4、单独的属性名:`disabled` 正因如此,需要三个正则分组并配合可选属性来分别匹配四种情况,我们可以对这个正则做一个测试,如下: ```js 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)) // 测试无引号 console.log('disabled'.match(attribute)) // 测试无属性值 ``` 对于双引号的情况,我们将得到以下结果: ```js [ 'class="some-class"', 'class', '=', 'some-class', undefined, undefined ] ``` 数组共有从 `0` 到 `5` 六个元素,第 `0` 个元素是被整个正则所匹配的结果,从第 `1` 至第 `5` 个元素分别对应五个捕获组的匹配结果,我们可以看到,第 `1` 个元素对应第一个捕获组,匹配到了属性名(`class`);第 `2` 个元素对应第二个捕获组,匹配到了等号(`=`);第 `3` 个元素对应第三个捕获组,匹配到了带双引号的属性值;而第 `4` 和第 `5` 个元素分别对应第四和第五个捕获组,由于没有匹配到所以都是 `undefined`。 所以通过以上结果我们很容易想到当属性值被单引号引起来和不使用引号的情况下所得到的匹配结果是什么,变化主要就在匹配结果数组的第 `3`、`4`、`5` 个元素,匹配到哪种情况,那么对应的位置就是属性值,其他位置则是 `undefined`,如下: ```js // 对于单引号的情况 [ "class='some-class'", 'class', '=', undefined, 'some-class', undefined ] // 对于没有引号 [ 'class=some-class', 'class', '=', undefined, undefined, 'some-class' ] // 对于单独的属性名 [ 'disabled', 'disabled', undefined, undefined, undefined, undefined ] ``` ### ncname 接下来一句代码如下: ```js // 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 中,标签是用户自己定义的,比如:``。 正因为这样,所以不同的文档中如果定义了相同的元素(标签),就会产生冲突,为此,XML 允许用户为标签指定前缀:``,前缀是字母 `k`。 除了前缀还可以使用命名空间,即使用标签的 `xmlns` 属性,为前缀赋予与指定命名空间相关联的限定名称: ```html ``` 综上所述,一个合法的XML标签名应该是由 `前缀`、`冒号(:)` 以及 `标签名称` 组成的:`<前缀:标签名称>` * 二、什么是 `ncname`? `ncname` 的全称是 `An XML name that does not contain a colon (:)` 即:不包含冒号(`:`)的 XML 名称。也就是说 `ncname` 就是不包含前缀的XML标签名称。大家可以在这里找到关于 [ncname](https://msdn.microsoft.com/zh-cn/library/ms256452.aspx) 的概念。 * 三、什么是 `qname`? 我们可以在 `Vue` 的源码中看到其给出了一个链接:[https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName](https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName),其实 `qname` 就是:`<前缀:标签名称>`,也就是合法的XML标签。 了解了这些,我们再来看 `ncname` 的正则表达式,它定义了 `ncname` 的合法组成,这个正则所匹配的内容很简单:*字母或下划线开头,后面可以跟任意数量的字符、中横线和 `.`*。 ### qnameCapture 下一个正则是 `qnameCapture`,`qnameCapture` 同样是普通字符串,只不过将来会用在 `new RegExp()` 中: ```js const qnameCapture = `((?:${ncname}\\:)?${ncname})` ``` 我们知道 `qname` 实际上就是合法的标签名称,它是由可选项的 `前缀`、`冒号` 以及 `名称` 组成,观察 `qnameCapture` 可知它有一个捕获分组,捕获的内容就是整个 `qname` 名称,即整个标签的名称。 ### startTagOpen `startTagOpen` 是一个真正使用 `new RegExp()` 创建出来的正则表达式: ```js const startTagOpen = new RegExp(`^<${qnameCapture}`) ``` 用来匹配开始标签的一部分,这部分包括:`<` 以及后面的 `标签名称`,这个表达式的创建用到了上面定义的 `qnameCapture` 字符串,所以 `qnameCapture` 这个字符串中所设置的捕获分组,在这里同样适用,也就是说 `startTagOpen` 这个正则表达式也会有一个捕获的分组,用来捕获匹配的标签名称。 ### startTagClose ```js const startTagClose = /^\s*(\/?)>/ ``` `startTagOpen` 用来匹配开始标签的 `<` 以及标签的名字,但是并不包括开始标签的闭合部分,即:`>` 或者 `/>`,由于标签可能是一元标签,所以开始标签的闭合部分有可能是 `/>`,比如:`
`,如果不是一元标签,此时就应该是:`>` 观察 `startTagClose` 可知,这个正则拥有一个捕获分组,用来捕获开始标签结束部分的斜杠:`/`。 ### endTag ```js const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) ``` `endTag` 这个正则用来匹配结束标签,由于该正则同样使用了字符串 `qnameCapture`,所以这个正则也拥有了一个捕获组,用来捕获标签名称。 ### doctype ```js const doctype = /^]+>/i ``` 这个正则用来匹配文档的 `DOCTYPE` 标签,没有捕获组。 ### comment ```js // #7298: escape - to avoid being pased as HTML comment when inlined in page const comment = /^', '"': '"', '&': '&', ' ': '\n', ' ': '\t' } const encodedAttr = /&(?:lt|gt|quot|amp);/g const encodedAttrWithNewLines = /&(?:lt|gt|quot|amp|#10|#9);/g ``` 上面这段代码中,包含 `5` 个常量,我们逐个来看。 首先 `isPlainTextElement` 常量是一个函数,它是通过 `makeMap` 函数生成的,用来检测给定的标签名字是不是纯文本标签(包括:`script`、`style`、`textarea`)。 然后定义了 `reCache` 常量,它被初始化为一个空的 `JSON` 对象字面量。 再往下定义了 `decodingMap` 常量,它也是一个 `JSON` 对象字面量,其中 `key` 是一些特殊的 `html` 实体,值则是这些实体对应的字符。在 `decodingMap` 常量下面的是两个正则常量:`encodedAttr` 和 `encodedAttrWithNewLines`。可以发现正则 `encodedAttrWithNewLines` 会比 `encodedAttr` 多匹配两个 `html` 实体字符,分别是 ` ` 和 ` `。对于 `decodingMap` 以及下面两个正则的作用不知道大家能不能猜得到,其实我们讲解编译器的创建时有讲到 `shouldDecodeNewlines` 和 `shouldDecodeNewlinesForHref` 这两个编译器选项,当时我们就有针对这两个选项的作用做讲解,可以在附录 [platforms/web/util 目录下的工具方法全解](../appendix/web-util.md) 中查看。 所以这里的常量 `decodingMap` 以及两个正则 `encodedAttr` 和 `encodedAttrWithNewLines` 的作用就是用来完成对 `html` 实体进行解码的。 再往下是这样一段代码: ```js // #5992 const isIgnoreNewlineTag = makeMap('pre,textarea', true) const shouldIgnoreFirstNewline = (tag, html) => tag && isIgnoreNewlineTag(tag) && html[0] === '\n' ``` 定义了两个常量,其中 `isIgnoreNewlineTag` 是一个通过 `makeMap` 函数生成的函数,用来检测给定的标签是否是 `
` 标签或者 `'
```

该字符串是一个 `textarea` 标签并包含了一些文本,在解析这段字符串的时候首先会遇到开始标签 ``,接着以新的 `html` 字符串重新执行 `while` 循环,此时当遇到如下 `if` 语句块时:

```js
if (!lastTag || !isPlainTextElement(lastTag)) {
  // 省略...
} else {
  // 省略...
}
```

由于 `lastTag` 的值为 `textarea`,并且 `textarea` 标签为纯文本标签,所以会执行 `else` 分支的代码。在 `else` 语句块内首先定义了一个变量和两个常量,如下:

```js
let endTagLength = 0
const stackedTag = lastTag.toLowerCase()
const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(]*>)', 'i'))
```

变量 `endTagLength` 的初始值为 `0`,后面我们会看到 `endTagLength` 变量用来保存纯文本标签闭合标签的字符长度。`stackedTag` 常量的值为纯文本标签的小写版,`reStackedTag` 常量稍微复杂一些,它的值是一个正则表达式实例,并且使用 `reCache[stackedTag]` 做了缓存,我们看一下 `reStackedTag` 正则的作用是什么,如下:

```js
new RegExp('([\\s\\S]*?)(]*>)', 'i'))
```

该正则表达式中使用到了 `stackedTag` 常量,我们假设纯文本标签是 `textarea`,那么 `stackedTag` 常量的值也应该是 `textarea`,所以此时正则表达式应该为:

```js
new RegExp('([\\s\\S]*?)(]*>)', 'i'))
```

该正则表达式由两个分组组成,我们先看第一个分组,`\s` 用来匹配空白符,而 `\S` 则用来匹配非空白符,由于二者同时存在于中括号(`[]`)中,所以它匹配的是二者的并集,也就是字符全集,大家注意中括号后面的 `*?`,其代表懒惰模式,也就是说只要第二个分组的内容匹配成功就立刻停止匹配。可以发现第一个分组的内容用来匹配纯文本标签的内容。第二个分组很简单它用来匹配纯文本标签的结束标签。总的来说正则 `reStackedTag` 的作用是用来匹配纯文本标签的内容以及结束标签的。

接着代码来到这里:

```js
const rest = html.replace(reStackedTag, function (all, text, endTag) {
  endTagLength = endTag.length
  if (shouldIgnoreFirstNewline(stackedTag, text)) {
    text = text.slice(1)
  }
  if (options.chars) {
    options.chars(text)
  }
  return ''
})
```

这段代码使用正则 `reStackedTag` 匹配字符串 `html` 并将其替换为空字符串,我们可以注意到 `replace` 函数的回调函数返回值为空字符串。还是拿前面的例子,此时 `html` 的值为字符串 `aaaabbbb`,可以看到该字符串将被 `reStackedTag` 正则完全匹配,并将其替换为空字符串,所以最终 `rest` 常量的值就为空字符串。但是假如 `html` 字符串为 `aaaabbbbddd`,我们发现在 `` 标签的后面还有三个字符 `ddd`,如果这个字符串使用 `reStackedTag` 进行匹配替换,可知常量 `rest` 的值将是字符串 `ddd`,总之常量 `rest` 将保存剩余的字符。

接下来我们看一下 `replace` 函数的回调函数内的代码,回调函数接收三个参数,其中参数 `all` 保存着整个匹配的字符串,即:`aaaabbbb`。参数 `text` 为第一个捕获组的值,也就是纯文本标签的内容,即:`aaaabbbb`。参数 `endTag` 保存着结束标签,即:``。在回调函数内部,首先使用结束标签的字符长度更新了 `endTagLength` 的值,然后执行了一个 `if` 语句块,如下:

```js
if (shouldIgnoreFirstNewline(stackedTag, text)) {
  text = text.slice(1)
}
```

我们前面遇到过类似的 `if` 语句块,其作用是忽略 `
` 标签和 `