乐趣区

关于前端:vue源码分析挂载流程和模板编译

后面几节咱们从 new Vue 创立实例开始,介绍了创立实例时执行初始化流程中的重要两步,配置选项的资源合并, 以及响应式零碎的核心思想,数据代理。在合并章节,咱们对 Vue 丰盛的选项合并策略有了根本的认知,在数据代理章节咱们又对代理拦挡的意义和应用场景有了深刻的意识。依照 Vue 源码的设计思路,初始化过程还会进行很多操作,例如组件之间创立关联,初始化事件核心,初始化数据并建设响应式零碎等,并最终将模板和数据渲染成为 dom 节点。如果间接按流程的先后顺序剖析每个步骤的实现细节,会有很多概念很难了解。因而在这一章节,咱们先重点剖析一个概念,实例的挂载渲染流程。

3.1 Runtime Only VS Runtime + Compiler

在注释开始之前,咱们先理解一下 vue 基于源码构建的两个版本,一个是runtime only(一个只蕴含运行时的版本),另一个是runtime + compiler(一个同时蕴含编译器和运行时的版本)。而两个版本的区别仅在于后者蕴含了一个编译器。

什么是编译器,百度百科这样解释道:

简略讲,编译器就是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级语言)”的程序。一个古代编译器的次要工作流程:源代码 (source code) → 预处理器 (preprocessor) → 编译器 (compiler) → 指标代码 (object code) → 链接器 (Linker) → 可执行程序 (executables)。

艰深点讲,编译器是一个提供了将 源代码 转化为 指标代码 的工具。从 Vue 的角度登程,内置的编译器实现了将 template 模板转换编译为可执行 javascript 脚本的性能。

3.1.1 Runtime + Compiler

一个残缺的 Vue 版本是蕴含编译器的,咱们能够应用 template 进行模板编写。编译器会主动将模板字符串编译成渲染函数的代码, 源码中就是 render 函数。
如果你须要在客户端编译模板 (比方传入一个字符串给 template 选项,或挂载到一个元素上并以其 DOM 外部的 HTML 作为模板),就须要一个蕴含编译器的版本。

// 须要编译器的版本
new Vue({template: '<div>{{ hi}}</div>'
})

3.1.2 Runtime Only

只蕴含运行时的代码领有创立 Vue 实例、渲染并解决 Virtual DOM 等性能,基本上就是除去编译器外的残缺代码。Runtime Only的实用场景有两种:
1. 咱们在选项中通过手写 render 函数去定义渲染过程,这个时候并不需要蕴含编译器的版本便可残缺执行。

// 不须要编译器
new Vue({render (h) {return h('div', this.hi)
  }
})

2. 借助 vue-loader 这样的编译工具进行编译,当咱们利用 webpack 进行 Vue 的工程化开发时,经常会利用 vue-loader.vue进行编译,只管咱们也是利用 template 模板标签去书写代码,然而此时的 Vue 曾经不须要利用编译器去负责模板的编译工作了,这个过程交给了插件去实现。

很显著,编译过程对性能会造成肯定的损耗,并且因为退出了编译的流程代码,Vue代码的总体积也更加宏大 (运行时版本相比完整版体积要小大概 30%)。因而在理论开发中,咱们须要借助像webpackvue-loader这类工具进行编译,将 Vue 对模板的编译阶段合并到 webpack 的构建流程中,这样不仅缩小了生产环境代码的体积,也大大提高了运行时的性能,两全其美。

3.2 实例挂载的基本思路

有了下面的根底,咱们回头看初始化 _init 的代码,在代码中咱们察看到 initProxy 后有一系列的函数调用,这些函数包含了创立组件关联,初始化事件处理,定义渲染函数,构建数据响应式零碎等,最初还有一段代码, 在 el 存在的状况下,实例会调用 $mount 进行实例挂载。

Vue.prototype._init = function (options) {
  ···
  // 选项合并
  vm.$options = mergeOptions(resolveConstructorOptions(vm.constructor),
    options || {},
    vm
  );
  // 数据代理
  initProxy(vm);
  vm._self = vm;
  initLifecycle(vm);
  // 初始化事件处理
  initEvents(vm);
  // 定义渲染函数
  initRender(vm);
  // 构建响应式零碎
  initState(vm);
  // 等等
  ···
  if (vm.$options.el) {vm.$mount(vm.$options.el);
  }
}

