大白话Vue源码系列(02):编译器初探
Vue 的编译器模块相对独立且简单,本篇就从这块入手,先把它干掉。
编译器代码入口文件
前面已经提到,Vue 项目中的 entry-runtime.js 文件是 Vue 用于构建 仅包含运行时 的源码文件,而 entry-runtime-with-compiler.js 是用于构建 同时包含编译器和运行时 的全功能文件。因此两个文件的差集必然就是编译器实现。
先看一下 entry-runtime.js 文件的内容:
import Vue from './runtime/index'
export default Vue
文件里总共就这两行代码。这样的话就基本确定编译器相关的代码就在 entry-runtime-with-compiler.js 文件里了,事实证明也确实是这样。
Vue.prototype.$mount
entry-runtime-with-compiler.js 文件里的关键代码是为 Vue 的 prototype 扩展了一个 $mount
方法,并将模板编译相关的工作都封装在了这个 $mount 方法里。
在具体深扒 $mount
方法的内部实现之前,有必要先看一下它的应用场景是怎样的,这样会更有助于理解它内部是怎么工作的。
例如下面一段 html 模板:
<div id="index">
<div>{{msg}}</div>
</div>
开发者可以通过如下操作使用 Vue 将上面这段模板编译成 render 函数:
let vm = new Vue({
data: {
msg: 'hello',
}
});
// 实例化 Vue 时 new Vue(options) 传入的 options 可通过 vm.$options 访问
console.log(vm.$options.render);
/* Console 输出:
* undefined
*/
vm.$mount('#index');
console.log(vm.$options.render);
/* Console 输出:
* ƒ anonymous() {
* with(this){return _c('div',{attrs:{"id":"index"}},[_c('div',[_v(_s(msg))])])}
* }
*/
可以看到在调用 $mount
方法之后已经生成了 Vue 的 render 函数。
更常用也更方便的用法是:
new Vue({
el: '#index',
data: {
msg: 'hello',
},
});
这两种写法是完全等价的。实际上,如果在实例化 Vue 的时候提供了 el 选项,Vue 也是在内部调用 $mount
方法进行编译的。
接下来就看看 $mount
方法的具体是怎么实现的,为了更加清晰地描述思路,以下均使用伪代码进行书写:
/**
* 作用:将 Vue 的 html 模板编译成 render 函数。
*
* 通过将 $mount 方法定义在 Vue 的 prototype 上,
* 使得每一个 new 出来的 Vue 实例都能使用 $mount 方法。
*/
Vue.prototype.$mount = function (el){
// options 是 new Vue(options) 提供的实参 options
const options = this.$options;
// 优先使用实例化 Vue 时提供 render 函数
if (options.render) {
// 已经是 render 函数了,因此不用做任何操作
return this;
// 如果没有提供 render 函数,则优先使用提供的 template 选项
}else if(options.template){
template = getOuterHTML(options.template);
// 如果既没有提供 render 函数,又没有 template 选项,就使用 el 选项
}else{
template = getOuterHTML(el);
}
// 编译 html 模板生成 render 函数,并赋给 options 的 render 选项
// 这也是为什么上面在调用 $mount 方法之后 vm.$options.render 的值发生了变化
options.render = compileToFunctions(template);
return this;
}
// 负责兼容多样化的输入形式并返回要处理的 html模板片段
function getOuterHTML(){/*...*/}
// 负责将 html模板片段编译成 render 函数
function compileToFunctions(el){/*...*/}
可以看到,如果实例化 Vue 的时候同时提供了 render、template、el 选项中的多个,则 Vue 使用的优先级是 render > template > el。
# getOuterHTML 函数
上面的 getOuterHTML
函数所做的工作就是兼容你使用 Vue 的各种姿势,比如:
{ el: '#index' }
{ el: document.querySelector('#index') }
{ template: '#index' }
{ template: '<div>{{msg}}</div>'}
你可以传 CSS 选择器,也可以直接传 DOM, 还可以传 html 片段,怎么玩你说了算。getOuterHTML
函数的返回值是 DOM 的 outerHTML,总之,它负责得到 html 模板片段。
至此一切仍然是在扯淡,上面的都只是前戏,现在还没进入真正的编译阶段。眼贼的同学估计已经看到了,上面的 compileToFunctions 函数才是真刀实枪负责编译的。
# compileToFunctions 函数
接下来就扒进去看看 compileToFunctions
是怎么把 getOuterHTML
获得的 html 模板片段编译成 render 函数的。
compileToFunctions
函数编译模板的过程主要分为三步:
- 将 html 模板解析成抽象语法树(AST)。
- 对 AST 做优化处理。
- 根据 AST 生成 render 函数。
什么是抽象语法树
抽象语法树(Abstract Syntax Tree) 是源代码语法结构的抽象表示,并以树这种数据结构进行描述。AST 属编译原理范畴,有比较成熟的理论基础,因此被广泛运用在对各种程序语言(JavaScript, C, Java, Python等等)的编译处理中。Vue 同样也是使用 AST 作为中间形式完成对 html 模板的编译。
构建 AST 的一般过程
首先看一下第一步,也就是 解析成 AST。但是在继续 Vue 模板如何生成 AST 之前,有必要先看一下 AST 的一般解析过程。
通常程序语言解析成 AST 的过程会分为两步:
- 词法分析(Lexical Analysis)
- 语法分析(Syntax Analysis)
拿咱最熟悉的 JavaScript 来说吧,比如下面一段程序:
let a = 1
词法分析器会把代码的字符序列转换为单词序列(tokens)。经过词法分析后就能得到如下一个词素列表:
[
{ type: 'Keyword', value: 'let' },
{ type: 'Identifier', value: 'a' },
{ type: 'Punctuator', value: '=' },
{ type: 'Numeric', value: '1' }
]
语法分析器会在词法分析的基础上将单词序列(tokens)组合成各类语法短语(语句、表达式等)。经过语法分析后即可得到 AST 的 JSON 格式:
{
type: "Program",
body: [
{
type: "VariableDeclaration",
declarations: [
{
type: "VariableDeclarator",
id: {
type: "Identifier",
name: "a"
},
init: {
type: "Literal",
value: 1,
raw: "1"
}
}
],
kind: "let"
}
],
sourceType: "script"
}
上面的英文单词大家不认识的自己去搜下翻译哈。JSON 是天然的树形结构,树形图想必诸位早就脑补出来了吧:
源代码生成的抽象语法树
以上是使用 Esprima 工具对 JS 代码进行词法分析和语法分析的结果。
这里有一个 在线的AST生成工具。
还有一个 AST树形图预览工具。
Vue 构建的 AST
扯了这么多,应该对抽象语法树有个模糊的概念了吧,这对理解 Vue 的 AST 构建过程就足够用了。
回到正题,Vue 的 html 模板比较特殊,因为它根本算不上是一门语言,而是基于 HTML 的声明式绑定。因此,Vue 生成的 AST 类似于大家已经非常熟悉且非常成熟的 DOM 树,实际上 Vue 也确实是仿照着 DOM 树进行解析的。只要你熟悉 DOM 树,Vue 生成的 AST 是灰常好看且简单的。如果连 DOM 树都不了解,那咱只能帮你到这里了,你一定是个假前端。
最后再次强调的一点是,Vue 编译器的编译结果是一个函数——Vue 的 render 函数,AST 只是方便处理的中间形式。
本篇完,将在下篇深究 Vue 构建 AST 的细节。
本系列会以每周一篇的速度持续更新,喜欢的小伙伴记得点关注哦。