阅读 599

[Vue.js进阶]从源码角度剖析异步组件

image

前言

在使用 Vue 开发单页面应用时,往往会通过路由懒加载的形式减少首屏的代码量,实现访问其他页面再加载对应组件的功能

而针对当前的页面,有时也会通过异步加载组件的形式进一步减少当前页面的代码量

  components: {
    Imgshow: () => import('../../../components/Imgshow'),
    Audioplay: () => import('../../../components/Audioplay'),
    Videoplay: () => import('../../../components/Videoplay')
  }
复制代码

这些组件可能是用户打开一个 dialog 才会显示的,反过来说当用户不打开 dialog 时,加载这些组件是没有必要的,通过异步组件,让用户打开 dialog 时才异步加载组件的代码,达到更快速度的响应

路由懒加载和异步组件实际上原理相同,这篇文章我将从源码的角度剖析异步组件的实现原理

文中的源码只保留核心逻辑 完整源码地址

Vue 版本:2.5.21(和最新版代码有些小差别,但核心原理相同)

Vue 加载组件原理

在解释异步组件原理前,我们先从 Vue 如何加载组件开始说起

在使用 Vue 单文件组件开发的过程中,往往通过 template 模版字符串来描述 DOM 结构

<template>
  <div>
    <HelloWorld />
  </div>
</template>

<script>
export default {
  name: "home",
  components: {
    HelloWorld: () => import("@/components/HelloWorld.vue")
  }
};
</script>
复制代码
<script>
export default {
  name: "home",
  components: {
    HelloWorld: () => import("@/components/HelloWorld.vue")
  },
  render(h) {
    return h("div", [h("HelloWorld")]);
  }
};
</script>
复制代码

针对第一种情况,vue-loader 会解析 template 标签中的模版字符串并转换为 render 函数,因此上述两种写法实质的效果是相同的

render 方法的 h 参数为 createElement 函数的别名(HTML 第一个单词 hyper 的首字母),它的作用是将参数转换为 vnode 即虚拟 DOM,对应源码如下

export function _createElement(
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  let vnode
  if (typeof tag === 'string') {
    let Ctor
    if (config.isReservedTag(tag)) { // 原生 html 标签
      vnode = new VNode(
        config.parsePlatformTagName(tag),
        data,
        children,
        undefined,
        undefined,
        context
      )
    } else if (
      (!data || !data.pre) &&
      // 将标签字符串转为组件函数
      isDef((Ctor = resolveAsset(context.$options, 'components', tag)))
    ) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(tag, data, children, undefined, undefined, context)
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  return vnode
}
复制代码

createElement 第一个参数为 context,指的是父级 Vue 实例对象,它会默认被 Vue 作为第一个参数传入,而从第二个 tag 参数开始就是在 render 函数中给 createElement 传入的参数了

当给 createElement 传入一个非 HTML 默认的标签名( 对应例子中的 'HelloWorld' ),Vue 会认为它是一个组件的标签,执行 resolveAsset 从 $options.components 中找到对应的组件函数也就是 () => import("@/components/HelloWorld.vue"),随后执行 createComponent 生成组件 vnode

创建组件

createComponent 是一个用来创建组件 vnode 的函数,经过上一步 resolveAsset 最终将() => import("@/components/HelloWorld.vue") 作为第一个参数 Ctor 传入,

export function createComponent(
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component, //vm实例
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  const baseCtor = context.$options._base
  // plain options object: turn it into a constructor
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }
  // async component
  let asyncFactory
  //当找不到 cid 时,即为异步组件
  if (isUndef(Ctor.cid)) {
    asyncFactory = Ctor
    Ctor = resolveAsyncComponent(asyncFactory, baseCtor, context)
    if (Ctor === undefined) {
      // return a placeholder node for async component, which is rendered
      // as a comment node but preserves all the raw information for the node.
      // the information will be used for async server-rendering and hydration.
      return createAsyncPlaceholder(asyncFactory, data, context, children, tag)
    }
  }
 
  // ......
  const name = Ctor.options.name || tag
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data,
    undefined,
    undefined,
    undefined,
    context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )
  return vnode
}
复制代码

对于异步组件,我们只需关心其中的 resolveAsyncComponent 函数即可,由于 Ctor 是一个返回动态加载组件的函数,并没有 cid 这个属性,所以会认为是一个异步组件,并执行中间的 resolveAsyncComponent 尝试解析异步组件,接着我们来看真正解析异步组件的这个函数具体做了什么事情

