技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

Vue 异步组件的实现原理

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/17-Vue 异步组件的实现原理
本文目录6 个章节
  1. 为什么需要异步组件?
  2. Vue 2 的异步组件
  3. Vue 3 的当前写法:defineAsyncComponent
  4. Webpack 与现代构建工具
  5. 总结
  6. 官方参考

Vue 异步组件的实现原理

本文原文主要分析 Vue 2 的异步组件源码。resolveAsyncComponentCtor.cidowners$forceUpdate 都是 Vue 2 runtime 的内部实现,不是 Vue 3 的公共 API。下面保留这部分源码学习内容,再补充 Vue 3.5 的 defineAsyncComponent()Suspense 和现代代码分割写法。

为什么需要异步组件?

大型应用通常包含很多不需要在首屏显示的页面或功能。如果把所有组件打进首屏 JavaScript,用户必须先下载不相关的代码。异步组件可以把组件拆成独立 chunk,在组件真正被渲染时再加载:

  • 减少首屏需要下载的 JavaScript。
  • 将不常用的功能按需加载。
  • 允许为加载中、加载失败和超时状态提供界面。

异步组件解决的是组件加载时机;它和路由懒加载经常一起使用,但路由懒加载还涉及路由配置和页面切换。

Vue 2 的异步组件

基本写法

Vue 2 中可以用一个接收 resolvereject 的工厂函数注册异步组件:

// 全局注册(Vue 2 历史写法)
Vue.component('async-component', (resolve, reject) => {
  setTimeout(() => {
    resolve({
      template: '<div>async-component</div>'
    })
  }, 2000)
})

这个示例使用了 template 字符串,需要带模板编译器的 Vue 2 构建版本;runtime-only 构建应改用预编译的 SFC、render 函数或动态 import()

也可以局部注册一个返回 Promise 的工厂函数:

new Vue({
  components: {
    asyncComponent: () => import('./test.vue')
  }
})

Vue 2 只有在组件需要被渲染时才会执行工厂函数,并缓存解析结果。resolve 表示加载成功,reject(reason) 表示加载失败。

创建组件 VNode 时发生了什么?

Vue 2 在创建组件 VNode 时,会区分普通组件构造器和异步组件工厂函数。下面是根据 Vue 2.7 源码整理的伪代码,省略了和本文无关的参数及选项合并:

function createComponent(Ctor, data, context, children, tag) {
  if (isUndef(Ctor)) {
    return
  }

  const baseCtor = context.$options._base
  let asyncFactory

  // 工厂函数还没有 cid,说明它可能是异步组件工厂
  if (isUndef(Ctor.cid)) {
    asyncFactory = Ctor
    Ctor = resolveAsyncComponent(asyncFactory, baseCtor)

    if (Ctor === undefined) {
      // 组件尚未加载完成,先创建异步占位 VNode
      return createAsyncPlaceholder(
        asyncFactory,
        data,
        context,
        children,
        tag
      )
    }
  }

  // 解析成功后,Ctor 已经是正常的组件构造器
  resolveConstructorOptions(Ctor)
  installComponentHooks(data)
  return new VNode(/* 组件 VNode */)
}

加载尚未完成时,Vue 2 会先渲染一个注释形式的占位节点;加载完成后,异步组件会触发拥有它的组件重新渲染,下一次创建 VNode 时就会进入正常组件流程。这里不是“再次创建 createEmptyVNode 就完成渲染”,而是占位节点被正常组件 VNode 替换。

resolveAsyncComponent 的核心流程

Vue 2.7 中,resolveAsyncComponent 负责保存加载状态、处理多个 owner、超时和重新渲染。下面是保留原文思路的简化代码:

