Vue 异步组件的实现原理
本文原文主要分析 Vue 2 的异步组件源码。
resolveAsyncComponent、Ctor.cid、owners和$forceUpdate都是 Vue 2 runtime 的内部实现,不是 Vue 3 的公共 API。下面保留这部分源码学习内容,再补充 Vue 3.5 的defineAsyncComponent()、Suspense和现代代码分割写法。
为什么需要异步组件?
大型应用通常包含很多不需要在首屏显示的页面或功能。如果把所有组件打进首屏 JavaScript,用户必须先下载不相关的代码。异步组件可以把组件拆成独立 chunk,在组件真正被渲染时再加载:
- 减少首屏需要下载的 JavaScript。
- 将不常用的功能按需加载。
- 允许为加载中、加载失败和超时状态提供界面。
异步组件解决的是组件加载时机;它和路由懒加载经常一起使用,但路由懒加载还涉及路由配置和页面切换。
Vue 2 的异步组件
基本写法
Vue 2 中可以用一个接收 resolve、reject 的工厂函数注册异步组件:
// 全局注册(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 为什么存在?
异步工厂可能错误地多次调用 resolve 或 reject。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+ 支持返回一个带有 component、loading、error 等字段的对象。原文示例的箭头函数缺少返回值,正确的 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 的内部流程会:
- 对
res.component调用then(resolve, reject)。 - 用
ensureCtor把加载结果转换为组件构造器。 - 在 delay 到期且尚未完成时显示 loading 组件。
- 在 timeout 到期时调用 reject,必要时显示 error 组件。
- 通过重新渲染把 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 变为 loader,loading 变为 loadingComponent,error 变为 errorComponent。当前文档中 timeout 默认值为 Infinity,只有显式设置后才会因超时进入 error 状态。
与 <Suspense> 一起使用
异步组件可以交给 <Suspense> 统一处理 fallback:
<template>
<Suspense>
<AsyncPanel />
<template #fallback>
正在加载面板……
</template>
</Suspense>
</template>
<Suspense> 还可以等待 async setup() 等异步依赖。当前 Vue 文档仍将 Suspense 标记为实验性功能;异步组件默认可以被 Suspense 控制。如果希望异步组件自己显示 loadingComponent、errorComponent,可以设置 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 的
resolveAsyncComponent、Ctor、owners和$forceUpdate是历史内部实现。 - 动态
import()返回 Promise,可与代码分割配合;静态import不等同于动态加载。 - Vue 3 应使用
defineAsyncComponent(),可配置 loader、loading/error 组件、delay、timeout 和重试。 <Suspense>可统一处理异步依赖,但当前仍是实验性功能;需要时可通过suspensible选择由谁控制 loading/error。