Skip to content

L15 · 异步组件与 Suspense ​

版本信息

  • 适用版本:本课程的 Vue 3.5.x
  • Suspense 状态:Experimental(API 可能变化)
  • 最后核对:2026-09-22
  • 官方文档:Suspense · 异步组件
🎯 本节目标:理解异步组件加载、Suspense 使用、以及优雅的 Loading / Error 状态管理
📦 本节产出:带骨架屏的异步页面加载 + 错误边界组件 + 性能优化(代码拆分)
🔗 前置钩子:L10 路由懒加载(已使用 `() => import()`)、L14 通信方式
🔗 后续钩子:L16 自定义指令 + 主题系统

1. 为什么需要异步组件 ​

1.1 打包体积问题 ​

同步导入大型组件会增加首屏需要加载的代码。动态导入能把暂时不用的部分拆开;具体文件划分和体积以构建结果为准:

1.2 路由懒加载回顾(L10 已做) ​

typescript
// 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。

typescript
import { defineAsyncComponent } from 'vue'

// 最简形式
const AsyncChart = defineAsyncComponent(
  () => import('./components/HeavyChart.vue')
)

在模板中使用:

vue
<template>
  <AsyncChart />  <!-- 组件所在 chunk 会在渲染时自动加载 -->
</template>

2.2 完整配置 ​

这里延迟加载 L12 的标签管理器。在使用它的页面中放入以下脚本,并在模板写 <AsyncTagManager />。

typescript
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()
    }
  },
})

补充错误展示组件:

vue
<!-- 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. 骨架屏组件 ​

页面布局已知时,可以用骨架屏预留内容位置;短操作也可以用文字或进度指示器:

vue
<!-- 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 里的请求不会自动被它追踪。

vue
<template>
  <Suspense>
    <template #default>
      <DashboardView />  <!-- setup() 中有 await -->
    </template>
    <template #fallback>
      <LoadingSkeleton :lines="5" has-avatar />
    </template>
  </Suspense>
</template>

4.2 async setup ​

先用本地延迟观察效果,不依赖尚未提供的后端接口。新建以下演示页面(无需添加路由):

vue
<!-- 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 手动实现:

vue
<!-- 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 组合使用 ​

vue
<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
嵌套 SuspenseVue 3.3+ 支持通过内层 suspensible 参与父边界的协调按加载边界划分,不按固定层数判断
无内置 Error Boundary由父组件的 onErrorCaptured 捕获相关错误明确失败提示和重试行为
再次进入等待状态已完成的边界只在 default 根节点被替换时重新等待深层新增依赖需要合适的内层边界
timeout边界重新等待后,多久把旧内容换成 fallback:timeout="0" 可立即显示 fallback;它不是请求超时
与 Transition / KeepAlive 配合嵌套顺序会影响行为按官方组合示例接入

#default 和 #fallback 各只允许一个直接子节点。需要多个元素时,先用容器包起来。普通请求仍可以使用 loading / error 状态手动管理,不必为了展示加载提示把整个页面改成异步 setup。


7. 实际应用:为任务管理添加异步统计视图 ​

vue
<!-- 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 /> 替换成下面的边界,同时补上脚本导入:

vue
<!-- 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 提交 ​

bash
git add .
git commit -m "L15: 异步组件 + Suspense + 骨架屏 + ErrorBoundary"

🔗 → 下一节 ​

L16 将学习自定义指令和主题系统——用 v-focus、v-permission 等自定义指令封装可复用的 DOM 操作,用 CSS 变量实现明暗主题切换。