6.1 异步组件

6.1.1 使用场景

Vue作为单页面应用遇到最棘手的问题是首屏加载时间的问题,单页面应用会把页面脚本打包成一个文件,这个文件包含着所有业务和非业务的代码,而脚本文件过大也是造成首页渲染速度缓慢的原因。因此作为首屏性能优化的课题,最常用的处理方法是对文件的拆分和代码的分离。按需加载的概念也是在这个前提下引入的。我们往往会把一些非首屏的组件设计成异步组件,部分不影响初次视觉体验的组件也可以设计为异步组件。这个思想就是按需加载。通俗点理解,按需加载的思想让应用在需要使用某个组件时才去请求加载组件代码。我们借助webpack打包后的结果会更加直观。

6.1 异步组件 - 图1

6.1 异步组件 - 图2webpack遇到异步组件,会将其从主脚本中分离,减少脚本体积,加快首屏加载时间。当遇到场景需要使用该组件时,才会去加载组件脚本。

6.1.2 工厂函数

Vue中允许用户通过工厂函数的形式定义组件,这个工厂函数会异步解析组件定义,组件需要渲染的时候才会触发该工厂函数,加载结果会进行缓存,以供下一次调用组件时使用。具体使用:

  1. // 全局注册:
  2. Vue.component('asyncComponent', function(resolve, reject) {
  3. require(['./test.vue'], resolve)
  4. })
  5. // 局部注册:
  6. var vm = new Vue({
  7. el: '#app',
  8. template: '<div id="app"><asyncComponent></asyncComponent></div>',
  9. components: {
  10. asyncComponent: (resolve, reject) => require(['./test.vue'], resolve),
  11. // 另外写法
  12. asyncComponent: () => import('./test.vue'),
  13. }
  14. })

6.1.3 流程分析

