前面几节我们从new Vue初始化开始,分别介绍了选项的配置合并,设置vue实例代理以及初始化生命周期等操作,按照vue源码的设计思路,接下来过程还会进行初始化事件中心,初始化渲染,初始化数据并建立响应式系统的操作,最终vue会将模板和数据渲染成为最终的DOM。然而在初始化数据和建立响应式系统的过程中,我们需要弄清楚数据是如何驱动模板乃至数据更新如何驱动视图更新。为了弄清楚这一点,我们需要知道Vue的模板渲染流程,因此我把vue挂载,渲染模板数据到视图的过程放到前面进行分析。往后的几个小节也会围绕这一块内容展开。
3.1 Runtime Only VS Runtime + Compiler
在正文开始之前,先了解vue基于源码构建的两个版本,一个是runtime only
,另一个是runtime加compiler
的版本,两个版本的主要区别在于后者的源码包括了一个编译器。
什么是编译器,百度百科上面的解释是
简单讲,编译器就是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级语言)”的程序。一个现代编译器的主要工作流程:源代码 (source code) → 预处理器 (preprocessor) → 编译器 (compiler) → 目标代码 (object code) → 链接器 (Linker) → 可执行程序 (executables)。
通俗点讲,编译器是一个提供了将源代码转化为目标代码的工具。更进一步理解,vue内置的编译器实现了将.vue
文件转换编译为可执行javascript脚本的功能。
3.1.1 Runtime + Compiler
一个完整的vue版本是包含编译器的,我们可以使用template
进行模板编写。编译器会自动将模板编译成render 函数。
// 需要编译器的版本
new Vue({
template: '<div>{{ hi }}</div>'
})
复制代码
3.1.2 Runtime Only
而对于一个不包含编译器的runtime-only
版本,需要传递一个编译好的render
函数,如下所示:
// 不需要编译器
new Vue({
render (h) {
return h('div', this.hi)
}
})
复制代码
很明显,编译过程对性能有一定的损耗,并且由于加入了编译过程的代码,vue代码体积也更加庞大,所以我们可以借助webpack的vue-loader工具进行编译,将编译阶段从vue的构建中剥离出来,这样既优化了性能,也缩小了体积。
3.2 挂载的基本思路
vue挂载的流程是比较复杂的,我们通过流程图理清基本的实现思路。
如果用一句话概括挂载的过程,可以描述为挂载组件,将渲染函数生成虚拟DOM,更新视图时,将虚拟DOM渲染成为真正的DOM。详细的过程是:首先确定挂载的DOM元素,且必须保证该元素不能为html,body
这类跟节点。判断选项中是否有render
这个属性(如果不在运行时编译,则在选项初始化时需要传递render
渲染函数)。当有render
这个属性时,默认我们使用的是runtime-only
的版本,从而跳过模板编译阶段,调用真正的挂载函数$mount
。另一方面,当我们传递是template
模板时(即在不使用外置编译器的情况下,我们将使用runtime+compile
的版本),Vue源码将首先进入编译阶段。该阶段的核心是两步,一个是把模板解析成抽象的语法树,也就是我们常听到的AST
,第二个是根据给定的AST生成目标平台所需的代码,在浏览器端是前面提到的render
函数。完成模板编译后,同样会进入$mount
挂载阶段。真正的挂载过程,执行的是mountComponent
方法,该函数的核心是实例化一个渲染watcher
,具体watcher
的内容,另外放章节讨论。我们只要知道渲染watcher
的作用,一个是初始化的时候会执行回调函数,另一个是当 vm 实例中监测的数据发生变化的时候执行回调函数。而这个回调函数就是updateComponent
,这个方法会通过vm._render
生成虚拟DOM
,并最终通过vm._update
将虚拟DOM
转化为真正的DOM
。
往下,我们从代码的角度出发,了解一下挂载的实现思路,下面只提取mount骨架代码说明。
// 内部真正实现挂载的方法
Vue.prototype.$mount = function (el, hydrating) {
el = el && inBrowser ? query(el) : undefined;
// 调用mountComponent方法挂载
return mountComponent(this, el, hydrating)
};
// 缓存了原型上的 $mount 方法
var mount = Vue.prototype.$mount;
// 重新定义$mount,为包含编译器和不包含编译器的版本提供不同封装,最终调用的是缓存原型上的$mount方法
Vue.prototype.$mount = function (el, hydrating) {
// 获取挂载元素
el = el && query(el);
// 挂载元素不能为跟节点
if (el === document.body || el === document.documentElement) {
warn(
"Do not mount Vue to <html> or <body> - mount to normal elements instead."
);
return this
}
var options = this.$options;
// 需要编译 or 不需要编译
if (!options.render) {
···
// 使用内部编译器编译模板
}
// 最终调用缓存的$mount方法
return mount.call(this, el, hydrating)
}
// mountComponent方法思路
function mountComponent(vm, el, hydrating) {
// 定义updateComponent方法,在watch回调时调用。
updateComponent = function () {
// render函数渲染成虚拟DOM, 虚拟DOM渲染成真实的DOM
vm._update(vm._render(), hydrating);
};
// 实例化渲染watcher
new Watcher(vm, updateComponent, noop, {})
}
复制代码
3.3 编译过程 - 模板编译成 render 函数
通过文章前半段的学习,我们对Vue的挂载流程有了一个初略的认识。接下来将先从模板编译的过程展开。阅读源码时发现,模板的编译过程是相当复杂的,要在短篇幅内将整个编译的过程讲开是不切实际的,因此这节剩余内容只会对实现思路做简单的介绍。
3.3.1 template的三种写法
template模板的编写有三种方式,分别是:
// 1. 熟悉的字符串模板
var vm = new Vue({
el: '#app',
template: '<div>模板字符串</div>'
})
// 2. 选择符匹配元素的 innerHTML模板
<div id="app">
<div>test1</div>
<script type="x-template" id="test">
<p>test</p>
</script>
</div>
var vm = new Vue({
el: '#app',
template: '#test'
})
// 3. dom元素匹配元素的innerHTML模板
<div id="app">
<div>test1</div>
<span id="test"><div class="test2">test2</div></span>
</div>
var vm = new Vue({
el: '#app',
template: document.querySelector('#test')
})
复制代码
三种写法对应代码的三个不同分支。
var template = options.template;
if (template) {
// 针对字符串模板和选择符匹配模板
if (typeof template === 'string') {
// 选择符匹配模板,以'#'为前缀的选择器
if (template.charAt(0) === '#') {
// 获取匹配元素的innerHTML
template = idToTemplate(template);
/* istanbul ignore if */
if (!template) {
warn(
("Template element not found or is empty: " + (options.template)),
this
);
}
}
// 针对dom元素匹配
} else if (template.nodeType) {
// 获取匹配元素的innerHTML
template = template.innerHTML;
} else {
// 其他类型则判定为非法传入
{
warn('invalid template option:' + template, this);
}
return this
}
} else if (el) {
// 如果没有传入template模板,则默认以el元素所属的根节点作为基础模板
template = getOuterHTML(el);
}
复制代码
其中X-Template模板的方式一般用于模板特别大的 demo 或极小型的应用,官方不建议在其他情形下使用,因为这会将模板和组件的其它定义分离开。
3.3.2 流程图解
vue源码中编译流程代码比较绕,涉及的函数处理逻辑比较多,实现流程中巧妙的运用了偏函数的技巧将配置项处理和编译核心逻辑抽取出来,为了理解这个设计思路,我画了一个逻辑图帮助理解。
3.3.3 逻辑解析
即便有流程图,编译逻辑理解起来依然比较晦涩,接下来,结合代码分析每个环节的执行过程。
var ref = compileToFunctions(template, {
outputSourceRange: "development" !== 'production',
shouldDecodeNewlines: shouldDecodeNewlines,
shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this);
// 将compileToFunction方法暴露给Vue作为静态方法存在
Vue.compile = compileToFunctions;
复制代码
这是编译的入口,也是Vue对外暴露的编译方法。compileToFunctions
需要传递三个参数:template
模板,编译配置选项以及Vue实例。我们先大致了解一下配置中的几个默认选项
- 1.
delimiters
该选项可以改变纯文本插入分隔符,当不传递值时,vue默认的分隔符为{{}}
,用户可通过该选项修改 - 2.
comments
当设为true
时,将会保留且渲染模板中的HTML
注释。默认行为是舍弃它们。
接着一步步寻找compileToFunctions根源
var createCompiler = createCompilerCreator(function baseCompile (template,options) {
//把模板解析成抽象的语法树
var ast = parse(template.trim(), options);
// 配置中有代码优化选项则会对Ast语法树进行优化
if (options.optimize !== false) {
optimize(ast, options);
}
var code = generate(ast, options);
return {
ast: ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
});
复制代码
createCompilerCreator
角色定位为创建编译器的创建者。他传递了一个基础的编译器baseCompile
作为参数,baseCompile
是真正执行编译功能的地方,他传递template模板和基础的配置选项作为参数。实现的功能有两个
- 1.把模板解析成抽象的语法树,简称
AST
,代码中对应parse
部分 - 2.可选:优化
AST
语法树,执行optimize
方法 - 3.根据不同平台将
AST
语法树生成需要的代码,对应的generate
函数
具体看看createCompilerCreator
的实现方式。
function createCompilerCreator (baseCompile) {
return function createCompiler (baseOptions) {
// 内部定义compile方法
function compile (template, options) {
···
// 将剔除空格后的模板以及合并选项后的配置作为参数传递给baseCompile方法,其中finalOptions为baseOptions和用户options的合并
var compiled = baseCompile(template.trim(), finalOptions);
{
detectErrors(compiled.ast, warn);
}
compiled.errors = errors;
compiled.tips = tips;
return compiled
}
return {
compile: compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
}
复制代码
createCompilerCreator
函数只有一个作用,利用偏函数将baseCompile
基础编译方法缓存,并返回一个编译器函数,该函数内部定义了真正执行编译的compile
方法,并最终将compile
和compileToFunctons
作为两个对象属性返回,这也是compileToFunctions
的来源。而内部compile
的作用,是为了将基础的配置baseOptions
和用户自定义的配置options
进行合并,(baseOptions
是跟外部平台相关的配置),最终返回合并配置后的baseCompile
编译方法。
compileToFunctions
来源于createCompileToFunctionFn
函数的返回值,该函数会将编译的方法compile
作为参数传入。
function createCompileToFunctionFn (compile) {
var cache = Object.create(null);
return function compileToFunctions (template,options,vm) {
options = extend({}, options);
···
// 缓存的作用:避免重复编译同个模板造成性能的浪费
if (cache[key]) {
return cache[key]
}
// 执行编译方法
var compiled = compile(template, options);
···
// turn code into functions
var res = {};
var fnGenErrors = [];
// 编译出的函数体字符串作为参数传递给createFunction,返回最终的render函数
res.render = createFunction(compiled.render, fnGenErrors);
// 渲染优化相关
res.staticRenderFns = compiled.staticRenderFns.map(function (code) {
return createFunction(code, fnGenErrors)
});
···
return (cache[key] = res)
}
}
复制代码
最终,我们找到了compileToFunctions
真正的执行过程var compiled = compile(template, options);
,并将编译后的函数体字符串通过creatFunction
转化为render
函数返回。
function createFunction (code, errors) {
try {
return new Function(code)
} catch (err) {
errors.push({ err: err, code: code });
return noop
}
}
复制代码
其中函数体字符串类似于"with(this){return _m(0)}"
,最终的render渲染函数为function(){with(this){return _m(0)}}
至此,Vue中关于编译过程的思路也梳理清楚了,编译逻辑之所以绕,主要是因为Vue在不同平台有不同的编译过程,而每个编译过程的baseOptions
选项会有所不同,同时在同一个平台下又不希望每次编译时传入相同的baseOptions
参数,因此在createCompilerCreator
初始化编译器时便传入参数,并利用偏函数将配置进行缓存。同时剥离出编译相关的合并配置,这些都是Vue在编译这块非常巧妙的设计。
总结:
文章的两个重点,一个是理清楚了挂载的基本流程,另一个是了解了源码在编译设计中巧妙的实现思路。宏观上掌握这些设计思想对于后续某个具体流程的分析具有一定的指导意义。