异步组件

export function resolveAsyncComponent(
  factory: Function,
  baseCtor: Class<Component>,
  context: Component
): Class<Component> | void {
  if (isDef(factory.resolved)) {
    return factory.resolved
  }

  if (isDef(factory.contexts)) {
    // already pending
    factory.contexts.push(context)
  } else {
    const contexts = (factory.contexts = [context])
    let sync = true
    
    // 第三部分
    const forceRender = (renderCompleted: boolean) => {
      for (let i = 0, l = contexts.length; i < l; i++) {
        contexts[i].$forceUpdate()
      }

      if (renderCompleted) {
        contexts.length = 0
      }
    }
    // 第二部分
    const resolve = once((res: Object | Class<Component>) => {
      // cache resolved
      factory.resolved = ensureCtor(res, baseCtor)
      // invoke callbacks only if this is not a synchronous resolve
      // (async resolves are shimmed as synchronous during SSR)
      if (!sync) {
        forceRender(true)
      }
    })

    const reject = once(reason => {
      process.env.NODE_ENV !== 'production' &&
        warn(
          `Failed to resolve async component: ${String(factory)}` +
            (reason ? `\nReason: ${reason}` : '')
        )
    })
    // 第一部分
    // 老版本语法
    const res = factory(resolve, reject)

    // 新版本 import 语法
    if (isObject(res)) {
      if (typeof res.then === 'function') {
        if (isUndef(factory.resolved)) {
          res.then(resolve, reject)
        }
      }
    } else {
      // 特殊异步组件,本文不做讨论
      // https://cn.vuejs.org/v2/guide/components-dynamic-async.html#%E5%A4%84%E7%90%86%E5%8A%A0%E8%BD%BD%E7%8A%B6%E6%80%81
      //...
    }

    sync = false
    // return in case resolved synchronously
    return factory.resolved
  }
}
复制代码

源代码即使做了删减还是比较长,但是实现的功能并不复杂,核心就是从服务端拿到异步组件的代码,将其变成一个组件构造器,并更新视图

但是由于是异步组件需要考虑以下两件事

  • 组件加载时,视图应该怎么展示
  • 组件加载成功后如何更新视图

接下来我们来看 resolveAsyncComponent 是如何解决这两个问题的,从注释中的第一部分开始剖析,首先会执行传入的 factory 函数,也就是例子中的 () => import("@/components/HelloWorld.vue")

我们知道 import 作为一个函数执行时会返回一个 promise,然后将这个 promise 赋值给 res 变量,但在执行 factory 时还传入了 resolve 和 reject 这两个参数,这是干什么用的呢?

实质上这是给老版本引入异步组件的语法用的,并不会生效

// 老版本 webpack 动态加载模块的方法
const Foo = resolve => {
  require.ensure(['./Foo.vue'], () => {
    resolve(require('./Foo.vue'))
  })
}
复制代码

现在还是推荐使用 ES6 的 import() 语法,更符合标准

Vue 将 import() 语法的解析放到了后面,它会调用 res 的 then 方法并传入 resolve 和 reject,也就是说当异步组件被加载成功时,会执行 resolve 函数,反之执行 reject,关于这两个函数我们放到下个段落阐述,先继续执行同步的逻辑

异步组件加载时

当给 res 调用 then 方法注册了 resolve 和 reject 后,会将 factory.resolved 的值作为 resolveAsyncComponent 的返回值,但是此时可以发现 resolved 属性并没有被定义,所以最终返回 undefined,接着回到外层的 createComponent 函数中

当 resolveAsyncComponent 返回值为 undefined 时,会先渲染成一个注释节点作为占位符

接着就静静等待异步组件加载成功,执行之后的逻辑

异步组件加载成功后

同步逻辑结束之后,我们回头看注释中的第二部分, resolve 和 reject 具体实现了什么功能,当异步组件被加载成功时,会执行之前 then 方法注册的 resolve 函数

可以看到 resolve 和 reject 都被 once 这个辅助函数包裹,为的是让 resolve/reject 始终只执行其中一个并且只执行一次,但对于新版本,import() 返回的是一个 promise,而 promise 本身就已经内置了 once 的实现,所以 once 的存在也是为了兼容老版本的语法

当异步组件加载成功后,会得到异步组件的组件配置项作为 res 参数并传入 ensureCtor 函数中

function ensureCtor (comp: any, base) {
  if (
    comp.__esModule ||
    (hasSymbol && comp[Symbol.toStringTag] === 'Module')
  ) {
    comp = comp.default
  }
  return isObject(comp)
    ? base.extend(comp)
    : comp
}
复制代码

随后判断 comp ( 图中 default 属性的值 )如果是一个对象则转为一个组件构造器并返回( Ctor 即 constructor 的简写 ),并将它赋值给 factory.resolve ,这个赋值行为非常重要,要知道之前 factory.resolve 是未定义状态,而此时它的值为组件构造器

这里说一点题外话, 目前 Vue 中一般的单文件组件都是 option-based,即导出的一般都是一个对象

<template>
  <div class="hello-world">hello world</div>
</template>

<script>
export default {
  name: "HelloWorld",
  data() {
    return {
      a: 1
    };
  },
  methods: {
    handleClick() {}
  }
};
</script>
复制代码

另外还有一种 function-based 的组件,即导出一个函数(或者说一个组件类)

import Vue from 'vue'
import Component from 'vue-class-component'

@Component({
  template: '<button @click="onClick">Click!</button>'
})
export default class MyComponent extends Vue {
  message: string = 'Hello!'
  onClick (): void {
    window.alert(this.message)
  }
}
复制代码

没错,TypeScript,React 向大家证明了函数可以完美的演绎一个组件,事实上 Vue3 也废弃了 option-based 的语法,为的是更好的 TypeScript 类型推倒

至于如何将 option-base 转为 funtion-base 的组件可以查看 Vue.extend 也就是上述代码中的 base.extend 具体做了什么,它并不在本文的讨论范围内

resolve 的最后会判断 sync 的值,由于此时已是异步,所以 sync 为 false,最终执行 forceRender

刷新视图

当异步组件加载成功后,需要让已有组件得知异步组件已加载完毕,并给当前视图添加异步组件,这也是注释第三部分 forceRender 的功能,可以看到在最初执行 resolveAsyncComponent 时执行了这行代码

const contexts = factory.contexts = [context]

它会给 factory 函数添加一个 context 属性用来保存上下文数组,而一个上下文就是一个组件实例,更准确的说是当前异步组件的父组件实例, contexts 中保存了所有引用到这个异步组件到父组件,那么如何让父组件得知异步组件已经被加载成功了呢?

非常简单,只要让父组件重新刷新一边即可,即调用 $forceUpdate 这个 api 就能重新刷新父组件收集依赖,那这次的刷新和之前有什么区别呢?

再次刷新父组件会重新执行最初的 createComponent 方法,此时 Ctor 依然没有 cid 属性,仍是一开始的那个函数 () => import("@/components/HelloWorld.vue")

// createComponent
 if (isUndef(Ctor.cid)) {
    asyncFactory = Ctor
    Ctor = resolveAsyncComponent(asyncFactory, baseCtor, context)
    if (Ctor === undefined) {
      // return a placeholder node for async component, which is rendered
      // as a comment node but preserves all the raw information for the node.
      // the information will be used for async server-rendering and hydration.
      return createAsyncPlaceholder(
        asyncFactory,
        data,
        context,
        children,
        tag
      )
    }
  }
复制代码

所以会第二次执行 resolveAsyncComponent,而第二次和第一次执行不同的是,第一次的 factory.resolve 属性未定义,即为 undefined,现在它经过第一次执行时的 resolve 函数,赋值为了异步组件的组件构造器,所以 resolveAsyncComponent 不再返回 undefined,而是返回异步组件的组件构造器

而拿到组件构造器就可以正常的生成组件,之后的逻辑就和同步组件相同了

总结

当 Vue 遇到异步组件时,会先渲染一个注释节点,等异步组件加载完毕后,通过 $forceUpdate 这个 api 来刷新视图

关于异步组件还有一些高级用法,比如在异步组件加载的过程中,可以自定义加载时,加载失败时渲染的占位符节点,或者自定义需要延迟和超时的时间,本质都是在 resolveAsyncComponent 额外做了一些扩展,有兴趣的朋友可以查看完整源代码

参考资料

Vue.js 技术揭秘

关注下面的标签,发现更多相似文章
评论