以手写 template 模板为例,理分明什么是挂载。咱们会在选项中传递 template 为属性的模板字符串,如 <div>{{message}}</div>,最终这个模板字符串通过两头过程将其转成实在的DOM 节点,并挂载到选项中 el 代表的根节点上实现视图渲染。这个两头过程就是接下来要剖析的挂载流程。

Vue挂载的流程是比较复杂的,接下来我将通过 流程图,代码剖析 两种形式为大家展现挂载的实在过程。

3.2.1 流程图

如果用一句话概括挂载的过程,能够形容为 确认挂载节点, 编译模板为 render 函数,渲染函数转换Virtual DOM, 创立实在节点。

3.2.2 代码剖析

接下来咱们从代码的角度去分析挂载的流程。挂载的代码较多,上面只提取骨架相干的局部代码。

// 外部真正实现挂载的办法
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 不须要编译
  // render 选项不存在,代表是 template 模板的模式,此时须要进行模板的编译过程
  if (!options.render) {
    ···
    // 应用外部编译器编译模板
  }
  // 无论是 template 模板还是手写 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, {})
}

咱们用语言形容挂载流程的基本思路。

  • 确定挂载的 DOM 元素, 这个 DOM 须要保障不能为 html,body 这类跟节点。
  • 咱们晓得渲染有两种形式,一种是通过 template 模板字符串,另一种是手写 render 函数,后面提到 template 模板须要运行时进行编译,而后一个能够间接用 render 选项作为渲染函数。因而挂载阶段会有两条分支,template模板会先通过模板的解析,最终编译成 render 渲染函数参加实例挂载,而手写 render 函数能够绕过编译阶段,间接调用挂载的 $mount 办法。
  • 针对 template 而言,它会利用 Vue 外部的编译器进行模板的编译,字符串模板会转换为形象的语法树,即 AST 树,并最终转化为一个相似 function(){with(){}} 的渲染函数,这是咱们前面探讨的重点。
  • 无论是 template 模板还是手写 render 函数,最终都将进入 mountComponent 过程, 这个阶段会实例化一个渲染 watcher, 具体watcher 的内容,另外放章节探讨。咱们先晓得一个论断,渲染 watcher 的回调函数有两个执行机会,一个是在初始化时执行,另一个是当 vm 实例检测到数据发生变化时会再次执行回调函数。
  • 回调函数是执行 updateComponent 的过程,这个办法有两个阶段,一个是 vm._render, 另一个是vm._updatevm._render 会执行后面生成的 render 渲染函数,并生成一个 Virtual Dom tree, 而vm._update 会将这个 Virtual Dom tree 转化为实在的 DOM 节点。参考 前端进阶面试题具体解答

3.3 模板编译

通过文章前半段的学习,咱们对 Vue 的挂载流程有了一个初略的意识。这里有两个大的流程须要咱们具体去了解,一个是 template 模板的编译,另一个是 updateComponent 的实现细节。updateComponent的过程,咱们放到下一章节重点剖析,而这一节残余的内容咱们将会围绕模板编译的设计思路开展。

(编译器的实现细节是异样简单的,要在短篇幅内将整个编译的过程把握是不切实际的,并且从大方向上也不须要齐全理清编译的流程。因而针对模板,文章剖析只是浅尝即止,更多的细节读者能够自行剖析)

3.3.1 template 的三种写法

template模板的编写有三种形式,别离是:

  • 字符串模板
var vm = new Vue({
  el: '#app',
  template: '<div> 模板字符串 </div>'
})
  • 选择符匹配元素的 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'
})
  • 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')
})

模板编译的前提须要对 template 模板字符串的合法性进行检测,三种写法对应代码的三个不同分支。

Vue.prototype.$mount = function () {
  ···
  if(!options.render) {
    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);
    }
  }
}

