时间:2020-11-22 23:06:47 | 栏目:vue | 点击:次
在正文开始之前,先了解vue基于源码构建的两个版本,一个是 runtime only
,另一个是 runtime加compiler 的版本,两个版本的主要区别在于后者的源码包括了一个编译器。
什么是编译器,百度百科上面的解释是
简单讲,编译器就是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级语言)”的程序。一个现代编译器的主要工作流程:源代码 (source code) → 预处理器 (preprocessor) → 编译器 (compiler) → 目标代码 (object code) → 链接器 (Linker) → 可执行程序 (executables)。
通俗点讲,编译器是一个提供了将源代码转化为目标代码的工具。更进一步理解,vue内置的编译器实现了将 .vue 文件转换编译为可执行javascript脚本的功能。
一个完整的vue版本是包含编译器的,我们可以使用 template 进行模板编写。编译器会自动将模板编译成 render 函数。
// 需要编译器的版本 new Vue({ template: '<div>{{ hi }}</div>' })
而对于一个不包含编译器的 runtime-only
版本,需要传递一个编译好的 render 函数,如下所示:
// 不需要编译器 new Vue({ render (h) { return h('div', this.hi) } })
很明显,编译过程对性能有一定的损耗,并且由于加入了编译过程的代码,vue代码体积也更加庞大,所以我们可以借助webpack的vue-loader
工具进行编译,将编译阶段从vue的构建中剥离出来,这样既优化了性能,也缩小了体积。
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, {}) }
通过文章前半段的学习,我们对Vue的挂载流程有了一个初略的认识。接下来将先从模板编译的过程展开。阅读源码时发现,模板的编译过程是相当复杂的,要在短篇幅内将整个编译的过程讲开是不切实际的,因此这节剩余内容只会对实现思路做简单的介绍。
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 或极小型的应用,官方不建议在其他情形下使用,因为这会将模板和组件的其它定义分离开。
vue源码中编译流程代码比较绕,涉及的函数处理逻辑比较多,实现流程中巧妙的运用了偏函数的技巧将配置项处理和编译核心逻辑抽取出来,为了理解这个设计思路,我画了一个逻辑图帮助理解。
即便有流程图,编译逻辑理解起来依然比较晦涩,接下来,结合代码分析每个环节的执行过程。
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在编译这块非常巧妙的设计。
总结