有了上一节组件注册的基础,我们来分析异步组件的实现逻辑。简单回忆一下上一节的流程,实例的挂载流程分为根据渲染函数创建Vnode和根据Vnode产生真实节点的过程。期间创建Vnode过程,如果遇到子的占位符节点会调用creatComponent,这里会为子组件做选项合并和钩子挂载的操作,并创建一个以vue-component-为标记的子Vnode,而异步组件的处理逻辑也是在这个阶段处理。

  1. // 创建子组件过程
  2. function createComponent (
  3. Ctor, // 子类构造器
  4. data,
  5. context, // vm实例
  6. children, // 子节点
  7. tag // 子组件占位符
  8. ) {
  9. ···
  10. // 针对局部注册组件创建子类构造器
  11. if (isObject(Ctor)) {
  12. Ctor = baseCtor.extend(Ctor);
  13. }
  14. // 异步组件分支
  15. var asyncFactory;
  16. if (isUndef(Ctor.cid)) {
  17. // 异步工厂函数
  18. asyncFactory = Ctor;
  19. // 创建异步组件函数
  20. Ctor = resolveAsyncComponent(asyncFactory, baseCtor);
  21. if (Ctor === undefined) {
  22. return createAsyncPlaceholder(
  23. asyncFactory,
  24. data,
  25. context,
  26. children,
  27. tag
  28. )
  29. }
  30. }
  31. ···
  32. // 创建子组件vnode
  33. var vnode = new VNode(
  34. ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
  35. data, undefined, undefined, undefined, context,
  36. { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
  37. asyncFactory
  38. );
  39. return vnode
  40. }

工厂函数的用法使得Vue.component(name, options)的第二个参数不是一个对象,因此不论是全局注册还是局部注册,都不会执行Vue.extend生成一个子组件的构造器,所以Ctor.cid不会存在,代码会进入异步组件的分支。

异步组件分支的核心是resolveAsyncComponent,它的处理逻辑分支众多,我们先关心工厂函数处理部分。

  1. function resolveAsyncComponent (
  2. factory,
  3. baseCtor
  4. ) {
  5. if (!isDef(factory.owners)) {
  6. // 异步请求成功处理
  7. var resolve = function() {}
  8. // 异步请求失败处理
  9. var reject = function() {}
  10. // 创建子组件时会先执行工厂函数,并将resolve和reject传入
  11. var res = factory(resolve, reject);
  12. // resolved 同步返回
  13. return factory.loading
  14. ? factory.loadingComp
  15. : factory.resolved
  16. }
  17. }

如果经常使用promise进行开发,我们很容易发现,这部分代码像极了promsie原理内部的实现,针对异步组件工厂函数的写法,大致可以总结出以下三个步骤:

  1. 定义异步请求成功的函数处理,定义异步请求失败的函数处理;
  2. 执行组件定义的工厂函数;
  3. 同步返回请求成功的函数处理。

resolve, reject的实现,都是once方法执行的结果,所以我们先关注一下高级函数once的原理。为了防止当多个地方调用异步组件时,resolve,reject不会重复执行,once函数保证了函数在代码只执行一次。也就是说,once缓存了已经请求过的异步组件

  1. // once函数保证了这个调用函数只在系统中调用一次
  2. function once (fn) {
  3. // 利用闭包特性将called作为标志位
  4. var called = false;
  5. return function () {
  6. // 调用过则不再调用
  7. if (!called) {
  8. called = true;
  9. fn.apply(this, arguments);
  10. }
  11. }
  12. }

成功resolve和失败reject的详细处理逻辑如下:

  1. // 成功处理
  2. var resolve = once(function (res) {
  3. // 转成组件构造器,并将其缓存到resolved属性中。
  4. factory.resolved = ensureCtor(res, baseCtor);
  5. if (!sync) {
  6. //强制更新渲染视图
  7. forceRender(true);
  8. } else {
  9. owners.length = 0;
  10. }
  11. });
  12. // 失败处理
  13. var reject = once(function (reason) {
  14. warn(
  15. "Failed to resolve async component: " + (String(factory)) +
  16. (reason ? ("\nReason: " + reason) : '')
  17. );
  18. if (isDef(factory.errorComp)) {
  19. factory.error = true;
  20. forceRender(true);
  21. }
  22. });

异步组件加载完毕,会调用resolve定义的方法,方法会通过ensureCtor将加载完成的组件转换为组件构造器,并存储在resolved属性中,其中 ensureCtor的定义为:

  1. function ensureCtor (comp, base) {
  2. if (comp.__esModule ||(hasSymbol && comp[Symbol.toStringTag] === 'Module')) {
  3. comp = comp.default;
  4. }
  5. // comp结果为对象时,调用extend方法创建一个子类构造器
  6. return isObject(comp)
  7. ? base.extend(comp)
  8. : comp
  9. }

组件构造器创建完毕,会进行一次视图的重新渲染,由于Vue是数据驱动视图渲染的,而组件在加载到完毕的过程中,并没有数据发生变化,因此需要手动强制更新视图。forceRender函数的内部会拿到每个调用异步组件的实例,执行原型上的$forceUpdate方法,这部分的知识等到响应式系统时介绍。

异步组件加载失败后,会调用reject定义的方法,方法会提示并标记错误,最后同样会强制更新视图。

回到异步组件创建的流程,执行异步过程会同步为加载中的异步组件创建一个注释节点Vnode

  1. function createComponent (){
  2. ···
  3. // 创建异步组件函数
  4. Ctor = resolveAsyncComponent(asyncFactory, baseCtor);
  5. if (Ctor === undefined) {
  6. // 创建注释节点
  7. return createAsyncPlaceholder(asyncFactory,data,context,children,tag)
  8. }
  9. }

createAsyncPlaceholder的定义也很简单,其中createEmptyVNode之前有介绍过,是创建一个注释节点vnode,而asyncFactory,asyncMeta都是用来标注该节点为异步组件的临时节点和相关属性。

  1. // 创建注释Vnode
  2. function createAsyncPlaceholder (factory,data,context,children,tag) {
  3. var node = createEmptyVNode();
  4. node.asyncFactory = factory;
  5. node.asyncMeta = { data: data, context: context, children: children, tag: tag };
  6. return node
  7. }

执行forceRender触发组件的重新渲染过程时,又会再次调用resolveAsyncComponent,这时返回值Ctor不再为 undefined了,因此会正常走组件的render,patch过程。这时,旧的注释节点也会被取代。

6.1.4 Promise异步组件

异步组件的第二种写法是在工厂函数中返回一个promise对象,我们知道importes6引入模块加载的用法,但是import是一个静态加载的方法,它会优先模块内的其他语句执行。因此引入了import(),import()是一个运行时加载模块的方法,可以用来类比require()方法,区别在于前者是一个异步方法,后者是同步的,且import()会返回一个promise对象。

具体用法:

  1. Vue.component('asyncComponent', () => import('./test.vue'))

源码依然走着异步组件处理分支,并且大部分的处理过程还是工厂函数的逻辑处理,区别在于执行异步函数后会返回一个promise对象,成功加载则执行resolve,失败加载则执行reject.

  1. var res = factory(resolve, reject);
  2. // res是返回的promise
  3. if (isObject(res)) {
  4. if (isPromise(res)) {
  5. if (isUndef(factory.resolved)) {
  6. // 核心处理
  7. res.then(resolve, reject);
  8. }
  9. }
  10. }

其中promise对象的判断最简单的是判断是否有thencatch方法:

  1. // 判断promise对象的方法
  2. function isPromise (val) {
  3. return (isDef(val) && typeof val.then === 'function' && typeof val.catch === 'function')
  4. }

6.1.5 高级异步组件

为了在操作上更加灵活,比如使用loading组件处理组件加载时间过长的等待问题,使用error组件处理加载组件失败的错误提示等,Vue在2.3.0+版本新增了返回对象形式的异步组件格式,对象中可以定义需要加载的组件component,加载中显示的组件loading,加载失败的组件error,以及各种延时超时设置,源码同样进入异步组件分支。

  1. Vue.component('asyncComponent', () => ({
  2. // 需要加载的组件 (应该是一个 `Promise` 对象)
  3. component: import('./MyComponent.vue'),
  4. // 异步组件加载时使用的组件
  5. loading: LoadingComponent,
  6. // 加载失败时使用的组件
  7. error: ErrorComponent,
  8. // 展示加载时组件的延时时间。默认值是 200 (毫秒)
  9. delay: 200,
  10. // 如果提供了超时时间且组件加载也超时了,
  11. // 则使用加载失败时使用的组件。默认值是:`Infinity`
  12. timeout: 3000
  13. }))

异步组件函数执行后返回一个对象,并且对象的component执行会返回一个promise对象,因此进入高级异步组件处理分支。

  1. if (isObject(res)) {
  2. if (isPromise(res)) {}
  3. // 返回对象,且res.component返回一个promise对象,进入分支
  4. // 高级异步组件处理分支
  5. else if (isPromise(res.component)) {
  6. // 和promise异步组件处理方式相同
  7. res.component.then(resolve, reject);
  8. ···
  9. }
  10. }

异步组件会等待响应成功失败的结果,与此同时,代码继续同步执行。高级选项设置中如果设置了errorloading组件,会同时创建两个子类的构造器,

  1. if (isDef(res.error)) {
  2. // 异步错误时组件的处理,创建错误组件的子类构造器,并赋值给errorComp
  3. factory.errorComp = ensureCtor(res.error, baseCtor);
  4. }
  5. if (isDef(res.loading)) {
  6. // 异步加载时组件的处理,创建错误组件的子类构造器,并赋值给errorComp
  7. factory.loadingComp = ensureCtor(res.loading, baseCtor);
  8. }

如果存在delay属性,则通过settimeout设置loading组件显示的延迟时间。factory.loading属性用来标注是否是显示loading组件。

  1. if (res.delay === 0) {
  2. factory.loading = true;
  3. } else {
  4. // 超过时间会成功加载,则执行失败结果
  5. setTimeout(function () {
  6. if (isUndef(factory.resolved) && isUndef(factory.error)) {
  7. factory.loading = true;
  8. forceRender(false);
  9. }
  10. }, res.delay || 200);
  11. }

如果在timeout时间内,异步组件还未执行resolve的成功结果,即resolve没有赋值,则进行reject失败处理。

接下来依然是渲染注释节点或者渲染loading组件,等待异步处理结果,根据处理结果重新渲染视图节点,相似过程不再阐述。

6.1.6 wepack异步组件用法

webpack作为Vue应用构建工具的标配,我们需要知道Vue如何结合webpack进行异步组件的代码分离,并且需要关注分离后的文件名,这个名字在webpack中称为chunkNamewebpack为异步组件的加载提供了两种写法。

  • require.ensure:它是webpack传统提供给异步组件的写法,在编译时,webpack会静态地解析代码中的 require.ensure(),同时将模块添加到一个分开的 chunk 中,其中函数的第三个参数为分离代码块的名字。修改后的代码写法如下:
  1. Vue.component('asyncComponent', function (resolve, reject) {
  2. require.ensure([], function () {
  3. resolve(require('./test.vue'));
  4. }, 'asyncComponent'); // asyncComponent为chunkname
  5. })
  • import(/* webpackChunkName: "asyncComponent" */, component): 有了es6,import的写法是现今官方最推荐的做法,其中通过注释webpackChunkName来指定分离后组件模块的命名。修改后的写法如下:
  1. Vue.component('asyncComponent', () => import(/* webpackChunkName: "asyncComponent" */, './test.vue'))

至此,我们已经掌握了所有异步组件的写法,并深入了解了其内部的实现细节。我相信全面的掌握异步组件对今后单页面性能优化方面会起到积极的指导作用。