// 判断 el 元素是否存在
function query (el) {if (typeof el === 'string') {var selected = document.querySelector(el);
      if (!selected) {
        warn('Cannot find element:' + el);
        return document.createElement('div')
      }
      return selected
    } else {return el}
  }
var idToTemplate = cached(function (id) {var el = query(id);
  return el && el.innerHTML
});

留神:其中 X -Template 模板的形式个别用于模板特地大的 demo 或极小型的利用,官网不倡议在其余情景下应用,因为这会将模板和组件的其它定义分来到。

3.3.2 编译流程图解

vue源码中编译的设计思路是比拟绕,波及的函数解决逻辑比拟多,实现流程中奇妙的使用了偏函数的技巧将配置项解决和编译外围逻辑抽取进去,为了了解这个设计思路,我画了一个逻辑图帮忙了解。

3.3.3 逻辑解析

即使有流程图,编译逻辑了解起来仍然比拟艰涩,接下来,联合代码剖析每个环节的执行过程。

Vue.prototype.$mount = function () {
  ···
  if(!options.render) {
    var template = options.template;
    if (template) {
      var ref = compileToFunctions(template, {
          outputSourceRange: "development" !== 'production',
          shouldDecodeNewlines: shouldDecodeNewlines,
          shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,
          delimiters: options.delimiters,
          comments: options.comments
        }, this);
        var render = ref.render;
    }
    ...
  }
}

compileToFunctions有三个参数,一个是 template 模板,另一个是编译的配置信息,并且这个办法是对外裸露的编译办法,用户能够自定义配置信息进行模板的编译。最初一个参数是 Vue 实例。

// 将 compileToFunction 办法裸露给 Vue 作为静态方法存在
Vue.compile = compileToFunctions;

Vue 的官网文档中,Vue.compile只容许传递一个 template 模板参数,这是否意味着用户无奈决定某些编译的行为?显然不是的,咱们看回代码,有两个选项配置能够提供给用户,用户只须要在实例化 Vue 时传递选项扭转配置,他们别离是:

1.delimiters:该选项能够扭转纯文本插入分隔符,当不传递值时,Vue默认的分隔符为 {{}}。如果咱们想应用其余模板,能够通过 delimiters 批改。

2.comments:当设为 true 时,将会保留且渲染模板中的 HTML正文。默认行为是舍弃它们。

留神,因为这两个选项是在完整版的编译流程读取的配置,所以在运行时版本配置这两个选项是有效的

接着咱们一步步寻找 compileToFunctions 的本源。

首先咱们须要有一个认知,不同平台对 Vue 的编译过程是不一样的,也就是说根底的编译办法会随着平台的不同有区别,编译阶段的配置选项也因为平台的不同出现差别。然而设计者又不心愿在雷同平台下编译不同模板时,每次都要传入雷同的配置选项。这才有了源码中较为简单的编译实现。

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
  }
});

var ref$1 = createCompiler(baseOptions);
var compile = ref$1.compile;
var compileToFunctions = ref$1.compileToFunctions;

这部分代码是在 Vue 引入阶段定义的,createCompilerCreator在传递了一个 baseCompile 函数作为参数后,返回了一个编译器的生成器,也就是 createCompiler, 有了这个生成器,当将编译配置选项baseOptions 传入后, 这个编译器生成器便 生成了一个指定环境指定配置下的编译器,而其中编译执行函数就是返回对象的compileToFunctions

这里的 baseCompile 是真正执行编译性能的中央,也就是后面说到的特定平台的编译办法。它在源码初始化时就曾经作为参数的模式保留在内存变量中。咱们先看看 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) {···}
      return {
        compile: compile,
        compileToFunctions: createCompileToFunctionFn(compile)
      }
    }
  } 

createCompilerCreator函数只有一个作用,利用 偏函数 的思维将 baseCompile 这一根底的编译办法缓存,并返回一个编程器生成器,当执行 var ref$1 = createCompiler(baseOptions); 时,createCompiler会将外部定义的 compilecompileToFunctions返回。

咱们持续关注 compileToFunctions 的由来,它是 createCompileToFunctionFn 函数以 compile 为参数返回的办法,接着看 createCompileToFunctionFn 的实现逻辑。

 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)
    }
  }

