Skip to content

D10 · Suspense 的局限性 ​

对应主课: L15 异步组件 + Suspense 适用版本: Vue 3.5;实现行为以 3.5.43 为核对基线 当前状态: Experimental(实验性功能) 最后核对: 2026-09-23


1. Suspense 是什么 ​

Suspense 协调组件树中的异步依赖。它能等待两类依赖:异步 setup()(包括 <script setup> 的顶层 await),以及默认允许 Suspense 接管的 defineAsyncComponent 组件。普通点击事件中的 Promise 不会自动成为依赖。官方说明

下面是结构示意,AsyncDashboard 需要具有上述异步依赖;每个插槽都只能有一个直接根节点:

vue
<Suspense>
  <template #default><AsyncDashboard /></template>
  <template #fallback><p>仪表盘加载中…</p></template>
</Suspense>

若异步组件由 Suspense 接管,它自身的 loadingComponent、errorComponent、delay 和 timeout 加载选项会被忽略;需要组件独立处理时,使用 suspensible: false。这和嵌套 Suspense 上的 suspensible prop 是不同对象上的选项。异步组件


2. 当前局限性 ​

2.1 仍是实验性功能 ​

Vue 3.5 的官方文档仍标记 Suspense 为 experimental,不能把它写成已经稳定的 API。开发环境会输出实验性提示;这条提示本身不是组件报错。

2.2 没有内置错误插槽 ​

#fallback 表示等待中,不承担失败 UI。Suspense 没有 #error 插槽;应在它的父组件用 onErrorCaptured 接住子树错误,并切换到错误界面。错误值应先规范化,不能假定任何抛出的值都是 Error;捕获后返回 false 会停止该错误向祖先和全局处理器传播。第 4 节给出完整示例。onErrorCaptured

2.3 嵌套边界需要明确谁负责等待 ​

下面仍是结构示意,内层边界用于处理内部组件替换:

vue
<Suspense>
  <AsyncLayout>
    <Suspense suspensible>
      <AsyncContent :key="contentId" />
    </Suspense>
  </AsyncLayout>
  <template #fallback><p>页面加载中…</p></template>
</Suspense>

AsyncLayout 必须渲染它的默认插槽。内层不设 suspensible 时会独立处理加载;设置后,父边界能将它纳入异步依赖协调。两者不是“嵌套越多越好”的关系:先决定布局与内容应同时出现,还是允许内容区域独立加载,再划分边界。

2.4 并非只处理首次加载 ​

首次渲染遇到异步依赖会显示 fallback。已经 resolved 的边界,在 默认插槽根节点被替换 时可再次 pending,例如根组件的类型或 key 改变。保持同一个根,只在更深处加入异步组件,不会让这个已完成的外层边界重新等待。

再次 pending 时,旧内容可继续显示;timeout 控制多久后换成 fallback,:timeout="0" 表示立即切换。它不是取消请求或判定请求失败的超时。

分页、筛选以及点击“刷新”发出的普通请求仍要自行管理 loading、error、取消和竞态。修改数据本身不会重跑当前组件的 async setup。

2.5 与 Transition、路由配合要检查层级 ​

Transition 可以与 Suspense 配合。下面是已有 Vue Router 的项目中可采用的外层结构;路由页仍需满足 Transition 的单根要求:

vue
<RouterView v-slot="{ Component }">
  <Transition mode="out-in" name="fade">
    <Suspense v-if="Component" :timeout="0">
      <component :is="Component" />
      <template #fallback><p>页面加载中…</p></template>
    </Suspense>
  </Transition>
</RouterView>

过渡 CSS 由项目提供;需要 KeepAlive 时,官方组合顺序是 Transition → KeepAlive → Suspense。Vue Router 路由配置中的 () => import(...) 由路由器等待,本身不会触发 Suspense;进入页面后遇到的 async setup 等依赖才由 Suspense 协调。组合与路由说明


3. 按需求选择机制 ​

需求对应机制
等待一组组件异步初始化Suspense 的 default / fallback 边界
搜索、分页、刷新请求composable 管理 loading / error / 请求参数
子组件初始化失败父级 onErrorCaptured + 错误界面
骨架屏放入 fallback 或普通 loading 分支
SSR 获取并复用服务端数据使用框架的数据获取与序列化机制,例如 L29 的 Nuxt useAsyncData

Suspense 不自动替请求缓存、序列化 SSR 数据,也不自动取消网络请求。Nuxt 的 useAsyncData 包含框架约定,不能用同名的几个 ref 函数等价替代。


4. 完整示例:加载、失败与重试 ​

在 Vue 3.5 + Vite + TypeScript 实验项目中创建下面两个文件,并让 App 渲染 SuspenseDemo。固定的 300ms 延迟用于观察 UI,不是请求实现或性能数据。