function resolveAsyncComponent(factory, baseCtor) {
  // 已解析:直接复用构造器
  if (isDef(factory.resolved)) {
    return factory.resolved
  }

  const owner = currentRenderingInstance
  if (!owner) {
    return
  }

  if (!factory.owners) {
    factory.owners = [owner]
    let sync = true
    let timerLoading = null
    let timerTimeout = null

    const forceRender = renderCompleted => {
      for (const owner of factory.owners) {
        owner.$forceUpdate()
      }

      if (renderCompleted) {
        factory.owners.length = 0
        if (timerLoading !== null) clearTimeout(timerLoading)
        if (timerTimeout !== null) clearTimeout(timerTimeout)
      }
    }

    const resolve = once(res => {
      factory.resolved = ensureCtor(res, baseCtor)
      if (!sync) {
        forceRender(true)
      } else {
        factory.owners.length = 0
      }
    })

    const reject = once(reason => {
      if (factory.errorComp) {
        factory.error = true
        forceRender(true)
      }
      warn(`Failed to resolve async component: ${String(reason)}`)
    })

    const res = factory(resolve, reject)
    // 此处还会处理 Promise、loading、error、delay 和 timeout
    sync = false

    return factory.loading
      ? factory.loadingComp
      : factory.resolved
  }

  factory.owners.push(owner)
  return factory.loading ? factory.loadingComp : factory.resolved
}

源码中还会在 owner 销毁时把它从 factory.owners 移除,避免异步结果返回后更新已经销毁的组件。$forceUpdate() 在这里不是业务层推荐的更新手段,而是 Vue 2 内部在“组件加载完成但没有普通响应式数据变化”时重新执行渲染的方式。

once 为什么存在?

异步工厂可能错误地多次调用 resolvereject。Vue 2 使用闭包保证每个处理函数只生效一次:

function once(fn) {
  let called = false

  return function (...args) {
    if (!called) {
      called = true
      return fn.apply(this, args)
    }
  }
}

Promise 异步组件

动态 import() 表达式返回 Promise,Webpack、Rollup、Vite 等构建工具可以把它作为代码分割点:

Vue.component(
  'async-webpack-example',
  () => import('./my-async-component.vue')
)

需要注意:静态 import Foo from './Foo.vue' 是模块声明;只有动态 import('./Foo.vue') 表达式才返回 Promise。CommonJS 的 require() 在 Node.js 语境下通常是同步加载,但具体行为还会受到 bundler 和配置影响,不能简单概括所有环境。

Vue 2 的 loading、error 和 timeout

Vue 2.3+ 支持返回一个带有 componentloadingerror 等字段的对象。原文示例的箭头函数缺少返回值,正确的 Vue 2 历史写法如下:

const AsyncComponent = () => ({
  // 必须是 Promise
  component: import('./test.vue'),
  loading: LoadingComponent,
  error: ErrorComponent,
  // 默认延迟 200ms 后显示 loading
  delay: 200,
  // 默认不超时;这里设置为 3000ms
  timeout: 3000
})

Vue.component('async-component', AsyncComponent)

Vue 2 的内部流程会:

  1. res.component 调用 then(resolve, reject)
  2. ensureCtor 把加载结果转换为组件构造器。
  3. 在 delay 到期且尚未完成时显示 loading 组件。
  4. 在 timeout 到期时调用 reject,必要时显示 error 组件。
  5. 通过重新渲染把 loading/error/占位节点替换为最终组件。

以下是原理级伪代码:

const res = factory(resolve, reject)

if (isObject(res)) {
  if (isPromise(res)) {
    res.then(resolve, reject)
  } else if (isPromise(res.component)) {
    res.component.then(resolve, reject)

    if (res.error) {
      factory.errorComp = ensureCtor(res.error, baseCtor)
    }

    if (res.loading) {
      factory.loadingComp = ensureCtor(res.loading, baseCtor)
      timerLoading = setTimeout(() => {
        if (!factory.resolved && !factory.error) {
          factory.loading = true
          forceRender(false)
        }
      }, res.delay === 0 ? 0 : (res.delay || 200))
    }

    if (res.timeout != null) {
      timerTimeout = setTimeout(() => {
        if (!factory.resolved) {
          reject(`timeout (${res.timeout}ms)`)
        }
      }, res.timeout)
    }
  }
}

