
前言
在使用 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}` : '')
)
})
// 第一部分
// 老版本 webpack 的工厂函数语法
const res = factory(resolve, reject)
// 新版本 ES6 动态 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>
这种方式的缺点就是严重依赖 this,这使得 TypeScript 很难进行类型推倒
另外还有一种 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)
}
}
这正是 Vue 接入的 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 额外做了一些扩展,有兴趣的朋友可以查看完整源代码