likes
comments
collection
share

理解 Vue3 的异步组件

作者站长头像
站长
· 阅读数 5

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

从根本上来说,异步组件的实现不需要任何框架层面的支持,我们完全可以借助原生 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 函数要设置到异步加载器函数返回的组件上,才能让 refce 函数起作用。

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

参考资料

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