vue
<!-- src/AsyncPanel.vue -->
<script setup lang="ts">
const props = defineProps<{ attempt: number; fail: boolean }>()
const shouldFail = props.fail
await new Promise<void>((resolve) => setTimeout(resolve, 300))
if (shouldFail) throw new Error('模拟加载失败')
</script>

<template>
  <section>面板已加载:第 {{ props.attempt }} 次</section>
</template>
vue
<!-- src/SuspenseDemo.vue -->
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import AsyncPanel from './AsyncPanel.vue'

const attempt = ref(0)
const fail = ref(false)
const error = ref<string | null>(null)
const status = ref('等待初始化')

onErrorCaptured((cause: unknown) => {
  error.value = cause instanceof Error ? cause.message : String(cause)
  status.value = '失败'
  return false
})

function load(shouldFail: boolean) {
  fail.value = shouldFail
  attempt.value++
  error.value = null
}
</script>

<template>
  <div>
    <p>状态:{{ status }}</p>
    <div v-if="error" role="alert">
      {{ error }}
      <button @click="load(false)">重试</button>
    </div>
    <Suspense
      v-else
      :timeout="0"
      @pending="status = '等待中'"
      @resolve="!error && (status = '已完成')"
    >
      <AsyncPanel :key="attempt" :attempt="attempt" :fail="fail" />
      <template #fallback><p role="status">加载中…</p></template>
    </Suspense>
    <button :disabled="status === '等待中'" @click="load(false)">重新加载</button>
    <button :disabled="status === '等待中'" @click="load(true)">模拟失败</button>
  </div>
</template>

初次加载和点击“重新加载”都应短暂显示 fallback。点击“模拟失败”后出现错误;“重试”清除父级错误并创建新实例。这里替换的是 default 插槽的根组件,因而可以再次 pending。实际请求还应在作用域销毁时取消或忽略结果;重建组件不会撤销服务端已经执行的写入。

若接入商城的商品刷新,沿用 L21 的 useRequest,不要再写一个忽略竞态、取消和卸载的同名数据工具。下面脚本可作为商城中的独立页面组件:

vue
<!-- client/src/views/RequestProductsDemo.vue -->
<script setup lang="ts">
import { productApi } from '@/api/products'
import { useRequest } from '@/composables/useRequest'

const { data, loading, error, execute } = useRequest(
  async (signal: AbortSignal) => (await productApi.getList({ page: 1 }, signal)).data,
  { immediateArgs: [] },
)
</script>

<template>
  <div>
    <button :disabled="loading" @click="execute()">刷新</button>
    <p v-if="loading">正在获取商品…</p>
    <p v-if="error" role="alert">{{ error }}</p>
    <ul><li v-for="item in data ?? []" :key="item._id">{{ item.name }}</li></ul>
  </div>
</template>

5. 常见问题与处理方式 ​

问题 1:失败后如何 retry ​

Suspense 没有内置重试按钮。第 4 节通过清除父级 error、递增根组件 key 来重新初始化。只递增 key 却保留 v-if="error" 的错误分支,组件不会重新挂载。若只是普通请求失败,直接调用请求 composable 的 execute 即可,无需销毁整个页面。

问题 2:多个异步组件如何显示 ​

一组内容要同时出现时,可以共用边界;允许独立显示时,拆开边界。下面为结构对照,并不表示其中一种总是错误:

vue
<!-- 同步呈现:所有初始异步依赖完成后显示整个区域 -->
<Suspense>
  <div><FastComponent /><SlowComponent /></div>
  <template #fallback><p>整个区域加载中…</p></template>
</Suspense>

<!-- 独立呈现:两个区域各自处理等待 -->
<Suspense>
  <FastComponent />
  <template #fallback><p>区域一加载中…</p></template>
</Suspense>
<Suspense>
  <SlowComponent />
  <template #fallback><p>区域二加载中…</p></template>
</Suspense>

6. 决策表:Suspense 与请求 composable ​

需求SuspenseL21 useRequest
组件初始化等待可以统一协调只管理传入的请求
加载状态 UIfallback 插槽及事件loading ref
错误处理父级捕获error ref
重试父级状态与组件 key 配合execute 再发起请求
分页 / 筛选不追踪普通请求配合 watch 与参数调用
SSR 数据复用需要上层框架本课程 SPA 工具没有 SSR 序列化机制
取消请求不自动取消signal 传到底层,并在退出时取消

7. 检查边界 ​

遇到加载问题时,先确认正在等待的是组件初始化还是普通请求,再检查 default 根节点是否发生替换、错误 UI 由谁负责、离开页面后请求如何收尾。这样才能判断该调整 Suspense 边界,还是修请求状态管理。