异步组件与 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>
小练习
- 跑
pnpm build,记录dist/assets/里最大的 3 个 chunk 是什么(大概率是 Shiki 的语言高亮) - 在
src/router/index.ts里把某个路由改成同步导入(import X from '@/pages/X.vue'),再 build 一次,对比 chunk 大小变化 - 思考:如果以后把 AI 聊天的大模型 SDK 塞到
<AiChatPage>里,应该怎么打包?(答:路由懒加载 + 页面内部再用defineAsyncComponent拆出"历史记录"、"设置面板"等子组件)