理解 Vue3 的异步组件
在大型的前端项目中,为了提升应用页面的加载性能,可能会需要做组件懒加载,即在需要使用该组件的时候才加载该组件,这个时候异步组件就派上了用场。所谓异步组件,就是指以异步的方式加载并渲染一个组件。
从根本上来说,异步组件的实现不需要任何框架层面的支持,我们完全可以借助原生 JS 的动态 import 来实现。例如这个渲染 App 组件到页面的示例:
// App.js
const App = {
template: `
<div>异步组件</div>
`
}
export default App
<!-- demo.html -->
<script src="../../dist/vue.global.js"></script>
<div id="demo"></div>
<script type="module">
const loader = () => import('./App.js')
loader().then(App => {
Vue.createApp(App.default).mount('#demo')
})
</script>
这里我们使用动态导入语句 import() 来加载组件,他会返回一个 Promise 示例。组件加载成功后,会调用 createApp 函数完成挂载,这样就实现了以异步的方式来渲染页面。
上面的例子实现了整个页面的异步渲染,在实际的工作开发中,只异步渲染页面的部分组件是比较常见的,使用动态 import 也是有能力仅异步加载页面中的某个组件的。
// CompB.js
const CompB = {
template: `
<div>异步组件</div>
`
}
export default CompB
<!-- demo.html -->
<script src="../../../dist/vue.global.js"></script>
<script type="text/x-template" id="comp-a">
<div>CompA 组件</div>
</script>
<script>
const CompA = {
template: '#comp-a',
}
</script>
<div id="app"></div>
<script type="text/x-template" id="demo">
<div>
<comp-a />
<component :is="asyncComp" />
</div>
</script>
<script type="module">
Vue.createApp({
components: {
CompA
},
template: `#demo`,
setup() {
const asyncComp = Vue.shallowRef(null)
// 异步加载 CompB 组件
import('./CompB.js').then(CompB => {
asyncComp.value = CompB.default
})
return {
asyncComp
}
}
}).mount('#app')
</script>
从上面的代码可以看出,页面由 CompA 组件和动态组件 component
构成,其中 CompA 组件是同步渲染的,动态组件绑定了 asyncComp 变量,当通过动态导入语句 import() 异步加载 CompB 组件成功后,会将 asyncComp 变量的值设置为 CompB 。这样就实现了 CompB 组件的异步加载和渲染。
虽然我们可以自行实现组件的异步加载和渲染,但是一个完善的异步组件的实现还是比较复杂的,通常在异步加载组件时,我们需要考虑以下几个方面:
-
如果组件加载失败或加载超时,是否要渲染 Error 组件?
-
组件在加载时,是否要展示占位的内容?例如渲染一个 Loading 组件。
-
组件加载的速度可能很快,也可能很慢,是否要设置一个延迟展示 Loading 组件的时间?如果组件在 200ms 内没有加载成功才展示 Loading 组件,这样可以避免由组件加载过快所导致的闪烁。
-
组件加载失败后,是否需要重试?
为了替用户更好地解决这些问题,Vue3 在框架层面为异步组件提供了更好的封装支持,与之对应的能力为:
-
允许用户指定加载出错时要渲染的组件。
-
允许用户指定 Loading 组件,以及展示该组件的延迟时间。
-
允许用户设置加载组件的超时时长。
-
组件加载失败时,为用户提供重试的能力。
异步组件的源码分析
从 Vue3 的源码层面来说,异步组件本质上是通过封装手段来实现友好的用户接口,从而降低用户层面的使用复杂度。
在 Vue3 中使用 defineAsyncComponent
定义异步组件,参数可以是一个异步加载函数,或是对加载行为进行更具体定制的一个选项对象。我们可以在这个选项对象中自定义异步组件的加载器、配置在异步组件加载过程中展示的 Loading 组件以及指定当错误发生时渲染的 Error 组件等,可以说 Vue3 为我们提供的异步组件 API 是非常完善的 👍。
function defineAsyncComponent(
source: AsyncComponentLoader | AsyncComponentOptions
): Component
type AsyncComponentLoader = () => Promise<Component>
interface AsyncComponentOptions {
// 指定异步组件的加载器
loader: AsyncComponentLoader
// 用于配置 Loading 组件,在组件加载过程中展示
loadingComponent?: Component
// 指定一个 Error 组件,当错误发生时会渲染它
errorComponent?: Component
// 延迟展示 Loading 组件的时间,默认为 200ms
delay?: number
// 指定异步组件的超时时长,单位为 ms
timeout?: number
// 异步组件加载失败后的错误回调
onError?: (
// 捕获到加载器的错误对象
error: Error,
// 重试
retry: () => void,
// 失败
fail: () => void,
// 重试次数
attempts: number
) => any
}
使用 defineAsyncComponent
函数定义的异步组件,可以直接使用 components 组件选项来注册他。这样,在模板中就可以像使用普通组件一样使用异步组件了。这会比我们自行实现异步组件方式要简单直接很多。
<script>
import { defineAsyncComponent } from 'vue'
export default {
components: {
AdminPage: defineAsyncComponent(() =>
import('./components/AdminPageComponent.vue')
)
}
}
</script>
<template>
<AdminPage />
</template>
defineAsyncComponent
函数会先判断入参是否为函数,如果是函数,则说明用户传入的是加载器,则需要将传入的参数转换为配置项的形式。这也是 defineAsyncComponent
函数定义异步组件支持两种形式的参数的原因。
// packages/runtime-core/src/apiAsyncComponent.ts
export function defineAsyncComponent<
T extends Component = { new (): ComponentPublicInstance }
>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
// source 可以是配置项,也可以是加载器
if (isFunction(source)) {
// 如果 source 是加载器,则将其格式化为配置项形式
source = { loader: source }
}
// ...
}
本文中的源码均摘自 Vue.js 3.2.45,为了方便理解,会省略与本文主题无关的代码
通过解构,从用户传入的配置项中取得异步组件加载器、加载过程中需要展示的 Loading 组件、加载发生错误时展示的 Error 组件、延迟加载时间(默认为 200ms)、加载的超时时长以及加载错误的回调函数。
// packages/runtime-core/src/apiAsyncComponent.ts
export function defineAsyncComponent<
T extends Component = { new (): ComponentPublicInstance }
>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
// ...
const {
loader, // 指定异步组件的加载器。
loadingComponent, // 类似于 errorComponent 选项,用于配置 Loading 组件
errorComponent, // 指定一个 Error 组件,当错误发生时会渲染它
delay = 200, // 延迟 200ms 展示 Loading 组件
timeout, // undefined = never times out // 单位为 ms,指定超时时长。
onError: userOnError // 加载错误的回调函数
} = source
}
定义 pendingRequest
记录当前正在进行的异步加载函数返回的 Promise 对象。用于确保同一时间只有一个异步加载请求正在进行。定义 resolvedComp
用于存储异步加载函数加载成功后返回的组件。
// packages/runtime-core/src/apiAsyncComponent.ts
export function defineAsyncComponent<
T extends Component = { new (): ComponentPublicInstance }
>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
// ...
let pendingRequest: Promise<ConcreteComponent> | null = null
let resolvedComp: ConcreteComponent | undefined
//...
const load = (): Promise<ConcreteComponent> => {
let thisRequest: Promise<ConcreteComponent>
return (
pendingRequest ||
(
thisRequest = pendingRequest =
loader()
.catch(err => {
// ...
})
.then((comp: any) => {
// ...
})
)
)
}
}
定义 retries
用于记录重试次数,定义 retry
函数,用于调用异步加载函数。在加载组件的过程中,发生错误的情况非常常见,尤其是在网络不稳定的情况下。因此,提供重试机制,会提升用户的开发体验。
// packages/runtime-core/src/apiAsyncComponent.ts
export function defineAsyncComponent<
T extends Component = { new (): ComponentPublicInstance }
>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
// ...
// 记录重试次数
let retries = 0
const retry = () => {
retries++
pendingRequest = null
return load()
}
}
封装 load 函数用来加载异步组件。
// packages/runtime-core/src/apiAsyncComponent.ts
export function defineAsyncComponent<
T extends Component = { new (): ComponentPublicInstance }
>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
// 封装 load 函数用来加载异步组件
const load = (): Promise<ConcreteComponent> => {
let thisRequest: Promise<ConcreteComponent>
return (
pendingRequest ||
(thisRequest = pendingRequest =
loader()
.catch(err => { // 捕获加载器的错误
err = err instanceof Error ? err : new Error(String(err))
// 如果用户指定了 onError 回调(userOnError),则将控制权交给用户
if (userOnError) {
// 返回一个新的 Promise 实例
return new Promise((resolve, reject) => {
// 重试
const userRetry = () => resolve(retry())
// 失败
const userFail = () => reject(err)
// 作为 onError 回调函数的参数,让用户来决定下一步怎么做
userOnError(err, userRetry, userFail, retries + 1)
})
} else {
// 如果用户没有指定错误回调,则将加载器的错误抛出
throw err
}
})
.then((comp: any) => { // 组件加载成功
if (thisRequest !== pendingRequest && pendingRequest) {
// thisRequest 不等于 pendingRequest,
// 为了保证同一时间只有一个异步加载请求正在进行,
// 直接返回 pendingRequest
return pendingRequest
}
// interop module default
if (
comp &&
(comp.__esModule || comp[Symbol.toStringTag] === 'Module')
) {
// 加载器返回的是 ES Module 对象,则取其 default 属性,
// 因为 default 属性存储的才是真正的组件对象
comp = comp.default
}
// 将加载成功后的组件存储到 resolvedComp 变量
resolvedComp = comp
return comp
}))
)
}
}
使用 catch 语句捕获加载器的错误,如果用户指定了错误回调,则重新返回一个新的 Promise 实例,并将重试函数、捕获到的错误对象,重试次数都作为参数传给用户指定的错误回调,由用户来决定下一步怎么做。如果用户没有指定错误回调,则将加载器的错误抛出。
如果加载成功,则将组件存储到 resolvedComp
变量中,需要注意的是,如果用户采用 ES Module 的模块化规范,则要取加载器返回的对象的 default 属性,default 属性存储的才是真正的组件对象。
按照 Vue.js 单文件组件的规范,组件会通过 export default
的方式导出,因此采用 ES Module 的话,default 属性存储的才是真正的组件对象。
Symbol.toStringTag
,可作为对象的属性,他的值是个字符串,用于代表对象的类型。是 Object.prototype.toString() 方法返回值的一部分。详情可查阅 Symbol.toStringTag
if (
comp &&
(comp.__esModule || comp[Symbol.toStringTag] === 'Module')
) {
comp = comp.default
}
defineAsyncComponent
函数最后会返回一个包装组件,该包装组件的名字被定义为 AsyncComponentWrapper
// packages/runtime-core/src/apiAsyncComponent.ts
export function defineAsyncComponent<
T extends Component = { new (): ComponentPublicInstance }
>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
// ...
// 返回一个包装组件
return defineComponent({
name: 'AsyncComponentWrapper',
__asyncLoader: load,
get __asyncResolved() {
return resolvedComp
},
setup() {
const instance = currentInstance!
// already resolved
if (resolvedComp) {
return () => createInnerComp(resolvedComp!, instance)
}
const onError = (err: Error) => {
pendingRequest = null
handleError(
err,
instance,
ErrorCodes.ASYNC_COMPONENT_LOADER,
!errorComponent /* do not throw in dev if user provided error component */
)
}
const loaded = ref(false)
// 定义 error,当错误发生时,用来存储错误对象
const error = ref()
const delayed = ref(!!delay)
if (delay) {
setTimeout(() => {
delayed.value = false
}, delay)
}
if (timeout != null) {
// 如果指定了超时时长,则开启一个定时器计时
setTimeout(() => {
if (!loaded.value && !error.value) {
// 超时后创建一个错误对象,并复制给 error.value
const err = new Error(
`Async component timed out after ${timeout}ms.`
)
onError(err)
error.value = err
}
}, timeout)
}
load()
.then(() => {
loaded.value = true
if (instance.parent && isKeepAlive(instance.parent.vnode)) {
// parent is keep-alive, force update so the loaded component's
// name is taken into account
queueJob(instance.parent.update)
}
})
.catch(err => {
onError(err)
// 使用 catch 语句来捕获加载过程中的错误
error.value = err
})
return () => {
if (loaded.value && resolvedComp) {
return createInnerComp(resolvedComp, instance)
} else if (error.value && errorComponent) {
// 只有当错误存在且用户配置了 errorComponent 时才展示 Error 组件,
// 同时将 error 作为 props 传递
return createVNode(errorComponent as ConcreteComponent, {
error: error.value
})
} else if (loadingComponent && !delayed.value) {
return createVNode(loadingComponent as ConcreteComponent)
}
}
}
}) as T
}
__asyncLoader
,为私有属性,标识 AsyncComponentWrapper
__asyncResolved()
,getter 属性,返回加载器加载成功后返回的组件
如果加载函数已经返回组件,则直接将该组件返回。setup 函数返回函数的话,该函数会作为组件的渲染函数。
setup() {
// already resolved
if (resolvedComp) {
return () => createInnerComp(resolvedComp!, instance)
}
}
onError 函数为 setup 函数发生错误时的公共错误处理函数。
const onError = (err: Error) => {
pendingRequest = null
handleError(
err,
instance,
ErrorCodes.ASYNC_COMPONENT_LOADER,
!errorComponent /* do not throw in dev if user provided error component */
)
}
定义 loaded
记录异步组件是否加载成功。定义 error
,当错误发生时,用来存储错误对象。定义 delayed
变量,记录是否延迟展示 Loading 组件。
为啥 Loading 组件需要延迟展示?因为异步加载的组件受网络影响较大,加载过程可能很慢,也可能很快。对于加载很慢的情况,我们自然能想到通过展示 Loading 组件来提供更好的用户体验。这样,用户就不会有“卡死”的感觉。对于加载很快的情况,即网络状况良好的情况,异步组件的加载速度会非常快,如果我们从加载开始的那一刻起就展示 Loading 组件,这会导致 Loading 组件刚完成渲染就立即进入卸载阶段,于是出现闪烁的情况。对于用户来说这是非常不好的体验。因此,我们需要为 Loading 组件设置一个延迟展示的时间。因此,Vue3 设置 200ms 的 Loading 组件延迟展示时间,当超过 200ms 没有完成加载才展示 Loading 组件。这样,对于在 200ms 内能够完成加载的情况来说,就避免了闪烁问题的出现。
const loaded = ref(false)
// 定义 error,当错误发生时,用来存储错误对象
const error = ref()
const delayed = ref(!!delay)
if (delay) {
setTimeout(() => {
// 到了延迟时间后,将 delayed 设置为 false ,展示 Loading 组件
delayed.value = false
}, delay)
}
return () => {
if() {
} else if (loadingComponent && !delayed.value) {
// Loading 延迟时间到,展示 Loading 组件
return createVNode(loadingComponent as ConcreteComponent)
}
}
如果用户指定了超时时长,则开启一个定时器计时,如果到了超时时间组件还未加载完成,则创建一个错误对象,提示异步组件超时。
if (timeout != null) {
// 如果指定了超时时长,则开启一个定时器计时
setTimeout(() => {
// loaded.value 为 false ,组件没有加载成功
if (!loaded.value && !error.value) {
// 超时后创建一个错误对象,并复制给 error.value
const err = new Error(
`Async component timed out after ${timeout}ms.`
)
onError(err)
error.value = err
}
}, timeout)
}
调用异步加载函数加载组件,判断异步组件的父组件是否为 KeepAlive 组件,如果是 KeepAlive 组件则需要强制更新 KeepAlive 组件,使异步组件能够保持 KeepAlive 的状态,具体可见这个 issues(keep-alive include not working for async loaded components )以及 pr(fix(KeepAlive): should work with async component)。同时也使用 catch 语句捕获异步组件加载时出现的错误。
load()
.then(() => {
loaded.value = true
if (instance.parent && isKeepAlive(instance.parent.vnode)) {
// 如果异步组件的父组件的 KeepAlive 组件,
// 则强制更新 KeepAlive 组件,使异步组件能保持 KeepAlive 的状态
queueJob(instance.parent.update)
}
})
.catch(err => {
onError(err)
// 使用 catch 语句来捕获加载过程中的错误
error.value = err
})
最后这个包装组件(AsyncComponentWrapper
)的 setup 会返回一个函数,如果 setup 返回一个函数的话,这个函数则会当做组件的渲染函数。
-
如果组件加载成功,则展示加载成功后的组件
-
如果发生错误并且用户配置了 errorComponent,则展示 Error 组件,并将捕获到的 error 当做 props 传递
-
用户指定了 Loading 组件并过了延迟展示的时间(200ms),则展示 Loading 组件
// packages/runtime-core/src/apiAsyncComponent.ts
export function defineAsyncComponent<
T extends Component = { new (): ComponentPublicInstance }
>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
//...
// 返回一个包装组件
return defineComponent({
name: 'AsyncComponentWrapper',
//...
setup() {
// ...
return () => {
if (loaded.value && resolvedComp) {
// 组件加载成功,则返回加载的组件
return createInnerComp(resolvedComp, instance)
} else if (error.value && errorComponent) {
// 发生了错误且用户配置了 errorComponent 时,则展示 Error 组件,
// 同时将 error 作为 props 传递
return createVNode(errorComponent as ConcreteComponent, {
error: error.value
})
} else if (loadingComponent && !delayed.value) {
// 用户指定了 Loading 组件并过了延迟展示的时间(200ms),
// 则展示 Loading 组件
return createVNode(loadingComponent as ConcreteComponent)
}
}
}
})
}
createInnerComp 用于创建传入组件的虚拟 DOM ,因为异步组件返回包装组件,最后渲染的是加载器函数返回的组件,因此包装组件上面的 ref
引用,ce
函数要设置到异步加载器函数返回的组件上,才能让 ref
,ce
函数起作用。
// packages/runtime-core/src/apiAsyncComponent.ts
function createInnerComp(
comp: ConcreteComponent,
parent: ComponentInternalInstance
) {
const { ref, props, children, ce } = parent.vnode
const vnode = createVNode(comp, props, children)
// ensure inner component inherits the async wrapper's ref owner
vnode.ref = ref
// pass the custom element callback on to the inner comp
// and remove it from the async wrapper
vnode.ce = ce
delete parent.vnode.ce
return vnode
}
ce
是 Custom Element 的缩写,ce
函数用于派发 Web Component 组件的自定义事件。
// packages/runtime-dom/src/apiCustomElement.ts
export class VueElement extends BaseClass {
private _createVNode(): VNode<any, any> {
const vnode = createVNode(this._def, extend({}, this._props))
if (!this._instance) {
// 定义 ce 函数
vnode.ce = instance => {
this._instance = instance
instance.isCE = true
const dispatch = (event: string, args: any[]) => {
this.dispatchEvent(
new CustomEvent(event, {
detail: args
})
)
}
instance.emit = (event: string, ...args: any[]) => {
// 派发 Web Component 的自定义事件
dispatch(event, args)
}
}
}
return vnode
}
}
总结
异步组件在页面性能、拆包以及服务端下发组件等场景中具有重要作用。从根本上来说,异步组件的实现可以完全在用户层面实现,而无须框架支持。但一个完善的异步组件需要考虑许多问题,比如:
- 允许用户指定加载出错时要渲染的组件;
- 允许用户指定 Loading 组件,以及展示该组件的延迟时间;
- 允许用户设置加载组件的超时时长;
- 组件加载失败时,为用户提供重试的能力。
因此,为了提升用户的开发体验,Vue3 内建了异步组件的实现。Vue3 提供了 defineAsyncComponent
函数来定义异步组件,并提供了友好的用户接口,允许用户指定异步组件加载器、配置 Loading 组件、发生错误时展示的 Error 组件、Loading 组件的延迟展示时间、组件超时时间以及错误回调。
组件在加载的时候发生错误是很常见的情况,因此 Vue3 还提供了重试机制。这样用户在需要处理异步组件相关场景时,只需给 defineAsyncComponent
函数传入相关配置项即可实现,会比自己实现简单很多。
参考资料
- 《Vue.js 设计与实现》霍春阳·著
转载自:https://juejin.cn/post/7355012708582080531