L15 · 异步组件与 Suspense
版本信息
🎯 本节目标:理解异步组件加载、Suspense 使用、以及优雅的 Loading / Error 状态管理
📦 本节产出:带骨架屏的异步页面加载 + 错误边界组件 + 性能优化(代码拆分)
🔗 前置钩子:L10 路由懒加载(已使用 `() => import()`)、L14 通信方式
🔗 后续钩子:L16 自定义指令 + 主题系统1. 为什么需要异步组件
1.1 打包体积问题
同步导入大型组件会增加首屏需要加载的代码。动态导入能把暂时不用的部分拆开;具体文件划分和体积以构建结果为准:
1.2 路由懒加载回顾(L10 已做)
// router/index.ts
const StatsView = () => import('@/views/StatsView.vue') // 访问 /stats 时才加载
const KanbanView = () => import('@/views/KanbanView.vue') // 访问 /kanban 时才加载两者都能通过动态导入拆分代码,但 Vue Router 的路由加载函数与 defineAsyncComponent 是不同机制。路由导入本身不会触发 <Suspense>;本节再看组件级别的加载和异步 setup。
2. defineAsyncComponent
2.1 基础用法
以下以已有的 HeavyChart.vue 为例说明 API;任务项目暂未创建图表组件,实际接入见 §2.2 和 §7。
import { defineAsyncComponent } from 'vue'
// 最简形式
const AsyncChart = defineAsyncComponent(
() => import('./components/HeavyChart.vue')
)在模板中使用:
<template>
<AsyncChart /> <!-- 组件所在 chunk 会在渲染时自动加载 -->
</template>2.2 完整配置
这里延迟加载 L12 的标签管理器。在使用它的页面中放入以下脚本,并在模板写 <AsyncTagManager />。
import { defineAsyncComponent } from 'vue'
import LoadingSkeleton from '@/components/ui/LoadingSkeleton.vue'
import ErrorDisplay from '@/components/ui/ErrorDisplay.vue'
const AsyncTagManager = defineAsyncComponent({
// 加载器函数
loader: () => import('@/components/todo/TagManager.vue'),
// 加载中显示的组件
loadingComponent: LoadingSkeleton,
// 加载失败显示的组件
errorComponent: ErrorDisplay,
// 延迟显示 loading(ms)
// 如果在 200ms 内加载完成,就不会显示 loading(避免闪烁)
delay: 200,
// 超时时间(ms)
// 超过此时间显示 errorComponent;不会取消正在进行的导入
timeout: 10000,
// 本例由组件自己管理加载状态,不交给外层 Suspense
suspensible: false,
// 自定义错误处理
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
// 演示有限次重试;实际项目可按错误类型决定是否重试
console.warn('组件加载失败,准备重试', error)
setTimeout(retry, 1000)
} else {
fail()
}
},
})补充错误展示组件:
<!-- src/components/ui/ErrorDisplay.vue -->
<script setup lang="ts">
defineProps<{ error?: Error }>()
</script>
<template>
<p role="alert">组件加载失败:{{ error?.message || '请稍后刷新页面' }}</p>
</template>若改用默认的 suspensible: true 并置于 <Suspense> 下,边界会接管等待状态,上面的 loadingComponent、errorComponent、delay 和 timeout 将不生效。不要同时依赖两套加载界面。
2.3 时序图:delay 的作用
关键设计: delay 避免了快速网络下的 loading 闪烁——如果组件在 200ms 内就加载好了,用户根本看不到 loading 状态。
3. 骨架屏组件
页面布局已知时,可以用骨架屏预留内容位置;短操作也可以用文字或进度指示器:
<!-- src/components/ui/LoadingSkeleton.vue -->
<script setup lang="ts">
withDefaults(defineProps<{
lines?: number
hasAvatar?: boolean
hasImage?: boolean
}>(), {
lines: 3,
hasAvatar: false,
hasImage: false,
})
</script>
<template>
<div class="skeleton" aria-busy="true" aria-label="加载中">
<!-- 头部:头像 + 标题 -->
<div v-if="hasAvatar" class="skeleton-header">
<div class="skeleton-avatar pulse"></div>
<div class="skeleton-title-group">
<div class="skeleton-line skeleton-title pulse"></div>
<div class="skeleton-line skeleton-subtitle pulse"></div>
</div>
</div>
<!-- 图片占位 -->
<div v-if="hasImage" class="skeleton-image pulse"></div>
<!-- 文本行 -->
<div class="skeleton-body">
<div
v-for="i in lines"
:key="i"
class="skeleton-line pulse"
:style="{ width: i === lines ? '60%' : '100%' }"
></div>
</div>
</div>
</template>
<style scoped>
.skeleton {
padding: 20px;
}
.skeleton-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.skeleton-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: #e0e0e0;
flex-shrink: 0;
}
.skeleton-title-group {
flex: 1;
}
.skeleton-title {
height: 16px;
width: 40%;
margin-bottom: 8px;
}
.skeleton-subtitle {
height: 12px;
width: 25%;
}
.skeleton-image {
width: 100%;
height: 200px;
border-radius: 8px;
background: #e0e0e0;
margin-bottom: 16px;
}
.skeleton-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.skeleton-line {
height: 14px;
background: #e0e0e0;
border-radius: 6px;
}
/* 脉冲动画 */
.pulse {
animation: pulse 1.5s infinite ease-in-out;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@media (prefers-reduced-motion: reduce) {
.pulse { animation: none; }
}
</style>4. Suspense
4.1 什么是 Suspense
<Suspense> 能等待异步 setup()(包括 <script setup> 顶层 await)和允许挂起的异步组件。首次渲染遇到这些依赖时,它显示 fallback,待依赖完成再显示内容。普通事件处理函数或 onMounted 里的请求不会自动被它追踪。
<template>
<Suspense>
<template #default>
<DashboardView /> <!-- setup() 中有 await -->
</template>
<template #fallback>
<LoadingSkeleton :lines="5" has-avatar />
</template>
</Suspense>
</template>4.2 async setup
先用本地延迟观察效果,不依赖尚未提供的后端接口。新建以下演示页面(无需添加路由):
<!-- src/views/DashboardView.vue — 这是一个 async setup 组件 -->
<script setup lang="ts">
// ⚠️ 顶层 await 让整个 setup 变成异步
// Suspense 会等待所有 await 完成后再渲染
await new Promise<void>(resolve => setTimeout(resolve, 500))
const user = { name: '学习者' }
const stats = { totalTasks: 8, completedTasks: 3 }
// 此时 Suspense 的 fallback 关闭,显示 default 内容
</script>
<template>
<div class="dashboard">
<h1>欢迎回来,{{ user.name }}</h1>
<div class="stats-grid">
<div class="stat-card">
<span>{{ stats.totalTasks }}</span>
<label>总任务</label>
</div>
<div class="stat-card">
<span>{{ stats.completedTasks }}</span>
<label>已完成</label>
</div>
</div>
</div>
</template>4.3 Suspense + 错误处理
Suspense 没有内置的错误处理。我们需要用 onErrorCaptured 手动实现:
<!-- src/components/ui/ErrorBoundary.vue — 可复用的错误边界组件 -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
const error = ref<Error | null>(null)
onErrorCaptured((err) => {
error.value = err instanceof Error ? err : new Error(String(err))
return false // 阻止继续传给祖先的错误钩子和全局处理器
})
function retry() {
error.value = null // 重置错误 → 重新渲染子组件
}
</script>
<template>
<div v-if="error" class="error-boundary">
<div class="error-icon">❌</div>
<h3>加载出错了</h3>
<p class="error-message">{{ error.message }}</p>
<button @click="retry" class="retry-btn">🔄 重试</button>
</div>
<slot v-else />
</template>
<style scoped>
.error-boundary {
text-align: center;
padding: 40px;
border: 1px solid #fee;
border-radius: 12px;
background: #fff5f5;
}
.error-icon {
font-size: 2rem;
margin-bottom: 12px;
}
.error-message {
color: #666;
font-size: 0.9rem;
margin: 8px 0 16px;
}
.retry-btn {
padding: 8px 24px;
border: none;
border-radius: 8px;
background: #42b883;
color: white;
cursor: pointer;
}
</style>此处重试通过重新挂载子树重新执行 setup。它不会自动取消旧请求,也不是所有网络错误的统一重试器;异步组件加载器的重试可使用 §2.2 的 onError。
4.4 组合使用
<script setup lang="ts">
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import LoadingSkeleton from '@/components/ui/LoadingSkeleton.vue'
import DashboardView from '@/views/DashboardView.vue'
</script>
<template>
<ErrorBoundary>
<Suspense>
<template #default>
<DashboardView />
</template>
<template #fallback>
<LoadingSkeleton :lines="5" has-avatar />
</template>
</Suspense>
</ErrorBoundary>
</template>5. defineAsyncComponent vs Suspense 选型
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 路由级代码拆分 | 路由懒加载 () => import() | 最简洁 |
| 重型组件(图表、编辑器) | defineAsyncComponent + loading/error 组件 | 独立管理组件代码的加载状态 |
| 需要协调多个异步依赖 | <Suspense> | 协调当前边界识别的异步依赖 |
需要 async setup 获取数据 | <Suspense> + ErrorBoundary | 声明式数据获取 |
| 简单的条件加载 | v-if + defineAsyncComponent | 首次渲染时导入组件 |
6. Suspense 的限制和注意事项
⚠️ Suspense 在 Vue 3 中仍标记为 Experimental(实验性)
| 限制 | 详细说明 | 建议 |
|---|---|---|
| API 可能变化 | 未来 Vue 版本可能调整行为 | 关注 RFC 和 changelog |
| 嵌套 Suspense | Vue 3.3+ 支持通过内层 suspensible 参与父边界的协调 | 按加载边界划分,不按固定层数判断 |
| 无内置 Error Boundary | 由父组件的 onErrorCaptured 捕获相关错误 | 明确失败提示和重试行为 |
| 再次进入等待状态 | 已完成的边界只在 default 根节点被替换时重新等待 | 深层新增依赖需要合适的内层边界 |
timeout | 边界重新等待后,多久把旧内容换成 fallback | :timeout="0" 可立即显示 fallback;它不是请求超时 |
| 与 Transition / KeepAlive 配合 | 嵌套顺序会影响行为 | 按官方组合示例接入 |
#default 和 #fallback 各只允许一个直接子节点。需要多个元素时,先用容器包起来。普通请求仍可以使用 loading / error 状态手动管理,不必为了展示加载提示把整个页面改成异步 setup。
7. 实际应用:为任务管理添加异步统计视图
<!-- src/views/StatsView.vue -->
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useTaskStore } from '@/stores/taskStore'
const { stats } = storeToRefs(useTaskStore())
// 仅用于观察 Suspense;真实统计仍来自已有的本地任务 Store
await new Promise<void>(resolve => setTimeout(resolve, 500))
</script>
<template>
<div class="stats-page">
<h1>📊 统计概览</h1>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">总任务</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.doneCount }}</div>
<div class="stat-label">已完成</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.donePercent }}%</div>
<div class="stat-label">完成率</div>
</div>
</div>
</div>
</template>这里只模拟 500ms 等待,尚未调用后端。观察完可以移除这行延迟,恢复同步统计页面。若以后改为真实 fetch,还需检查 response.ok、校验响应数据并处理请求取消。
保留 App.vue 原有导航和样式,把其 <RouterView /> 替换成下面的边界,同时补上脚本导入:
<!-- App.vue:路由出口部分 -->
<script setup lang="ts">
import { RouterView } from 'vue-router'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import LoadingSkeleton from '@/components/ui/LoadingSkeleton.vue'
</script>
<template>
<ErrorBoundary>
<RouterView v-slot="{ Component }">
<Suspense v-if="Component" :timeout="0">
<template #default>
<component :is="Component" />
</template>
<template #fallback>
<LoadingSkeleton :lines="4" />
</template>
</Suspense>
</RouterView>
</ErrorBoundary>
</template>路由模块本身仍由 Vue Router 加载;这里的骨架屏展示的是 StatsView 异步 setup 的等待。若只切换同一页面的路由参数而复用组件实例,不能据此期待 setup 或 fallback 再运行一次。
8. 本节总结
🔬 深度专题
📖 D10 · Suspense 现状与限制 — 为什么 Suspense 仍是实验性功能?
检查清单
- [ ] 理解异步组件解决的打包体积问题
- [ ] 能用
defineAsyncComponent创建异步组件 - [ ] 能配置
loadingComponent/errorComponent/delay/timeout/onError - [ ] 理解
delay防止 loading 闪烁的设计 - [ ] 能实现骨架屏组件
- [ ] 能用
<Suspense>处理 async setup 组件 - [ ] 能封装 ErrorBoundary 组件处理错误
- [ ] 知道 Suspense 的实验性状态和限制
- [ ] 能根据场景选择 defineAsyncComponent vs Suspense
Git 提交
git add .
git commit -m "L15: 异步组件 + Suspense + 骨架屏 + ErrorBoundary"🔗 → 下一节
L16 将学习自定义指令和主题系统——用 v-focus、v-permission 等自定义指令封装可复用的 DOM 操作,用 CSS 变量实现明暗主题切换。