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 需要具有上述异步依赖;每个插槽都只能有一个直接根节点:
<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 嵌套边界需要明确谁负责等待
下面仍是结构示意,内层边界用于处理内部组件替换:
<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 的单根要求:
<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,不是请求实现或性能数据。
<!-- 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><!-- 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,不要再写一个忽略竞态、取消和卸载的同名数据工具。下面脚本可作为商城中的独立页面组件:
<!-- 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:多个异步组件如何显示
一组内容要同时出现时,可以共用边界;允许独立显示时,拆开边界。下面为结构对照,并不表示其中一种总是错误:
<!-- 同步呈现:所有初始异步依赖完成后显示整个区域 -->
<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
| 需求 | Suspense | L21 useRequest |
|---|---|---|
| 组件初始化等待 | 可以统一协调 | 只管理传入的请求 |
| 加载状态 UI | fallback 插槽及事件 | loading ref |
| 错误处理 | 父级捕获 | error ref |
| 重试 | 父级状态与组件 key 配合 | execute 再发起请求 |
| 分页 / 筛选 | 不追踪普通请求 | 配合 watch 与参数调用 |
| SSR 数据复用 | 需要上层框架 | 本课程 SPA 工具没有 SSR 序列化机制 |
| 取消请求 | 不自动取消 | signal 传到底层,并在退出时取消 |
7. 检查边界
遇到加载问题时,先确认正在等待的是组件初始化还是普通请求,再检查 default 根节点是否发生替换、错误 UI 由谁负责、离开页面后请求如何收尾。这样才能判断该调整 Suspense 边界,还是修请求状态管理。