摘要
Suspense 是 Vue3 中引入的一个革命性特性,它提供了一种声明式的方式来处理组件的异步依赖。本文将深入探讨 Suspense 的工作原理、使用场景、高级技巧,通过详细的代码示例、执行流程分析和最佳实践,帮助你彻底掌握这一现代化异步处理方案。
一、 什么是 Suspense?为什么需要它?
1.1 传统异步组件的痛点
在 Vue2 和 Vue3 早期版本中,处理异步组件通常需要这样:
<template>
<div>
<div v-if="loading" class="loading">
加载中...
</div>
<div v-else-if="error" class="error">
加载失败: {{ error.message }}
</div>
<div v-else>
<AsyncComponent />
</div>
</div>
</template>
<script>
export default {
data() {
return {
loading: false,
error: null
}
},
async mounted() {
this.loading = true
try {
await this.$store.dispatch('fetchData')
} catch (error) {
this.error = error
} finally {
this.loading = false
}
}
}
</script>
传统方式的问题:
- 模板冗余:每个异步组件都需要重复的 loading/error 逻辑
- 状态管理复杂:需要手动管理 loading、error 等状态
- 用户体验不一致:不同组件的加载状态处理方式不同
- 代码耦合:异步逻辑与组件逻辑紧密耦合
1.2 Suspense 的解决方案
Suspense 提供了一种声明式的异步处理方式:
<template>
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<div class="loading">加载中...</div>
</template>
</Suspense>
</template>
二、 Suspense 核心概念与基本用法
2.1 Suspense 的基本语法
<Suspense>
<!-- 默认插槽:包含异步依赖的组件 -->
<template #default>
<AsyncComponent />
</template>
<!-- 回退插槽:加载状态时显示 -->
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
2.2 Suspense 的工作原理
流程图:Suspense 完整工作流程
flowchart TD
A[Suspense组件渲染] --> B[开始渲染默认插槽]
B --> C[检测异步依赖]
C --> D{有未解决的<br>异步依赖?}
D -- 是 --> E[显示fallback内容]
D -- 否 --> F[直接显示默认内容]
E --> G[异步依赖解析完成]
G --> H[显示默认内容]
H --> I[触发resolved事件]
F --> J[触发resolved事件]
E --> K[异步依赖出错]
K --> L[显示错误边界<br>或向上冒泡]
2.3 基础示例:简单的异步组件
<template>
<div class="suspense-basic-demo">
<h2>Suspense 基础示例</h2>
<Suspense>
<template #default>
<AsyncUserProfile :user-id="userId" />
</template>
<template #fallback>
<div class="loading-state">
<div class="spinner"></div>
<p>用户信息加载中...</p>
</div>
</template>
</Suspense>
<div class="controls">
<button @click="changeUser" class="btn-primary">
切换用户 ({{ userId }})
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import AsyncUserProfile from './components/AsyncUserProfile.vue'
const userId = ref(1)
const changeUser = () => {
userId.value = userId.value === 1 ? 2 : 1
}
</script>
<style scoped>
.suspense-basic-demo {
padding: 20px;
max-width: 800px;
margin: 0 auto;
font-family: Arial, sans-serif;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
color: #666;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #42b883;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.controls {
margin-top: 20px;
text-align: center;
}
.btn-primary {
background: #42b883;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.btn-primary:hover {
background: #369870;
}
</style>
AsyncUserProfile.vue
<template>
<div class="user-profile">
<div class="profile-header">
<img :src="user.avatar" :alt="user.name" class="avatar" />
<div class="user-info">
<h3>{{ user.name }}</h3>
<p class="title">{{ user.title }}</p>
<p class="company">{{ user.company }}</p>
</div>
</div>
<div class="profile-stats">
<div class="stat">
<span class="stat-value">{{ user.stats.posts }}</span>
<span class="stat-label">文章</span>
</div>
<div class="stat">
<span class="stat-value">{{ user.stats.followers }}</span>
<span class="stat-label">粉丝</span>
</div>
<div class="stat">
<span class="stat-value">{{ user.stats.following }}</span>
<span class="stat-label">关注</span>
</div>
</div>
<div class="profile-bio">
<h4>个人简介</h4>
<p>{{ user.bio }}</p>
</div>
<div class="recent-activity">
<h4>最近活动</h4>
<ul>
<li v-for="activity in user.recentActivity" :key="activity.id">
{{ activity.action }} - {{ activity.time }}
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({
userId: {
type: Number,
required: true
}
})
const user = ref({
name: '',
title: '',
company: '',
avatar: '',
bio: '',
stats: { posts: 0, followers: 0, following: 0 },
recentActivity: []
})
// 模拟异步数据获取 - 这会触发 Suspense
const loadUserData = async () => {
console.log(`开始加载用户 ${props.userId} 的数据...`)
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 2000))
// 模拟 API 响应数据
const mockUsers = {
1: {
name: '张三',
title: '前端开发工程师',
company: '某科技公司',
avatar: 'https://via.placeholder.com/100x100/42b883/ffffff?text=ZS',
bio: '专注于 Vue.js 和现代前端技术,热爱开源项目,喜欢分享技术经验。',
stats: { posts: 42, followers: 128, following: 56 },
recentActivity: [
{ id: 1, action: '发布了新文章《Vue3 进阶指南》', time: '2小时前' },
{ id: 2, action: '关注了李四', time: '5小时前' },
{ id: 3, action: '点赞了《Composition API 实践》', time: '1天前' }
]
},
2: {
name: '李四',
title: '全栈开发工程师',
company: '某互联网公司',
avatar: 'https://via.placeholder.com/100x100/3498db/ffffff?text=LS',
bio: '全栈开发者,擅长 React、Node.js 和云原生技术,热衷于技术架构设计。',
stats: { posts: 28, followers: 89, following: 112 },
recentActivity: [
{ id: 1, action: '完成了项目部署', time: '1小时前' },
{ id: 2, action: '提交了代码更新', time: '3小时前' },
{ id: 3, action: '参加了技术分享会', time: '2天前' }
]
}
}
user.value = mockUsers[props.userId]
console.log(`用户 ${props.userId} 数据加载完成`)
}
// 在 setup 中使用 async - 这会告诉 Suspense 此组件有异步依赖
const userData = await loadUserData()
onMounted(() => {
console.log('AsyncUserProfile 组件已挂载')
})
</script>
<style scoped>
.user-profile {
max-width: 500px;
margin: 0 auto;
padding: 30px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
border: 1px solid #e0e0e0;
}
.profile-header {
display: flex;
align-items: center;
margin-bottom: 24px;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
margin-right: 20px;
border: 3px solid #42b883;
}
.user-info h3 {
margin: 0 0 8px 0;
font-size: 24px;
color: #2c3e50;
}
.title {
margin: 0 0 4px 0;
font-weight: 600;
color: #42b883;
}
.company {
margin: 0;
color: #7f8c8d;
font-size: 14px;
}
.profile-stats {
display: flex;
justify-content: space-around;
margin: 24px 0;
padding: 20px 0;
border-top: 1px solid #e0e0e0;
border-bottom: 1px solid #e0e0e0;
}
.stat {
text-align: center;
}
.stat-value {
display: block;
font-size: 24px;
font-weight: bold;
color: #2c3e50;
}
.stat-label {
font-size: 14px;
color: #7f8c8d;
margin-top: 4px;
}
.profile-bio {
margin-bottom: 24px;
}
.profile-bio h4 {
margin: 0 0 12px 0;
color: #2c3e50;
font-size: 18px;
}
.profile-bio p {
margin: 0;
line-height: 1.6;
color: #5a6c7d;
}
.recent-activity h4 {
margin: 0 0 12px 0;
color: #2c3e50;
font-size: 18px;
}
.recent-activity ul {
margin: 0;
padding: 0;
list-style: none;
}
.recent-activity li {
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
color: #5a6c7d;
font-size: 14px;
}
.recent-activity li:last-child {
border-bottom: none;
}
</style>
三、 Suspense 的高级用法
3.1 嵌套 Suspense
<template>
<div class="nested-suspense-demo">
<h2>嵌套 Suspense 示例</h2>
<Suspense>
<template #default>
<div class="dashboard">
<Suspense>
<template #default>
<UserHeader />
</template>
<template #fallback>
<div class="skeleton-header">
<div class="skeleton-avatar"></div>
<div class="skeleton-text">
<div class="skeleton-line short"></div>
<div class="skeleton-line medium"></div>
</div>
</div>
</template>
</Suspense>
<div class="dashboard-content">
<Suspense>
<template #default>
<RecentPosts />
</template>
<template #fallback>
<div class="skeleton-posts">
<div class="skeleton-card" v-for="n in 3" :key="n"></div>
</div>
</template>
</Suspense>
<Suspense>
<template #default>
<UserStats />
</template>
<template #fallback>
<div class="skeleton-stats">
<div class="skeleton-stat" v-for="n in 4" :key="n"></div>
</div>
</template>
</Suspense>
</div>
</div>
</template>
<template #fallback>
<div class="global-loading">
<div class="spinner large"></div>
<p>仪表板加载中...</p>
</div>
</template>
</Suspense>
</div>
</template>
<script setup>
import UserHeader from './components/UserHeader.vue'
import RecentPosts from './components/RecentPosts.vue'
import UserStats from './components/UserStats.vue'
</script>
<style scoped>
.nested-suspense-demo {
padding: 20px;
max-width: 1000px;
margin: 0 auto;
}
.dashboard {
background: #f8f9fa;
border-radius: 12px;
padding: 0;
}
.dashboard-content {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
padding: 24px;
}
/* 骨架屏样式 */
.skeleton-header {
display: flex;
align-items: center;
padding: 24px;
background: white;
border-radius: 12px 12px 0 0;
border-bottom: 1px solid #e9ecef;
}
.skeleton-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-right: 20px;
}
.skeleton-text {
flex: 1;
}
.skeleton-line {
height: 16px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-bottom: 8px;
border-radius: 4px;
}
.skeleton-line.short {
width: 60%;
}
.skeleton-line.medium {
width: 80%;
}
.skeleton-posts {
display: flex;
flex-direction: column;
gap: 16px;
}
.skeleton-card {
height: 120px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 8px;
}
.skeleton-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.skeleton-stat {
height: 80px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 8px;
}
.global-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: #666;
}
.spinner.large {
width: 60px;
height: 60px;
border: 6px solid #f3f3f3;
border-top: 6px solid #42b883;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
3.2 异步组件配合 Suspense
<template>
<div class="async-component-demo">
<h2>异步组件 + Suspense</h2>
<div class="controls">
<button
v-for="tab in tabs"
:key="tab.id"
@click="currentTab = tab.id"
:class="{ active: currentTab === tab.id }"
class="tab-btn"
>
{{ tab.name }}
</button>
</div>
<Suspense @pending="onPending" @resolve="onResolve" @fallback="onFallback">
<template #default>
<component :is="currentComponent" :key="currentTab" />
</template>
<template #fallback>
<div class="tab-loading">
<div class="spinner"></div>
<p>加载 {{ currentTabName }} 中...</p>
</div>
</template>
</Suspense>
<div class="events-log">
<h3>Suspense 事件日志</h3>
<div v-for="(event, index) in events" :key="index" class="event-item">
<span class="event-time">{{ event.time }}</span>
<span class="event-type" :class="event.type">{{ event.type }}</span>
<span class="event-details">{{ event.details }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, defineAsyncComponent } from 'vue'
const currentTab = ref('dashboard')
const events = ref([])
// 定义异步组件
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./components/AsyncDashboard.vue'),
loadingComponent: {
template: '<div>自定义加载组件...</div>'
},
delay: 200,
timeout: 5000
})
const AsyncAnalytics = defineAsyncComponent(() =>
new Promise(resolve => {
setTimeout(() => {
resolve(import('./components/AsyncAnalytics.vue'))
}, 1500)
})
)
const AsyncSettings = defineAsyncComponent(() =>
import('./components/AsyncSettings.vue')
)
const tabs = [
{ id: 'dashboard', name: '仪表板', component: AsyncDashboard },
{ id: 'analytics', name: '分析', component: AsyncAnalytics },
{ id: 'settings', name: '设置', component: AsyncSettings }
]
const currentComponent = computed(() => {
return tabs.find(tab => tab.id === currentTab.value)?.component
})
const currentTabName = computed(() => {
return tabs.find(tab => tab.id === currentTab.value)?.name
})
// Suspense 事件处理
const addEvent = (type, details = '') => {
const time = new Date().toLocaleTimeString()
events.value.unshift({ time, type, details })
if (events.value.length > 10) {
events.value.pop()
}
}
const onPending = () => {
addEvent('pending', '开始等待异步依赖')
}
const onResolve = () => {
addEvent('resolve', `组件 ${currentTabName.value} 加载完成`)
}
const onFallback = () => {
addEvent('fallback', '显示回退内容')
}
</script>
<style scoped>
.async-component-demo {
padding: 20px;
max-width: 1000px;
margin: 0 auto;
}
.controls {
display: flex;
gap: 10px;
margin-bottom: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.tab-btn {
padding: 12px 24px;
background: white;
border: 2px solid #e9ecef;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s;
}
.tab-btn:hover {
border-color: #42b883;
color: #42b883;
}
.tab-btn.active {
background: #42b883;
color: white;
border-color: #42b883;
}
.tab-loading {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
background: white;
border-radius: 8px;
border: 2px dashed #e9ecef;
color: #666;
}
.events-log {
margin-top: 30px;
padding: 20px;
background: #2c3e50;
border-radius: 8px;
color: white;
}
.events-log h3 {
margin: 0 0 15px 0;
color: #42b883;
}
.event-item {
display: flex;
align-items: center;
gap: 15px;
padding: 10px;
margin: 5px 0;
background: #34495e;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
}
.event-time {
color: #bdc3c7;
min-width: 80px;
}
.event-type {
padding: 2px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: bold;
text-transform: uppercase;
}
.event-type.pending {
background: #f39c12;
color: white;
}
.event-type.resolve {
background: #27ae60;
color: white;
}
.event-type.fallback {
background: #3498db;
color: white;
}
.event-details {
color: #ecf0f1;
flex: 1;
}
</style>
四、 实际项目中的应用场景
4.1 数据获取与 Suspense
<template>
<div class="data-fetching-demo">
<h2>数据获取 + Suspense</h2>
<Suspense>
<template #default>
<ProductDetail :product-id="productId" />
</template>
<template #fallback>
<ProductDetailSkeleton />
</template>
</Suspense>
<div class="product-navigation">
<button
@click="productId--"
:disabled="productId <= 1"
class="nav-btn"
>
上一个产品
</button>
<span class="product-counter">产品 #{{ productId }}</span>
<button
@click="productId++"
class="nav-btn"
>
下一个产品
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ProductDetail from './components/ProductDetail.vue'
import ProductDetailSkeleton from './components/ProductDetailSkeleton.vue'
const productId = ref(1)
</script>
<style scoped>
.data-fetching-demo {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.product-navigation {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.nav-btn {
padding: 10px 20px;
background: #42b883;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.nav-btn:disabled {
background: #bdc3c7;
cursor: not-allowed;
}
.nav-btn:hover:not(:disabled) {
background: #369870;
}
.product-counter {
font-weight: bold;
color: #2c3e50;
}
</style>
ProductDetail.vue
<template>
<div class="product-detail">
<div class="product-header">
<img :src="product.image" :alt="product.name" class="product-image" />
<div class="product-info">
<h1>{{ product.name }}</h1>
<p class="product-category">{{ product.category }}</p>
<div class="product-price">¥{{ product.price }}</div>
<div class="product-rating">
<span class="stars">★★★★★</span>
<span class="rating-value">{{ product.rating }}/5.0</span>
<span class="review-count">({{ product.reviewCount }} 条评价)</span>
</div>
</div>
</div>
<div class="product-description">
<h3>产品描述</h3>
<p>{{ product.description }}</p>
</div>
<div class="product-specs">
<h3>规格参数</h3>
<div class="specs-grid">
<div v-for="spec in product.specifications" :key="spec.name" class="spec-item">
<span class="spec-name">{{ spec.name }}</span>
<span class="spec-value">{{ spec.value }}</span>
</div>
</div>
</div>
<div class="customer-reviews">
<h3>用户评价</h3>
<div v-for="review in product.reviews" :key="review.id" class="review">
<div class="review-header">
<span class="reviewer">{{ review.reviewer }}</span>
<span class="review-rating">★★★★★</span>
<span class="review-date">{{ review.date }}</span>
</div>
<p class="review-content">{{ review.content }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
productId: {
type: Number,
required: true
}
})
const product = ref({})
// 模拟异步数据获取
const fetchProductData = async () => {
console.log(`获取产品 ${props.productId} 的数据...`)
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 1500))
// 模拟 API 响应
const mockProducts = {
1: {
id: 1,
name: '高端无线耳机',
category: '电子产品',
price: 1299,
rating: 4.5,
reviewCount: 128,
image: 'https://via.placeholder.com/400x300/3498db/ffffff?text=Headphones',
description: '这款高端无线耳机采用最新的蓝牙技术,提供卓越的音质和舒适的佩戴体验。主动降噪功能让您在嘈杂环境中也能享受纯净的音乐。',
specifications: [
{ name: '蓝牙版本', value: '5.2' },
{ name: '电池续航', value: '30小时' },
{ name: '充电时间', value: '2小时' },
{ name: '重量', value: '250g' },
{ name: '防水等级', value: 'IPX4' }
],
reviews: [
{
id: 1,
reviewer: '音乐爱好者',
rating: 5,
date: '2024-01-15',
content: '音质非常棒,降噪效果出色,佩戴舒适度也很好!'
},
{
id: 2,
reviewer: '科技达人',
rating: 4,
date: '2024-01-10',
content: '性价比很高,电池续航能力很强,推荐购买。'
}
]
},
2: {
id: 2,
name: '智能手表 Pro',
category: '智能穿戴',
price: 899,
rating: 4.8,
reviewCount: 89,
image: 'https://via.placeholder.com/400x300/e74c3c/ffffff?text=Smartwatch',
description: '功能全面的智能手表,支持健康监测、运动追踪、消息通知等多种功能。精致的设计适合各种场合佩戴。',
specifications: [
{ name: '屏幕', value: '1.5英寸 AMOLED' },
{ name: '电池续航', value: '7天' },
{ name: '防水等级', value: '5ATM' },
{ name: '运动模式', value: '100+' },
{ name: '连接方式', value: '蓝牙 5.0' }
],
reviews: [
{
id: 1,
reviewer: '运动爱好者',
rating: 5,
date: '2024-01-12',
content: '运动监测非常准确,电池续航也很满意!'
}
]
}
}
product.value = mockProducts[props.productId] || mockProducts[1]
}
// 使用 async setup 触发 Suspense
await fetchProductData()
</script>
<style scoped>
.product-detail {
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.product-header {
display: flex;
padding: 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.product-image {
width: 300px;
height: 225px;
border-radius: 8px;
margin-right: 30px;
object-fit: cover;
}
.product-info h1 {
margin: 0 0 10px 0;
font-size: 32px;
}
.product-category {
margin: 0 0 15px 0;
opacity: 0.9;
font-size: 16px;
}
.product-price {
font-size: 28px;
font-weight: bold;
margin-bottom: 15px;
}
.product-rating {
display: flex;
align-items: center;
gap: 10px;
}
.stars {
color: #ffd700;
font-size: 18px;
}
.rating-value {
font-weight: bold;
}
.review-count {
opacity: 0.8;
}
.product-description {
padding: 30px;
border-bottom: 1px solid #e9ecef;
}
.product-description h3 {
margin: 0 0 15px 0;
color: #2c3e50;
font-size: 20px;
}
.product-description p {
margin: 0;
line-height: 1.6;
color: #5a6c7d;
}
.product-specs {
padding: 30px;
border-bottom: 1px solid #e9ecef;
}
.product-specs h3 {
margin: 0 0 20px 0;
color: #2c3e50;
font-size: 20px;
}
.specs-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.spec-item {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.spec-name {
color: #7f8c8d;
}
.spec-value {
font-weight: 600;
color: #2c3e50;
}
.customer-reviews {
padding: 30px;
}
.customer-reviews h3 {
margin: 0 0 20px 0;
color: #2c3e50;
font-size: 20px;
}
.review {
padding: 20px 0;
border-bottom: 1px solid #f0f0f0;
}
.review:last-child {
border-bottom: none;
}
.review-header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 10px;
}
.reviewer {
font-weight: 600;
color: #2c3e50;
}
.review-rating {
color: #ffd700;
}
.review-date {
color: #7f8c8d;
font-size: 14px;
}
.review-content {
margin: 0;
line-height: 1.5;
color: #5a6c7d;
}
</style>
五、 错误处理与边界情况
5.1 错误边界处理
<template>
<div class="error-handling-demo">
<h2>Suspense 错误处理</h2>
<div class="controls">
<button @click="simulateSuccess" class="btn-success">
模拟成功加载
</button>
<button @click="simulateError" class="btn-error">
模拟加载错误
</button>
<button @click="simulateTimeout" class="btn-warning">
模拟超时
</button>
</div>
<ErrorBoundary>
<template #default>
<Suspense>
<template #default>
<UnstableComponent :mode="loadMode" />
</template>
<template #fallback>
<div class="loading-state">
<div class="spinner"></div>
<p>组件加载中...</p>
</div>
</template>
</Suspense>
</template>
<template #fallback="{ error, reset }">
<div class="error-state">
<div class="error-icon">❌</div>
<h3>组件加载失败</h3>
<p class="error-message">{{ error.message }}</p>
<button @click="reset" class="btn-primary">
重试加载
</button>
</div>
</template>
</ErrorBoundary>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ErrorBoundary from './components/ErrorBoundary.vue'
import UnstableComponent from './components/UnstableComponent.vue'
const loadMode = ref('success')
const simulateSuccess = () => {
loadMode.value = 'success'
}
const simulateError = () => {
loadMode.value = 'error'
}
const simulateTimeout = () => {
loadMode.value = 'timeout'
}
</script>
<style scoped>
.error-handling-demo {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.btn-success { background: #27ae60; }
.btn-error { background: #e74c3c; }
.btn-warning { background: #f39c12; }
.btn-success, .btn-error, .btn-warning {
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-success:hover { background: #229954; }
.btn-error:hover { background: #c0392b; }
.btn-warning:hover { background: #e67e22; }
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
background: white;
border-radius: 8px;
border: 2px dashed #e9ecef;
color: #666;
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
background: #fff5f5;
border: 2px solid #fed7d7;
border-radius: 8px;
text-align: center;
}
.error-icon {
font-size: 48px;
margin-bottom: 20px;
}
.error-state h3 {
margin: 0 0 15px 0;
color: #e53e3e;
}
.error-message {
color: #718096;
margin-bottom: 20px;
max-width: 400px;
line-height: 1.5;
}
</style>
ErrorBoundary.vue
<template>
<slot v-if="error" name="fallback" :error="error" :reset="resetError" />
<slot v-else />
</template>
<script setup>
import { ref, onErrorCaptured } from 'vue'
const error = ref(null)
const resetError = () => {
error.value = null
}
onErrorCaptured((err, instance, info) => {
console.error('ErrorBoundary 捕获到错误:', err)
console.log('错误信息:', info)
error.value = err
// 返回 false 阻止错误继续向上传播
return false
})
</script>
UnstableComponent.vue
<template>
<div class="unstable-component">
<h3>不稳定的组件</h3>
<p>当前模式: <strong>{{ props.mode }}</strong></p>
<div v-if="data" class="component-content">
<p>数据加载成功: {{ data.message }}</p>
<p>加载时间: {{ data.timestamp }}</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
mode: {
type: String,
default: 'success'
}
})
const data = ref(null)
// 模拟不稳定的数据加载
const loadData = async () => {
console.log(`加载模式: ${props.mode}`)
await new Promise(resolve => setTimeout(resolve, 1000))
switch (props.mode) {
case 'success':
data.value = {
message: '数据加载成功!',
timestamp: new Date().toLocaleTimeString()
}
break
case 'error':
throw new Error('模拟的加载错误:API 服务器无响应')
case 'timeout':
await new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), 5000)
)
break
default:
data.value = { message: '未知模式', timestamp: 'N/A' }
}
}
// 触发 Suspense
await loadData()
</script>
<style scoped>
.unstable-component {
padding: 30px;
background: white;
border-radius: 8px;
border: 2px solid #e2e8f0;
text-align: center;
}
.unstable-component h3 {
margin: 0 0 15px 0;
color: #2c3e50;
}
.component-content {
margin-top: 20px;
padding: 20px;
background: #f0fff4;
border: 1px solid #9ae6b4;
border-radius: 6px;
}
.component-content p {
margin: 8px 0;
color: #276749;
}
</style>
六、 Suspense 最佳实践与性能优化
6.1 性能优化技巧
<template>
<div class="performance-demo">
<h2>Suspense 性能优化</h2>
<div class="optimization-tips">
<div class="tip">
<h3>💡 提示 1: 合理使用延迟加载</h3>
<Suspense>
<template #default>
<LazyHeavyComponent />
</template>
<template #fallback>
<div class="skeleton-heavy"></div>
</template>
</Suspense>
</div>
<div class="tip">
<h3>💡 提示 2: 预加载关键组件</h3>
<button @click="preloadComponents" class="btn-secondary">
预加载所有组件
</button>
<div class="preload-status">
<span v-for="(status, name) in preloadStatus" :key="name"
class="status-item" :class="status">
{{ name }}: {{ status }}
</span>
</div>
</div>
<div class="tip">
<h3>💡 提示 3: 使用适当的加载状态</h3>
<Suspense>
<template #default>
<CriticalComponent />
</template>
<template #fallback>
<ProgressiveLoading />
</template>
</Suspense>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, defineAsyncComponent } from 'vue'
// 延迟加载重型组件
const LazyHeavyComponent = defineAsyncComponent({
loader: () => import('./components/HeavyComponent.vue'),
delay: 200,
timeout: 10000
})
const CriticalComponent = defineAsyncComponent(() =>
import('./components/CriticalComponent.vue')
)
// 预加载状态
const preloadStatus = reactive({
'组件A': '未加载',
'组件B': '未加载',
'组件C': '未加载'
})
const preloadComponents = async () => {
const components = {
'组件A': import('./components/ComponentA.vue'),
'组件B': import('./components/ComponentB.vue'),
'组件C': import('./components/ComponentC.vue')
}
for (const [name, promise] of Object.entries(components)) {
try {
preloadStatus[name] = '加载中...'
await promise
preloadStatus[name] = '已加载'
} catch (error) {
preloadStatus[name] = '加载失败'
}
}
}
</script>
<style scoped>
.performance-demo {
padding: 20px;
max-width: 1000px;
margin: 0 auto;
}
.optimization-tips {
display: flex;
flex-direction: column;
gap: 30px;
}
.tip {
padding: 25px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #42b883;
}
.tip h3 {
margin: 0 0 15px 0;
color: #2c3e50;
}
.skeleton-heavy {
height: 200px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 8px;
}
.preload-status {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 15px;
}
.status-item {
padding: 8px 12px;
background: white;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
}
.status-item.已加载 {
background: #d4edda;
color: #155724;
}
.status-item.加载中 {
background: #fff3cd;
color: #856404;
}
.status-item.加载失败 {
background: #f8d7da;
color: #721c24;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>
七、 总结
7.1 Suspense 的核心价值
- 声明式异步处理:用声明式的方式处理异步依赖,代码更简洁
- 更好的用户体验:统一的加载状态处理,提升用户体验
- 代码组织优化:异步逻辑与组件逻辑分离,提高可维护性
- 错误处理统一:提供统一的错误处理机制
7.2 适用场景
- 异步组件加载:动态导入大型组件
- 数据获取:组件内部需要异步数据
- 路由级别加载:整个页面的异步依赖
- 条件渲染:根据条件动态加载组件
- 用户体验优化:需要精细控制加载状态的场景
7.3 最佳实践总结
- 合理使用 fallback:提供有意义的加载状态
- 错误边界处理:使用 ErrorBoundary 捕获错误
- 性能优化:合理使用延迟加载和预加载
- 嵌套 Suspense:为不同部分的异步依赖提供独立的加载状态
- 事件处理:利用 Suspense 事件进行监控和调试
Suspense 是 Vue3 中处理异步操作的现代化解决方案,它让异步组件的使用变得更加简单和直观。通过合理使用 Suspense,可以显著提升应用的加载性能和用户体验。
如果这篇文章对你有帮助,欢迎点赞、收藏和评论!有任何问题都可以在评论区讨论。