上面的代码只用于理解 Vue 2 内部状态机,不能当成稳定扩展 API。

Vue 3 的当前写法:defineAsyncComponent

Vue 3 不再使用 Vue.component(name, (resolve, reject) => {}) 作为公共异步组件写法。应使用 defineAsyncComponent()

<script setup>
import { defineAsyncComponent } from 'vue'

const AsyncPanel = defineAsyncComponent(() => import('./Panel.vue'))
</script>

<template>
  <AsyncPanel />
</template>

它也可以全局或局部注册:

import { createApp, defineAsyncComponent } from 'vue'
import App from './App.vue'

const AsyncPanel = defineAsyncComponent(() => import('./Panel.vue'))

const app = createApp(App)
app.component('AsyncPanel', AsyncPanel)
app.mount('#app')

defineAsyncComponent() 返回一个包装组件。包装组件真正被渲染时才调用 loader,loader resolve 后再渲染内部组件,并把 props 和 slots 传递给内部组件。

loading、error、delay、timeout

import { defineAsyncComponent } from 'vue'
import LoadingComponent from './LoadingComponent.vue'
import ErrorComponent from './ErrorComponent.vue'

const AsyncPanel = defineAsyncComponent({
  loader: () => import('./Panel.vue'),
  loadingComponent: LoadingComponent,
  errorComponent: ErrorComponent,
  delay: 200,
  timeout: 3000,
  onError(error, retry, fail, attempts) {
    // 网络抖动时最多重试 3 次,其余错误直接失败
    if (attempts <= 3 && error.message.includes('fetch')) {
      retry()
    } else {
      fail()
    }
  }
})

Vue 3 的配置名与 Vue 2 不同:component 变为 loaderloading 变为 loadingComponenterror 变为 errorComponent。当前文档中 timeout 默认值为 Infinity,只有显式设置后才会因超时进入 error 状态。

<Suspense> 一起使用

异步组件可以交给 <Suspense> 统一处理 fallback:

<template>
  <Suspense>
    <AsyncPanel />

    <template #fallback>
      正在加载面板……
    </template>
  </Suspense>
</template>

<Suspense> 还可以等待 async setup() 等异步依赖。当前 Vue 文档仍将 Suspense 标记为实验性功能;异步组件默认可以被 Suspense 控制。如果希望异步组件自己显示 loadingComponenterrorComponent,可以设置 suspensible: false

const AsyncPanel = defineAsyncComponent({
  loader: () => import('./Panel.vue'),
  suspensible: false,
  loadingComponent: LoadingComponent,
  errorComponent: ErrorComponent
})

Webpack 与现代构建工具

Vue 2 时代常见的 require.ensure() 是 webpack-specific 的历史 API,应优先改用动态 import()

// Vue 2 / webpack 历史写法
Vue.component('async-component', (resolve, reject) => {
  require.ensure([], () => {
    resolve(require('./test.vue'))
  }, 'async-component')
})

// 推荐的动态 import 写法
const AsyncComponent = () => import(
  /* webpackChunkName: "async-component" */
  './test.vue'
)

原文中的 import(/* webpackChunkName: "asyncComponent" */, './test.vue') 多了一个逗号,会造成语法错误;Webpack 魔法注释应放在动态 import 的参数注释位置,如上例。

在 Vue Router 4 中,路由懒加载通常直接写成:

const routes = [
  {
    path: '/panel',
    component: () => import('./views/PanelView.vue')
  }
]

总结

  • Vue 2 异步组件通过工厂函数、resolve/reject、占位 VNode 和内部重新渲染实现。
  • Vue 2 的 resolveAsyncComponentCtorowners$forceUpdate 是历史内部实现。
  • 动态 import() 返回 Promise,可与代码分割配合;静态 import 不等同于动态加载。
  • Vue 3 应使用 defineAsyncComponent(),可配置 loader、loading/error 组件、delay、timeout 和重试。
  • <Suspense> 可统一处理异步依赖,但当前仍是实验性功能;需要时可通过 suspensible 选择由谁控制 loading/error。

官方参考

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS