Vue 源码(八):异步组件原理
本文原文分析的是 Vue 2 异步组件的实现,主线是
resolveAsyncComponent、异步占位 VNode、owners和$forceUpdate。这部分仍然适合学习 Vue 2 源码,但原文抓取后的代码有大量缺失换行和语法损坏,且没有覆盖 Vue 3 的异步组件。本文保留 Vue 2.7.16 的源码思路,并补充 Vue 3.5.39 的
defineAsyncComponent对照。两代实现不能混写。
一、什么是异步组件
普通组件在应用加载或当前模块加载时就会进入 JavaScript bundle;异步组件则把组件代码拆成独立 chunk,在组件真正需要渲染时再加载:
应用启动
↓
加载首屏需要的代码
↓ 用户进入某个功能
动态 import 下载组件 chunk
↓
创建并渲染组件
异步组件适合:
- 页面中暂时不会显示的大型组件;
- 弹窗、抽屉、编辑器等低频功能;
- 路由级代码分割;
- 将大型应用拆成多个可缓存的 JavaScript chunk。
异步组件的“异步”主要指组件代码或组件定义的加载过程,不等于组件内部接口请求完成。组件加载完成后,仍然可能需要等待自己的数据请求。
二、Vue 2 的三类历史写法
Vue 2 文档通常将异步组件 factory 分为三类:
- 通过
resolve/reject回调加载的 factory; - 返回 Promise 的 factory;
- 返回
component/loading/error/delay/timeout配置对象的高级 factory。
这些写法在 Vue 2.7.16 中仍有历史兼容价值。
1. 普通 factory callback
Vue.component('hello-world', function (resolve, reject) {
require(['./components/HelloWorld.vue'], resolve)
})
这里的 require([...], resolve) 通常是 webpack 等构建工具提供的异步模块语法,不是浏览器原生 require。模块加载成功后调用 resolve,失败时调用 reject。
2. Promise factory
Vue.component(
'hello-world',
() => import('./components/HelloWorld.vue'),
)
构建工具会把 import() 拆成代码分块。组件真正渲染时,factory 返回 Promise,Vue 负责把加载结果转换成组件构造函数。
3. 高级 factory
const LoadingComp = {
template: '<div>loading...</div>',
}
const ErrorComp = {
template: '<div>加载失败</div>',
}
const AsyncComp = () => ({
component: import('./components/HelloWorld.vue'),
loading: LoadingComp,
error: ErrorComp,
delay: 200,
timeout: 3000,
})
Vue.component('hello-world', AsyncComp)
Vue 2 的字段名是:
component:真正的组件 Promise;loading:加载等待时间结束后显示的组件;error:加载失败后显示的组件;delay:显示 loading 前的等待时间,默认 200ms;timeout:最长等待时间,不设置时不主动超时。
不要把 Vue 2 的 loading、error 直接改名成 Vue 3 的 loadingComponent、errorComponent 后还声称是同一套 API。
三、Vue 2.7.16 中的源码位置
Vue 2.7.16 已经将相关源码迁移为 TypeScript,不能继续把它们标成旧的 .js 路径:
src/core/vdom/helpers/resolve-async-component.tssrc/core/vdom/create-component.tssrc/core/instance/events.tssrc/core/instance/lifecycle.ts
本文代码块有些是为了讲解流程而整理的伪代码,不是 Vue 2.7.16 的完整源码拷贝。
四、组件注册:Vue.component 做了什么
Vue 2 初始化全局 API 时,会注册 Vue.component、Vue.directive 和 Vue.filter 等资源 API。简化后的组件注册逻辑如下:
export const ASSET_TYPES = [
'component',
'directive',
'filter',
]
function initAssetRegisters(Vue) {
ASSET_TYPES.forEach(type => {
Vue[type] = function registerAsset(id, definition) {
if (!definition) {
return this.options[type + 's'][id]
}
if (type === 'component' && isPlainObject(definition)) {
definition.name = definition.name || id
definition = this.options._base.extend(definition)
}
this.options[type + 's'][id] = definition
return definition
}
})
}
注册普通对象组件时,Vue 2 会通过 Vue.extend 创建组件构造函数;注册异步 factory 时,传入的是函数,Vue 不会立刻把它扩展成普通组件构造函数,而是先保存这个 factory,等渲染阶段解析。
五、createComponent 如何识别异步组件
渲染组件 VNode 时,Vue 2 的 createComponent 会区分普通组件构造函数、组件配置对象和异步 factory。流程可以简化为:
const baseCtor = context.$options._base
if (isObject(Ctor)) {
Ctor = baseCtor.extend(Ctor)
}
let asyncFactory
if (isUndef(Ctor.cid)) {
// 没有 cid 的函数可能是异步 factory
asyncFactory = Ctor
Ctor = resolveAsyncComponent(asyncFactory, baseCtor)
if (Ctor === undefined) {
return createAsyncPlaceholder(
asyncFactory,
data,
context,
children,
tag,
)
}
}
普通 Vue 组件构造函数通常有 cid。如果传入的函数没有 cid,Vue 2 会把它当成可能的异步 factory,交给 resolveAsyncComponent。
createAsyncPlaceholder
异步组件还没有加载完成时,Vue 2 不会凭空丢掉这个位置,而是创建一个注释 VNode 作为占位符:
function createAsyncPlaceholder(
factory,
data,
context,
children,
tag,
) {
const node = createEmptyVNode()
node.asyncFactory = factory
node.asyncMeta = {
data,
context,
children,
tag,
}
return node
}
这个占位符的作用是:
- 保留异步组件应该出现的位置;
- 记录 factory 和创建时的上下文;
- 让后续异步解析完成后可以重新进入 patch 流程;
- 在未配置 loading 组件时,页面通常只看到一个注释节点,而不是一个真正的 loading DOM。
注释 VNode 不是异步组件本身,也不是组件实例;它只是等待期间的占位信息。
六、resolveAsyncComponent 的整体流程
Vue 2.7.16 的 resolveAsyncComponent 负责:
- 读取已经解析成功、失败或 loading 的缓存状态;
- 收集正在使用该 factory 的 owner;
- 调用 factory;
- 处理 Promise、回调和高级配置对象;
- 在加载完成后让 owner 重新渲染。
可以先用状态流程理解:
factory.resolved 存在? ── 是 ──> 返回已解析组件
│ 否
factory.error + errorComp? ── 是 ──> 返回错误组件
│ 否
factory.loading + loadingComp? ── 是 ──> 返回 loading 组件
│ 否
记录当前 owner
↓
调用 factory(resolve, reject)
↓
未完成:返回 undefined,创建注释占位 VNode
↓
完成:缓存 resolved/error,forceRender owners
1. 缓存状态优先级
简化后的入口判断:
if (factory.error && factory.errorComp) {
return factory.errorComp
}
if (factory.resolved) {
return factory.resolved
}
if (factory.loading && factory.loadingComp) {
return factory.loadingComp
}
这些状态挂在 factory 函数上,因此同一个异步 factory 可以复用加载结果。这里缓存的是解析后的组件构造函数,不是每个页面使用的组件实例;实例是否保留还要由 KeepAlive 决定。
2. owner 和 owners
owner 是当前正在渲染、使用这个异步组件的 Vue 实例。第一次解析时,Vue 会创建 factory.owners:
const owner = currentRenderingInstance
if (owner && factory.owners) {
if (factory.owners.indexOf(owner) === -1) {
factory.owners.push(owner)
}
}
if (owner && !factory.owners) {
const owners = factory.owners = [owner]
// 开始真正调用 factory
}
为什么需要数组?因为同一个异步组件可能同时被多个父组件使用:
ParentA ─┐
├─ 同一个 async factory
ParentB ─┘
加载完成后,所有正在等待的 owner 都需要重新渲染。
Vue 2 还会监听 owner 的 hook:destroyed,在 owner 销毁时从数组中删除它:
owner.$on('hook:destroyed', () => {
remove(owners, owner)
})
这也是 Vue 2 hook:* 实例事件机制在内部的一处使用。
3. forceRender
const forceRender = renderCompleted => {
for (let i = 0; i < owners.length; i++) {
owners[i].$forceUpdate()
}
if (renderCompleted) {
owners.length = 0
clearLoadingTimer()
clearTimeoutTimer()
}
}
forceRender 的准确含义是:
- 遍历等待当前异步 factory 的 owner;
- 对这些 owner 调用
$forceUpdate(); - owner 重新执行 render;
- 新一轮
createComponent发现factory.resolved后,创建真正的组件 VNode; - 完成后清除 owner 和定时器。
它不是“强制更新所有子孙组件”,也不是对全局应用执行刷新。
七、异步加载完成:resolve、reject、once
1. once
Vue 2 使用 once 保证同一个回调不会重复处理同类结果:
function once(fn) {
let called = false
return function onceWrapper(...args) {
if (!called) {
called = true
return fn.apply(this, args)
}
}
}
2. ensureCtor
webpack 的动态导入可能返回 ESM module 对象,而 Vue 2 需要组件对象或构造函数。ensureCtor 会解包 default:
function ensureCtor(comp, base) {
if (
comp.__esModule ||
(hasSymbol && comp[Symbol.toStringTag] === 'Module')
) {
comp = comp.default
}
return isObject(comp)
? base.extend(comp)
: comp
}
3. resolve
const resolve = once(res => {
factory.resolved = ensureCtor(res, baseCtor)
if (!sync) {
forceRender(true)
} else {
owners.length = 0
}
})
如果 factory 在当前同步调用阶段就返回结果,sync 仍为 true,Vue 可以直接使用结果;如果 Promise 在之后完成,sync 已经变为 false,就需要通过 forceRender(true) 让 owner 重新渲染。
4. reject
const reject = once(reason => {
if (process.env.NODE_ENV !== 'production') {
warn(`Failed to resolve async component: ${String(factory)}`)
if (reason) warn(`Reason: ${reason}`)
}
if (factory.errorComp) {
factory.error = true
forceRender(true)
}
})
只有配置了 error component 时,Vue 2 才能在页面中显示错误组件;否则主要是开发环境警告和占位状态。
超时后的一个常见误区: resolve 和 reject 是分别用 once 包装的函数。调用 reject 并不等于从 JavaScript 层面取消了底层 Promise,之后网络请求仍可能完成并调用 resolve。如果 error 状态已经被设置,后续解析时可能仍优先显示 error component。因此不能简单写成“超时后绝对不会执行 resolve”;真正的请求取消需要配合 AbortController 或请求库的取消机制。
八、普通 callback factory 的完整流程
以这段代码为例:
Vue.component('hello-world', function (resolve, reject) {
require(['./components/HelloWorld.vue'], resolve)
})
第一次渲染
createComponent
↓
发现 Ctor 没有 cid
↓
resolveAsyncComponent
↓
记录 owner
↓
调用 factory(resolve, reject)
↓
require 尚未完成,factory 返回 undefined
↓
resolveAsyncComponent 返回 undefined
↓
createAsyncPlaceholder
↓
页面保留注释占位 VNode
加载成功
require 完成
↓
调用 resolve(module)
↓
ensureCtor 解包 module.default
↓
factory.resolved = 组件构造函数
↓
forceRender(true)
↓
owner.$forceUpdate()
↓
重新 render
↓
返回 factory.resolved
↓
patch 真正的组件 VNode
多个 owner 同时使用
如果 ParentA 和 ParentB 同时使用同一个 factory:
第一次解析:owners = [ParentA]
第二次解析:owners = [ParentA, ParentB]
加载完成:分别调用 ParentA.$forceUpdate() 和 ParentB.$forceUpdate()
如果其中一个父组件在加载完成前销毁,hook:destroyed 监听器会把它从 owners 中移除,避免对已经销毁的实例继续更新。
九、Promise 异步组件
Vue.component(
'hello-world',
() => import('./components/HelloWorld.vue'),
)
这类 factory 被调用后返回一个 Promise:
const res = factory(resolve, reject)
if (isPromise(res)) {
if (isUndef(factory.resolved)) {
res.then(resolve, reject)
}
}
后续过程与 callback factory 类似:
- 第一次渲染时 Promise 尚未完成;
resolveAsyncComponent返回undefined;createComponent创建注释占位 VNode;- Promise 成功后调用
resolve; - 解析结果缓存到
factory.resolved; - owner 重新渲染并创建真正组件。
Vue 2 的异步组件 factory 会复用同一个 pending 解析过程,避免同一 factory 被重复启动多个加载任务。
十、高级异步组件的 loading 和 timeout
当 factory 返回下面的对象时:
const AsyncComp = () => ({
component: import('./HelloWorld.vue'),
loading: LoadingComp,
error: ErrorComp,
delay: 200,
timeout: 3000,
})
Vue 2 会额外处理 res.component、loading 和 timeout。
1. component Promise
if (isPromise(res.component)) {
res.component.then(resolve, reject)
}
2. loading delay
if (isDef(res.loading)) {
factory.loadingComp = ensureCtor(res.loading, baseCtor)
if (res.delay === 0) {
factory.loading = true
} else {
timerLoading = setTimeout(() => {
timerLoading = null
if (
isUndef(factory.resolved) &&
isUndef(factory.error)
) {
factory.loading = true
forceRender(false)
}
}, res.delay || 200)
}
}
delay 的意义是避免短暂的网络请求让 loading 组件闪烁。delay: 0 表示立即进入 loading 状态。
3. timeout
if (isDef(res.timeout)) {
timerTimeout = setTimeout(() => {
timerTimeout = null
if (isUndef(factory.resolved)) {
reject(`timeout (${res.timeout}ms)`)
}
}, res.timeout)
}
如果配置了 error component,reject 会将 factory.error 设为 true,并让 owner 重新渲染 error component;如果没有 error component,开发环境会输出警告,页面不一定得到可见的错误 UI。
4. loading、error、resolved 的优先级
正在加载且 delay 已到:显示 loading
加载失败且有 error component:显示 error
加载成功:显示 resolved component
实际判断由 resolveAsyncComponent 的状态字段决定。定时器在完成或失败后会被清理。
十一、Vue 2.7 的 defineAsyncComponent
Vue 2.7.4 起增加了兼容 API defineAsyncComponent,因此不能再说“Vue 2.7 完全没有这个 API”:
import { defineAsyncComponent } from 'vue'
const AsyncPanel = defineAsyncComponent(() => import('./Panel.vue'))
但 Vue 2.7 的实现是兼容桥,底层仍然使用 Vue 2 factory、placeholder、owners 和 resolveAsyncComponent:
- 函数形式会包装成带
loader的配置; - 最终返回旧式 Vue 2 async factory;
loading、error、delay、timeout仍会交给 Vue 2 解析流程;suspensible不是真正的 Vue 3 Suspense 能力,Vue 2 没有 Vue 3 的 Suspense runtime;- 不能把它描述成 Vue 3 的
AsyncComponentWrapper。
十二、Vue 3.5 的 defineAsyncComponent
Vue 3 使用新的异步组件 wrapper:
import { defineAsyncComponent } from 'vue'
const AsyncPanel = defineAsyncComponent(() => import('./Panel.vue'))
完整配置:
const AsyncPanel = defineAsyncComponent({
loader: () => import('./Panel.vue'),
loadingComponent: LoadingPanel,
errorComponent: ErrorPanel,
delay: 200,
timeout: 3000,
suspensible: true,
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
retry()
} else {
fail()
}
},
})
Vue 3.5 的实现重点:
defineAsyncComponent返回一个 wrapper component;- loader 结果会缓存到
resolvedComp; - 同一 wrapper 的并发加载会复用 pending Promise;
- loader reject 后可以通过
onError控制重试; - 默认
delay为 200ms,timeout默认Infinity; - 有父级 Suspense 且
suspensible为 true 时,wrapper 会把 loader 注册为 Suspense 依赖,Suspense 的 fallback/pending 控制未完成状态;wrapper 自己的 loading、delay 和 timeout 分支不会按普通方式驱动 UI,loader 错误仍通过onError或errorCaptured处理; - Vue 3.5 还支持与服务端懒 hydration 相关的
hydrate选项。
Vue 3 不再创建 Vue 2 那种 createAsyncPlaceholder 注释 VNode,也不使用 factory.owners + owner.$forceUpdate() 作为主要实现。
十三、Vue 2.7 与 Vue 3.5 对照
| 维度 | Vue 2.7.16 | Vue 3.5.39 |
|---|---|---|
| 基础 API | callback factory、Promise factory | defineAsyncComponent |
| 高级配置 | component/loading/error/delay/timeout | loader/loadingComponent/errorComponent/delay/timeout |
| 未完成状态 | comment VNode + asyncFactory/asyncMeta | wrapper、renderer 和可选 Suspense fallback 处理 |
| 等待者 | factory 上的 owners | pending Promise 和响应式 wrapper 状态 |
| 完成更新 | owner $forceUpdate() | wrapper 状态更新并重新渲染 |
| 加载结果 | factory.resolved | resolvedComp |
| Suspense | 不支持 Vue 3 Suspense | 内置但仍标为 Experimental |
| KeepAlive | Vue 2 patch/缓存机制 | Vue 3 KeepAlive 缓存和 LRU |
| 3.5 懒 hydration | 无 | 支持 hydrate 配置 |
十四、异步组件与路由懒加载
Vue Router 4 中,路由级懒加载应直接写返回 Promise 的 route component:
const routes = [
{
path: '/reports',
component: () => import('./views/Reports.vue'),
},
]
不要再额外包一层:
// 不推荐
{
path: '/reports',
component: defineAsyncComponent(() => import('./views/Reports.vue')),
}
Router 官方把路由懒加载和普通 async component 视为不同功能:Router 在首次进入路由时加载并缓存路由组件;组件 wrapper 则负责组件自身的 loading/error/retry。路由懒加载本身也不会因为放在 Suspense 旁边就自动触发 Suspense fallback。
参考:Vue Router Lazy Loading Routes。
十五、v-if、v-show 和异步组件
原文最后使用 v-if 配合异步组件:
<template>
<button @click="showPanel = true">显示组件</button>
<AsyncPanel v-if="showPanel" />
</template>
v-if 为 false 时,异步组件不会进入渲染流程,通常也不会触发 loader;变为 true 后才真正渲染 wrapper。v-show 则会创建组件,只是改变 CSS 显示状态:
<!-- 组件会被创建,不能用于完全延迟加载 -->
<AsyncPanel v-show="showPanel" />
选择建议:
- 首次很少显示、初始化成本高:使用
v-if; - 高频显示/隐藏且需要保留 DOM:考虑
v-show; - 需要保留组件实例状态:使用
KeepAlive; - 需要延迟下载代码:使用
import()和异步组件/路由懒加载。
如果父组件需要调用异步子组件方法,不要使用固定的 setTimeout(..., 400) 猜测加载时间。应使用 nextTick 处理同步 DOM 更新,并让子组件发出 ready 事件或等待明确的加载 Promise。
十六、源码流程总结
Vue 2.7.16 的异步组件可以浓缩为:
注册 async factory
↓
createComponent 发现没有 cid
↓
resolveAsyncComponent
↓
记录使用该 factory 的 owners
↓
调用 factory(resolve, reject)
↓
未完成:创建 async placeholder comment VNode
↓
加载完成:ensureCtor + factory.resolved
↓
forceRender 所有 owners
↓
重新 render 并创建真实组件
Vue 3.5.39 则是:
defineAsyncComponent 创建 wrapper
↓
wrapper 首次渲染时调用 loader
↓
pending Promise 复用请求
↓
响应式 loaded/error/delayed 状态更新
↓
Suspense 或 wrapper 决定 fallback/loading/error
↓
渲染 resolved component
十七、总结
- Vue 2 异步组件有 callback factory、Promise factory 和高级配置对象三种历史写法;
- Vue 2.7.16 通过
resolveAsyncComponent解析 factory,并使用注释 VNode、owners和$forceUpdate完成异步更新; ensureCtor负责处理 ESMdefault和组件 options 对象;- Vue 2 的
loading/error、Vue 3 的loadingComponent/errorComponent是不同版本命名; - Vue 2.7.4 起存在兼容版
defineAsyncComponent,但底层仍是 Vue 2 factory,不是 Vue 3 wrapper; - Vue 3.5 使用
defineAsyncComponent、Suspense 和响应式异步状态,不再使用 Vue 2 的 placeholder/owners 主流程; v-if可以延迟组件进入渲染流程,v-show不能替代代码分割;- 路由级懒加载使用
component: () => import(...),不要把 route component 包成defineAsyncComponent; - 异步组件的代码加载、接口请求、KeepAlive 实例缓存和路由 chunk 是不同概念,排查问题时要分别定位。
官方参考
Vue 2
- Vue 2 动态与异步组件
- Vue 2 Loading 状态
- Vue 2.7.16
resolve-async-component.ts - Vue 2.7.16
create-component.ts - Vue 2.7.16
apiAsyncComponent.ts - Vue 2.7.16 changelog
Vue 3
- Vue 3 异步组件
defineAsyncComponentAPI- Vue 3 Suspense
- Vue 3 KeepAlive
- Vue 3.5.39
apiAsyncComponent.ts - Vue 3.5.39
Suspense.ts - Vue Router 4 路由懒加载
原文出处
作者:zygg不含糖
来源:稀土掘金
本文保留原文 Vue 2 异步组件源码分析,并补充 Vue 2.7.16 和 Vue 3.5.39 的实现差异。