异步组件与 Suspense:类比 Bean 懒加载
为什么需要异步组件
一个笔记站有这些功能:首页、笔记详情、AI 助手、代码 Playground。用户打开首页时,浏览器要下载多少 JS?
同步写法(全量打包)
import HomePage from '@/pages/HomePage.vue'
import NoteDetailPage from '@/pages/NoteDetailPage.vue'
import AiChatPage from '@/pages/AiChatPage.vue' // 几 MB 的 AI SDK
import PlaygroundPage from '@/pages/PlaygroundPage.vue' // Monaco Editor 4MB+
打包时,这些组件一起塞进首页的 JS bundle。用户刚进首页就要下载几 MB 的 Monaco Editor——即使他从不点 Playground。
异步写法(按需加载)
import { defineAsyncComponent } from 'vue'
const AiChatPage = defineAsyncComponent(() => import('@/pages/AiChatPage.vue'))
const PlaygroundPage = defineAsyncComponent(() => import('@/pages/PlaygroundPage.vue'))
Vite 看到 import() 会自动把这些组件单独打包成 chunk,用户访问对应路由时才下载。首页 bundle 减小,首屏更快。
Java 对照:Spring 的 @Lazy
@Component
@Lazy
public class HeavyBean {
public HeavyBean() { /* 耗时初始化 */ }
}
@Lazy 让 Bean 在第一次被用到时才实例化,不是启动时。异步组件同理——要渲染时才下载。
1. defineAsyncComponent 基础
import { defineAsyncComponent } from 'vue'
// 最简写法
const AsyncComp = defineAsyncComponent(() => import('./MyComp.vue'))
// 完整配置
const AsyncComp = defineAsyncComponent({
loader: () => import('./MyComp.vue'),
loadingComponent: LoadingSpinner, // 加载中显示啥
errorComponent: ErrorView, // 加载失败显示啥
delay: 200, // 200ms 后才显示 loading(避免闪烁)
timeout: 3000, // 3s 还没加载完,显示 error
})
delay: 200 的细节:大部分情况下组件 100ms 内就加载完了。如果每次都立刻显示 loading 再立刻消失,UI 会"闪一下"。延迟 200ms 再显示 loading,让快路径不出现加载动画。
Java 对照:像 Hystrix / Resilience4j 的 fallback + timeout,前端社区的 UI 等价物。
2. 最常见的用法:路由懒加载
vue-router 里每个路由对应一个页面组件,路由懒加载是异步组件最经典的场景。
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('@/pages/HomePage.vue'), // 懒加载
},
{
path: '/notes/:slug',
component: () => import('@/pages/NoteDetailPage.vue'),
},
{
path: '/ai',
component: () => import('@/pages/AiChatPage.vue'),
},
],
})
component: () => import(...) 就是路由层的异步组件语法。Vue Router 内部会用 defineAsyncComponent 包装它。不用手动调 defineAsyncComponent。
实际效果
打开首页时 Network 面板能看到:
index.js 15 KB (主包)
HomePage.js 3 KB (首页 chunk)
点击进入 AI 页面时:
AiChatPage.js 180 KB (按需下载)
项目里的约定:src/router/ 里所有路由组件都用懒加载。只有当前路由组件会被下载。
3. <Suspense>:统一处理异步边界
defineAsyncComponent 配 loadingComponent 能显示加载态,但每个异步组件都要单独配很繁琐。更大的问题:组件的 <script setup> 里直接 await 的情况:
<!-- NoteDetailPage.vue -->
<script setup lang="ts">
const note = await fetchNote(slug) // 顶层 await
</script>
<template>
<article>{{ note.title }}</article>
</template>
Vue 3 支持 <script setup> 里的顶层 await——组件返回的不是同步的组件实例,而是一个 Promise。这种组件必须放在 <Suspense> 里。
基础用法
<template>
<Suspense>
<!-- default slot:真正要渲染的异步内容 -->
<NoteDetailPage :slug="slug" />
<!-- fallback slot:数据还没就绪时显示 -->
<template #fallback>
<div class="loading">加载中...</div>
</template>
</Suspense>
</template>
效果:
- 进入页面 → 显示 fallback 的"加载中..."
NoteDetailPage的await fetchNote(slug)完成 → 自动切换到真正的内容
捕获嵌套的异步
<Suspense> 会等子树里所有异步组件和顶层 await 都完成:
<Suspense>
<div>
<AsyncHeader /> <!-- 异步组件 A -->
<AsyncSidebar /> <!-- 异步组件 B -->
<ArticleWithTopLevelAwait /> <!-- 顶层 await 组件 C -->
</div>
<template #fallback>
<PageSkeleton />
</template>
</Suspense>
A、B、C 全部就绪后一次性显示内容——没有"半加载"状态。
Java 对照:像 CompletableFuture.allOf(...).thenRun(...) 统一等一组异步任务,全部完成才继续。
4. <Suspense> vs 手写 v-if="loaded"
手写加载状态:
<script setup>
const data = ref(null)
const loading = ref(true)
const error = ref(null)
onMounted(async () => {
try {
data.value = await fetch('/api/notes').then(r => r.json())
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
</script>
<template>
<div v-if="loading">加载中...</div>
<div v-else-if="error">出错了</div>
<div v-else>{{ data }}</div>
</template>
<Suspense> 把这套"三段模板 + 异步状态管理"简化成:
<!-- 父组件 -->
<Suspense>
<DataView />
<template #fallback><Spinner /></template>
</Suspense>
<!-- DataView.vue -->
<script setup>
const data = await fetch('/api/notes').then(r => r.json()) // 顶层 await
</script>
<template>{{ data }}</template>
优势:
- 子组件代码更直白(不用管 loading / error 态)
- 多个异步源统一一个加载态
- 避免"内容闪烁"(一部分先加载出来看着很糟)
权衡:
<Suspense>目前仍是实验性 API(Vue 3 文档明确标注),大版本可能有破坏性改动- 错误处理要配合
onErrorCaptured或<Suspense>的@pending / @resolve / @fallback事件 - 单个简单加载场景,
v-if="loaded"更直接
项目里的建议:
- 路由层:用路由懒加载 + 路由过渡(
<RouterView v-slot>包一层<Suspense>) - 页面内:简单 loading 用
v-if,多个异步源才上<Suspense>
5. 错误处理
异步组件本身加载失败
const AsyncComp = defineAsyncComponent({
loader: () => import('./MyComp.vue'),
errorComponent: ErrorView,
timeout: 5000,
onError(error, retry, fail, attempts) {
if (attempts < 3) {
retry() // 自动重试
} else {
fail()
}
},
})
Suspense 内部组件抛错
用 onErrorCaptured 在父组件里拦截:
<script setup>
import { onErrorCaptured, ref } from 'vue'
const error = ref(null)
onErrorCaptured((e) => {
error.value = e
return false // 阻止错误继续向上冒泡
})
</script>
<template>
<div v-if="error">出错啦:{{ error.message }}</div>
<Suspense v-else>
<DataView />
<template #fallback><Spinner /></template>
</Suspense>
</template>
Java 对照:onErrorCaptured ≈ @ControllerAdvice + @ExceptionHandler,声明一次处理子树抛出的所有错误。
6. 打包层面会发生什么
Vite 看到这行:
() => import('@/pages/AiChatPage.vue')
会把 AiChatPage.vue 以及它递归 import 的所有文件单独打包成一个 chunk(如 AiChatPage-abc123.js)。访问该路由时浏览器才请求这个文件。
查看 chunk 情况:
pnpm build
# 输出类似:
# dist/assets/HomePage-xxx.js 3.2 KB
# dist/assets/NoteDetailPage-xxx.js 5.1 KB
# dist/assets/AiChatPage-xxx.js 180.0 KB
pnpm build 末尾的 Vite 警告「chunks larger than 500 kB」就是在提醒你:"有块太大了,考虑用 import() 拆开"。
7. 项目里的用法规划
in4vue 目前的 src/router/index.ts 已经在用懒加载(每个页面都是 () => import(...))——打开 pnpm build 的输出能看到每个语言的高亮 chunk(wasm、cpp、emacs-lisp)都被 Shiki 按需拆分了。
以后会用 <Suspense> 的场景:
- AI 聊天页:首次打开要加载历史会话 + 初始化流式连接,整页包一层
<Suspense>,fallback 显示骨架屏 - 笔记详情页:如果笔记改成从远程 API 拉(当前是编译期静态导入),适合用
<Suspense>+ 顶层await
暂时不用的场景:
- 首页的笔记列表(数据是静态编译进 bundle 的,根本不用异步)
- 小的"加载中"状态(一个
v-if就够)
8. 新手常见误区
误区 1:给所有组件都加异步
// ❌ 小组件不值得拆分,反而多一次网络请求
const Button = defineAsyncComponent(() => import('./Button.vue'))
原则:只对大组件 + 非首屏必需的组件做异步。按钮、图标这类小东西同步打包更好。
误区 2:在循环里 defineAsyncComponent
// ❌ 每次渲染都创建新的异步组件,缓存失效
const items = computed(() =>
list.map(() => defineAsyncComponent(() => import('./Item.vue')))
)
defineAsyncComponent 要在模块顶层调用一次,复用返回值。
误区 3:忘了加 <Suspense>
<!-- 子组件有顶层 await -->
<script setup>
const data = await fetchData()
</script>
父组件没包 <Suspense> → 开发期警告 "A component with async setup() must be nested in a <Suspense> boundary"。
解法:要么加 <Suspense>,要么改成 onMounted 里 await。
速查表
// 异步组件
import { defineAsyncComponent } from 'vue'
const Comp = defineAsyncComponent(() => import('./Comp.vue'))
// 带配置
const Comp = defineAsyncComponent({
loader: () => import('./Comp.vue'),
loadingComponent, errorComponent,
delay: 200, timeout: 3000,
})
// 路由懒加载(vue-router)
{ path: '/x', component: () => import('@/pages/X.vue') }
// Suspense
<Suspense>
<AsyncChild />
<template #fallback><Spinner /></template>
</Suspense>
9. 原理深挖:defineAsyncComponent 内部就是个状态机
defineAsyncComponent 不返回原始组件,而是返回一个包装组件,包装组件根据 loader 状态切换渲染:
// 简化版源码
export function defineAsyncComponent(source) {
if (typeof source === 'function') source = { loader: source }
const { loader, loadingComponent, errorComponent, delay = 200, timeout } = source
let resolvedComp = undefined // 加载完的真组件
let pendingPromise = null // 加载中的 Promise(去重用)
function load() {
// ★ 同一个组件多处用,只发一次请求
return pendingPromise ||= loader()
.catch((err) => {
if (source.onError) {
return new Promise((resolve, reject) => {
source.onError(err,
() => resolve(load()), // retry
() => reject(err), // fail
attempts++
)
})
}
throw err
})
.then((mod) => {
resolvedComp = mod.default || mod
return resolvedComp
})
}
// ★ 真正返回的是这个包装组件
return defineComponent({
name: 'AsyncComponentWrapper',
setup() {
const loaded = ref(false)
const error = ref(null)
const delayed = ref(!!delay)
if (delay) setTimeout(() => delayed.value = false, delay)
load().then(() => loaded.value = true).catch(e => error.value = e)
if (timeout) setTimeout(() => {
if (!loaded.value && !error.value) error.value = new Error('Async component timed out')
}, timeout)
return () => {
if (loaded.value && resolvedComp) return h(resolvedComp)
if (error.value && errorComponent) return h(errorComponent, { error: error.value })
if (!delayed.value && loadingComponent) return h(loadingComponent)
}
}
})
}
关键洞察:
- 包装组件本身是同步的——只是它的 render 函数根据状态切换渲染目标
- 同一个 loader 多次调用只下载一次——因为 dynamic import 的 Promise 被浏览器/打包器缓存(webpack chunk 注册表 / Vite 模块图)
delay是给 loadingComponent 的延时,不是给 loader 的延时——加载快时跳过 loading- HMR 时:dev 模式下 Vue 会重置 resolvedComp,让 import.meta.hot 触发重新 load
Java 对照:类似 Spring 的 lazy proxy——拿到的是个代理对象,第一次方法调用时才真正初始化目标 Bean。Vue 的"代理"是组件包装,初始化触发器是 mount。
10. 原理深挖:Vite/webpack 怎么把 import() 切成独立 chunk
() => import('@/pages/AiChatPage.vue')
Vite 在 build 模式下用 Rollup,看到 import() 时:
- 依赖分析:从 AiChatPage.vue 出发,递归找到所有它导入的模块(包括 markdown-it 也会被它引用的 @vue/repl 等等)
- chunk 划分:
- 这些模块如果只被 AiChatPage chain 引用 → 进 AiChatPage chunk
- 被多个动态 chunk 共享的模块(比如 vue 本身) → 提取到 vendor chunk
- 生成产物:
AiChatPage-[hash].js+ 一个清单告诉运行时"AiChatPage chunk 依赖 vendor-vue chunk" - 运行时注入:
import()编译后变成__vitePreload(() => import('./AiChatPage-abc.js'), ['./vendor-vue-xyz.js'])——预加载所有依赖再加载入口
Vite 在 dev 模式下完全不打包——import() 走原生 ESM,浏览器原生支持,按需请求文件。所以 dev 模式下"懒加载"和"全量"几乎没区别(都是按需),但 build 后才能看到真实的 chunk 划分效果。
对应的 Network 行为:
点击进入 /ai 路由
↓
fetch('/assets/AiChatPage-abc.js')
+ fetch('/assets/vendor-monaco-xyz.js') ← 依赖的大块
+ fetch('/assets/AiChatPage.css') ← 样式也按 chunk 拆
↓
全部到达后执行 → 模块 default export → 真正的组件对象
↓
defineAsyncComponent 拿到组件 → loaded.value = true → 重新 render
Java 对照:类似 OSGi 的 bundle 按需加载——bundle 之间声明依赖,运行时按需解析装载。但 OSGi 是物理 jar 切分,Vite 是 chunk 切分。
11. 原理深挖:顶层 await 怎么和 Suspense 协作
<script setup>
const data = await fetchData()
</script>
<script setup> 编译器看到顶层 await,把 setup 编译成 async function:
// 编译后大致
{
async setup() {
const data = await fetchData()
return { data }
}
}
问题:组件 setup 现在返回 Promise,不是同步对象——Vue 渲染流程怎么处理?
Suspense 的工作方式:
// 简化版 Suspense 内部
function setupSuspense(suspenseVnode) {
const pendingPromises = [] // 收集子树里所有未完成的 Promise
// 临时改写 setupComponent,让 async setup 把 Promise 收上来
const originalSetup = setupComponent
setupComponent = (instance) => {
const result = originalSetup(instance)
if (result instanceof Promise) {
pendingPromises.push(result)
}
return result
}
// 先 render 一遍(fallback 状态)
renderFallback()
// 等所有 Promise 完成
Promise.all(pendingPromises).then(() => {
setupComponent = originalSetup
// 现在 async setup 已经 resolve,可以正常渲染真内容
renderActualContent()
})
}
实际比这复杂——Vue 用了一个叫 SuspenseBoundary 的状态机管理 pending / resolved / fallback / error 四种状态,但核心思想就是上面这套:截获子树的异步组件 setup Promise,全部完成后切换到真内容。
为什么子树里多个 await 能合并:因为 Promise.all 等待所有,子树有几十个 await 也只切换一次状态——避免"内容闪烁"。
为什么 onMounted 在 Suspense 下行为特殊:
- 普通组件 mount:先 mount → 触发 onMounted
- Suspense 子的异步组件:等 await 完成 → mount → onMounted(但中间 Suspense 已经 mount 完了)
所以Suspense 自己的 onMounted 比子组件的 onMounted 早——和正常父子顺序相反。这是因为 Suspense 先挂 fallback 启动,子异步组件后到。
12. 原理深挖:Suspense 的 4 个事件
<Suspense
@pending="onPending"
@resolve="onResolve"
@fallback="onFallback"
timeout="3000"
>
<AsyncChild />
<template #fallback><Spinner /></template>
</Suspense>
| 事件 | 触发时机 | 用途 |
|---|---|---|
pending |
进入"等待中"(首次或后续切换) | 显示进度条 |
resolve |
异步内容全部就绪 | 隐藏 loading、上报性能 |
fallback |
切换到 fallback 槽 | 切换路由时显示骨架 |
"后续切换"是什么意思——Suspense 不仅管首次加载。如果 default 槽里的异步组件重新 setup(比如 key 变了),又会进入 pending:
<Suspense>
<NoteDetail :slug="route.params.slug" :key="route.params.slug" />
<template #fallback><Skeleton /></template>
</Suspense>
切笔记 → key 变 → NoteDetail 重新 setup(顶层 await 重跑)→ Suspense 状态切到 pending → 触发 @pending 事件 → 显示骨架屏 → await 完成后 @resolve。
这就是 SPA 单页应用切页面骨架屏的标准实现。
13. 原理深挖:为什么不能在循环里 defineAsyncComponent
第 8 节说过这个误区,原理:
// ❌ 每次 computed 重算都创建新的 wrapper 组件
const items = computed(() =>
list.map(() => defineAsyncComponent(() => import('./Item.vue')))
)
每次 list 变化或重渲染,computed 重算 → 调用 defineAsyncComponent N 次 → 创建 N 个全新的包装组件对象。
Vue 渲染时:
diff 旧 vnode 树 vs 新 vnode 树
↓
旧 vnode.type = AsyncWrapper-001 (旧引用)
新 vnode.type = AsyncWrapper-002 (新对象)
↓
type 不同 → 当作不同组件 → 销毁旧的 + mount 新的
↓
新 wrapper 重新进入"开始 load → 显示 loading → ..."流程
结果:每次渲染 loading 都闪一下,体验糟糕,请求也多。
正确写法——在模块顶层创建一次:
const Item = defineAsyncComponent(() => import('./Item.vue')) // ★ 顶层一次
// 模板
<Item v-for="x in list" :key="x.id" :data="x" />
虽然 Item 是异步包装组件,但只创建一次——所有 v-for 实例共享同一个 wrapper,第一次任意实例触发 load 后,剩下的实例会拿到缓存的 resolvedComp 直接渲染。
判断准则:所有"返回组件对象"的工厂函数(defineAsyncComponent、markRaw(SomeComp)、shallowRef(SomeComp)),永远在模块顶层调用一次。
14. 原理深挖:动态组件 + 异步组件的组合
<component :is="currentComp" />
currentComp 是个组件对象(可能是异步组件 wrapper)。Vue 的 <component> 内置组件:
// 简化版
const ComponentResolver = {
setup(props, { slots }) {
return () => h(props.is, attrs, slots)
}
}
关键:is 可以是同步组件、异步组件、甚至字符串(标签名)。Vue 渲染时按 type 决定走哪条路径——异步组件会先显示 loading,加载完后再渲染。
实战场景——按需切换不同的"重型面板":
<script setup>
import { shallowRef, markRaw } from 'vue'
const tabs = {
ai: defineAsyncComponent(() => import('./AiPanel.vue')),
playground: defineAsyncComponent(() => import('./PlaygroundPanel.vue')),
notes: defineAsyncComponent(() => import('./NotesPanel.vue')),
}
const current = shallowRef(tabs.ai)
</script>
<template>
<Suspense>
<component :is="current" />
<template #fallback><Spinner /></template>
</Suspense>
<button @click="current = tabs.playground">切到 Playground</button>
</template>
切 Tab → component is 变 → 新异步组件触发 load → Suspense 显示 fallback → 加载完显示 Playground。
为什么用 shallowRef + markRaw:组件对象本身不需要响应式——ref(组件) 会把组件对象包成深响应式 Proxy,浪费且可能破坏 Vue 内部结构。shallowRef 只对 .value 替换敏感,markRaw 直接告诉 Vue "这玩意儿别响应式化"。
15. 原理深挖:Suspense 还在实验阶段意味着什么
第 4 节提到"Suspense 仍是实验性 API"。具体不稳定点:
- API 可能改——Vue 团队在讨论是否换成更接近 React Suspense 的 API(基于 throw Promise 而不是 async setup)
- 错误处理——目前错误必须用 onErrorCaptured 兜底,未来可能加
#error槽 - 嵌套 Suspense 的行为——多层 Suspense 时哪个先显示 fallback、resolve 顺序,文档不够清楚
- 服务端渲染(SSR)支持有限——SSR 等待异步内容时可能锁死整个页面
当前推荐策略:
| 场景 | 推荐 |
|---|---|
| 路由级懒加载 | Vue Router 内置就好,不需要手动 Suspense |
| 单页面单异步源 | v-if + onMounted async 简单稳妥 |
| 单页面多异步源(聚合) | Suspense(享受统一 loading) |
| SSR 关键路径 | 别用 Suspense,传统数据预取 |
| 想要"路由切换时旧页面留住直到新页面就绪" | Suspense(这是它的杀手锏场景) |
最后一条很重要——传统 router-view 切页面是"立即换",Suspense 包裹后变成"新页面准备好再换",避免"切到空白页"的感觉。这个体验提升非常明显,值得为它接受 API 不稳定的风险。
小练习
- 跑
pnpm build,记录dist/assets/里最大的 3 个 chunk 是什么(大概率是 Shiki 的语言高亮) - 在
src/router/index.ts里把某个路由改成同步导入(import X from '@/pages/X.vue'),再 build 一次,对比 chunk 大小变化 - 思考:如果以后把 AI 聊天的大模型 SDK 塞到
<AiChatPage>里,应该怎么打包?(答:路由懒加载 + 页面内部再用defineAsyncComponent拆出"历史记录"、"设置面板"等子组件)