加载中...

理解 Vue3 的异步组件

在大型的前端项目中,为了提升应用页面的加载性能,可能会需要做组件懒加载,即在需要使用该组件的时候才加载该组件,这个时候异步组件就派上了用场。所谓异步组件,就是指以异步的方式加载并渲染一个组件。

从根本上来说,异步组件的实现不需要任何框架层面的支持,我们完全可以借助原生 JS 的动态 import 来实现。例如这个渲染 App 组件到页面的示例:

js
代码解读
复制代码
// App.js const App = { template: ` <div>异步组件</div> ` } export default App
html
代码解读
复制代码
<!-- 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 也是有能力仅异步加载页面中的某个组件的。

js
代码解读
复制代码
// CompB.js const CompB = { template: ` <div>异步组件</div> ` } export default CompB
html
代码解读
复制代码
<!-- 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 是非常完善的 👍。

ts
代码解读
复制代码
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 组件选项来注册他。这样,在模板中就可以像使用普通组件一样使用异步组件了。这会比我们自行实现异步组件方式要简单直接很多。

html
代码解读
复制代码
<script> import { defineAsyncComponent } from ''vue'' export default { components: { AdminPage: defineAsyncComponent(() => import(''./components/AdminPageComponent.vue'') ) } } </script> <template> <AdminPage /> </template>

defineAsyncComponent 函数会先判断入参是否为函数,如果是函数,则说明用户传入的是加载器,则需要将传入的参数转换为配置项的形式。这也是 defineAsyncComponent 函数定义异步组件支持两种形式的参数的原因。

ts
代码解读
复制代码
// 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)、加载的超时时长以及加载错误的回调函数。

ts
代码解读
复制代码
// 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 用于存储异步加载函数加载成功后返回的组件。

ts
代码解读
复制代码
// 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 函数,用于调用异步加载函数。在加载组件的过程中,发生错误的情况非常常见,尤其是在网络不稳定的情况下。因此,提供重试机制,会提升用户的开发体验。

ts
代码解读
复制代码
// 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 函数用来加载异步组件。

ts
代码解读
复制代码
// 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

ts
代码解读
复制代码
if ( comp && (comp.__esModule || comp[Symbol.toStringTag] === ''Module'') ) { comp = comp.default }

defineAsyncComponent 函数最后会返回一个包装组件,该包装组件的名字被定义为 AsyncComponentWrapper

ts
代码解读
复制代码
// 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 函数返回函数的话,该函数会作为组件的渲染函数。

ts
代码解读
复制代码
setup() { // already resolved if (resolvedComp) { return () => createInnerComp(resolvedComp!, instance) } }

onError 函数为 setup 函数发生错误时的公共错误处理函数。

ts
代码解读
复制代码
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 内能够完成加载的情况来说,就避免了闪烁问题的出现。

ts
代码解读
复制代码
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) } }

如果用户指定了超时时长,则开启一个定时器计时,如果到了超时时间组件还未加载完成,则创建一个错误对象,提示异步组件超时。

ts
代码解读
复制代码
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 语句捕获异步组件加载时出现的错误。

ts
代码解读
复制代码
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 组件

ts
代码解读
复制代码
// 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 函数要设置到异步加载器函数返回的组件上,才能让 refce 函数起作用。

ts
代码解读
复制代码
// 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 组件的自定义事件。

ts
代码解读
复制代码
// 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 函数传入相关配置项即可实现,会比自己实现简单很多。

参考资料

  1. 《Vue.js 设计与实现》霍春阳·著