createCompileToFunctionFn利用了闭包的概念,将编译过的模板进行缓存,cache会将之前编译过的后果保留下来,利用缓存能够防止反复编译引起的节约性能。createCompileToFunctionFn最终会将 compileToFunctions 办法返回。

接下来,咱们剖析一下 compileToFunctions 的实现逻辑。在判断不应用缓存的编译后果后,compileToFunctions会执行 compile 办法,这个办法是后面剖析 createCompiler 时,返回的外部 compile 办法,所以咱们须要先看看 compile 的实现。

function createCompiler (baseOptions) {function compile (template, options) {var finalOptions = Object.create(baseOptions);
        var errors = [];
        var tips = [];
        var warn = function (msg, range, tip) {(tip ? tips : errors).push(msg);
        };
        // 选项合并
        if (options) {
          ···
          // 这里会将用户传递的配置和零碎自带编译配置进行合并
        }

        finalOptions.warn = warn;
        // 将剔除空格后的模板以及合并选项后的配置作为参数传递给 baseCompile 办法
        var compiled = baseCompile(template.trim(), finalOptions);
        {detectErrors(compiled.ast, warn);
        }
        compiled.errors = errors;
        compiled.tips = tips;
        return compiled
      }
      return {
        compile: compile,
        compileToFunctions: createCompileToFunctionFn(compile)
      }
}

咱们看到 compile 真正执行的办法,是一开始在创立编译器生成器时,传入的根底编译办法 baseCompilebaseCompile 真正执行的时候,会将用户传递的编译配置和零碎自带的编译配置选项合并,这也是结尾提到编译器设计思维的精华。

执行完 compile 会返回一个对象,ast顾名思义是模板解析成的形象语法树,render是最终生成的 with 语句,staticRenderFns是以数组模式存在的动态render

{
  ast: ast,
  render: code.render,
  staticRenderFns: code.staticRenderFns
}

createCompileToFunctionFn 最终会返回另外两个包装过的属性 render, staticRenderFns,他们的外围是with语句封装成执行函数。

// 编译出的函数体字符串作为参数传递给 createFunction, 返回最终的 render 函数
  res.render = createFunction(compiled.render, fnGenErrors);
  res.staticRenderFns = compiled.staticRenderFns.map(function (code) {return createFunction(code, fnGenErrors)
  });

  function createFunction (code, errors) {
    try {return new Function(code)
    } catch (err) {errors.push({ err: err, code: code});
      return noop
    }
  }

至此,Vue中对于编译器的设计思路也根本梳理分明了,一开始看代码的时候,总感觉编译逻辑的设计特地的绕,剖析完代码后发现,这正是作者思路奇妙的中央。Vue在不同平台上有不同的编译过程,而每个编译过程的 baseOptions 选项会有所不同,同时也提供了一些选项供用户去配置,整个设计思维粗浅的利用了偏函数的设计思维,而偏函数又是闭包的利用。作者利用偏函数将不同平台的编译形式进行缓存,同时剥离出编译相干的选项合并,这些形式都是值得咱们日常学习的。

编译的外围是 parse,generate 过程,这两个过程笔者并没有剖析,起因是形象语法树的解析分支较多,须要结合实际的代码场景才更好了解。这两局部的代码会在前面介绍到具体逻辑性能章节时再次提及。

3.4 小结

这一节的内容有两大块,首先具体的介绍了实例在挂载阶段的残缺流程,当咱们传入选项进行实例化时,最终的目标是将选项渲染成页面实在的可视节点。这个选项有两种模式,一个是以 template 模板字符串传入,另一个是手写 render 函数模式传入,不管哪种,最终会以 render 函数的模式参加挂载,render是一个用函数封装好的 with 语句。渲染实在节点前须要将 render 函数解析成虚构 DOM, 虚构DOMjs和实在 DOM 之间的桥梁。最终的 _update 过程让将虚构 DOM 渲染成实在节点。第二个大块次要介绍了作者在编译器设计时奇妙的实现思路。过程大量使用了偏函数的概念,将编译过程进行缓存并且将选项合并从编译过程中剥离。这些设计理念、思维都是值得咱们开发者学习和借鉴的。

退出移动版