Vue3 Suspense 深度解析:优雅处理异步依赖的完整指南

57 阅读6分钟

摘要

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 的核心价值

  1. 声明式异步处理:用声明式的方式处理异步依赖,代码更简洁
  2. 更好的用户体验:统一的加载状态处理,提升用户体验
  3. 代码组织优化:异步逻辑与组件逻辑分离,提高可维护性
  4. 错误处理统一:提供统一的错误处理机制

7.2 适用场景

  • 异步组件加载:动态导入大型组件
  • 数据获取:组件内部需要异步数据
  • 路由级别加载:整个页面的异步依赖
  • 条件渲染:根据条件动态加载组件
  • 用户体验优化:需要精细控制加载状态的场景

7.3 最佳实践总结

  1. 合理使用 fallback:提供有意义的加载状态
  2. 错误边界处理:使用 ErrorBoundary 捕获错误
  3. 性能优化:合理使用延迟加载和预加载
  4. 嵌套 Suspense:为不同部分的异步依赖提供独立的加载状态
  5. 事件处理:利用 Suspense 事件进行监控和调试

Suspense 是 Vue3 中处理异步操作的现代化解决方案,它让异步组件的使用变得更加简单和直观。通过合理使用 Suspense,可以显著提升应用的加载性能和用户体验。


如果这篇文章对你有帮助,欢迎点赞、收藏和评论!有任何问题都可以在评论区讨论。在这里插入图片描述