在前端开发中,我们最常处理的场景之一就是”等待”。
等一个接口的数据返回、等一个巨大的 JS 组件包加载完成……在漫长的等待里,怎么让网页不崩溃、用户不焦虑,是每一位开发者都在修行的功课。
而 Suspense(悬停/挂起),正是 React 官方给出的终极解法。
一、什么是 Suspense?
简单来说,Suspense 是 React 提供的一个内置组件。它允许你的组件在”缺少某些必要东西”(比如数据、代码)的时候,先暂停(挂起)自己的渲染,并优雅地展示一个”备用界面”,直到缺的东西到齐了,再换回真正的组件。
1.1 生活中的类比:餐厅上菜
想象你去一家餐厅点了一桌菜:
- 传统做法:厨房必须把所有菜都做完,才一起端上桌。哪怕凉菜 2 分钟就好了,也得等那道炖了 1 小时的佛跳墙,结果所有菜一起上——前面的全凉了。
- Suspense 做法:凉菜好了先上,热菜慢的先放个”敬请期待”的牌子在桌上,哪道好了就换哪道。
<Suspense fallback={<敬请期待 />}> 就是那个牌子,fallback 是牌子上的字,真正的菜是子组件。
1.2 从”防守”到”声明”
在没有 Suspense 之前,我们写代码通常像是在做”防守”——每个组件都得自己管 loading:
// 传统做法:每个组件都要自己管理 loading 状态
function UserDetail() {
const { data, isLoading } = useQuery(["user"], fetchUser);
if (isLoading) return <Spinner />; // ← 每个组件都得写这行
return <div>{data.name}</div>;
}
这种做法不仅让代码充满了密密麻麻的 if (isLoading),还容易导致页面各个区域的骨架屏(Skeleton)各转各的圈,体验极其破碎。
有了 Suspense 之后,逻辑变成了”声明式”的——组件只管要什么数据,loading 的事交给外层统一处理:

// Suspense 做法:组件只管渲染,loading 统一处理
function UserDetail() {
const data = use(fetchUserPromise); // ← 就是这行触发了 Suspense
return <div>{data.name}</div>;
}
// 外层统一管理 loading UI
<Suspense fallback={<Spinner />}>
<UserDetail />
</Suspense>;
use()和 Suspense 的关系:use()是 React 19 提供的 Hook,它和Suspense是配对使用的。use()负责在组件内部”声明我需要一个异步数据”,当数据没准备好时,它会自动 throw Promise 通知外层的<Suspense>。你可以把use()理解为触发器,<Suspense>理解为处理器——没有use()(或React.lazy()等),<Suspense>就永远不会被触发;没有<Suspense>,use()throw 出来的 Promise 就没人接,React 会报错。
对比一下:
| 传统写法 | Suspense 写法 | |
|---|---|---|
| loading 逻辑在哪 | 散落在每个组件里 | 集中在 <Suspense fallback={...}> |
| 组件的职责 | 数据获取 + loading 判断 + 渲染 | 只有渲染 |
| 多个组件同时 loading | 各转各的圈,体验破碎 | 统一一个 fallback,体验一致 |
1.3 核心原理:Throw Promise
括号里的内容如果看不懂没关系,先跳过,看完 1.4 的完整示例再回来看。
当子组件在进行异步操作时,它会向外抛出(throw)一个 Promise。React 捕获到这个 Promise 后,会立刻挂起该组件的渲染,转而展示最近的 <Suspense> 的 fallback。当 Promise resolve 后,React 自动回过头来完成这个组件的渲染。
关键点:不是 Suspense 自己去”检测”数据是否加载完毕,而是子组件主动 throw Promise 来”通知”外层的 Suspense。这意味着只有”Suspense-aware”的数据源才能触发这一机制。
1.4 “Suspense 边界”是什么?
<Suspense> 标签在组件树中划出了一块隔离区,这就是”Suspense 边界”。区内的组件挂起,只影响这块区域的 fallback,不会波及区外的兄弟组件。
“最近”的含义:子组件 throw Promise 后,Promise 会向上冒泡,遇到的第一个 <Suspense> 就是它的”最近边界”——在那里停下来,展示那个 fallback。
用嵌套结构最容易理解:
// 外层边界
<Suspense fallback={<PageSpinner />}>
<Header />
// 内层边界 ← 离 Comments 最近
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* use(commentsPromise) → throw Promise */}
</Suspense>
<Footer />
</Suspense>
Comments 挂起时的冒泡过程:
- Promise 向上冒泡 → 先遇到内层
<Suspense>→ 命中,停在这里 - 展示内层的 fallback(
CommentsSkeleton) - 外层
<Suspense>完全无感,Header 和 Footer 正常渲染
如果去掉内层 Suspense:
<Suspense fallback={<PageSpinner />}>
<Header />
<Comments /> {/* 挂起 → 一路冒泡到唯一的 Suspense */}
<Footer />
</Suspense>
这次没有内层边界兜底,Promise 直接冒泡到外层,整个页面(包括 Header、Footer)全被 PageSpinner 替换。
结论:边界越细,隔离越精确,用户体验越好。就像餐厅里每道菜放一个”敬请期待”的牌子,比整桌放一个牌子好得多。
1.5 完整示例:从数据源到 Suspense 边界的全链路
下面是一个最小可运行的示例,展示整个流程:
import { Suspense, use, useState } from "react";
// ---- 数据层:模拟一个带缓存的异步请求 ----
let cache = new Map();
function fetchData(url) {
if (!cache.has(url)) {
cache.set(url, getData(url)); // 同一个 url 只发一次请求
}
return cache.get(url); // 返回 Promise
}
async function getData(url) {
await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟 2 秒延迟
return [
{ id: 1, title: "Let It Be", year: 1970 },
{ id: 2, title: "Abbey Road", year: 1969 },
];
}
// ---- 组件层:用 use() 消费 Promise ----
function Albums() {
const albums = use(fetchData("/albums")); // ← Promise 未 resolve 时自动挂起
return (
<ul>
{albums.map(a => (
<li key={a.id}>
{a.title} ({a.year})
</li>
))}
</ul>
);
}
// ---- 页面层:用 Suspense 包裹 ----
export default function App() {
return (
<div>
<h1>My Music</h1>
<Suspense fallback={<p>Loading albums...</p>}>
<Albums />
</Suspense>
</div>
);
}
时间线——用户看到什么:
0s → 页面渲染,Albums 内部 use() 发现 Promise 未 resolve
→ throw Promise → 最近的 Suspense 捕获
→ 展示 fallback: "Loading albums..."
→ h1 "My Music" 正常显示(它在 Suspense 外面)
2s → Promise resolve,数据到达
→ React 自动把 "Loading albums..." 替换成专辑列表
→ 用户看到: "My Music" + [Let It Be, Abbey Road]
1.6 哪些数据源能触发 Suspense?
| 数据源 | 触发方式 | 适用场景 | 你什么时候会遇到 |
|---|---|---|---|
React.lazy() | 组件代码懒加载 | 代码分割 | 打包体积太大,想按需加载 |
use() Hook | 读取 Promise 或 Context | 数据获取 | 组件里要拿后端数据 |
| Suspense-aware 框架 | Relay、Next.js 等 | 框架内置数据获取 | 用 Next.js 的 fetch |
注意: 在
useEffect或事件处理器中直接调用fetch()不会触发 Suspense——普通的fetch返回的 Promise 不被 Suspense 识别,它只认lazy()、use()和框架集成这三种”打招呼”的方式。但在 RSC(服务端组件)环境中,async组件内直接await fetch()配合外层<Suspense>可以触发流式渲染——此时是服务端的 async/await 机制在起作用,而非客户端的 throw Promise 机制。
二、Suspense 的两大搭档 API
2.1 React.lazy —— 代码分割的元老搭档
React.lazy 是 Suspense 最早、最稳定的搭档,用于按需加载组件代码,避免首屏加载巨大的 JS bundle:
import { lazy, Suspense } from "react";
// 只在首次渲染 <MarkdownEditor> 时才加载对应 JS
const MarkdownEditor = lazy(() => import("./MarkdownEditor"));
const ChartDashboard = lazy(() => import("./ChartDashboard"));
function App({ page }) {
return (
<Suspense fallback={<div className="skeleton" />}>
{page === "editor" && <MarkdownEditor />}
{page === "dashboard" && <ChartDashboard />}
</Suspense>
);
}
这里发生了什么?
lazy(() => import('./MarkdownEditor'))返回一个特殊组件- 首次渲染这个组件时,
import()发起网络请求下载 JS 文件 - 文件没下载完 → 组件 throw Promise → Suspense 展示骨架屏
- 文件下载完 → Promise resolve → Suspense 替换为真实组件
- 后续再渲染同个组件 → 已有缓存,不会重新下载
2.2 use() —— 数据获取的新范式(React 19+)
React 19 引入了 use() Hook,这是目前官方推荐的在组件内消费 Promise 并配合 Suspense 的方式:
import { use, Suspense } from "react";
function PostsTab() {
const posts = use(fetchData("/posts"));
// ↑ 如果 Promise 未 resolve,自动挂起,触发最近的 Suspense 边界
return (
<ul>
{posts.map(post => (
<Post key={post.id} title={post.title} />
))}
</ul>
);
}
function App() {
return (
<Suspense fallback={<p>Loading posts...</p>}>
<PostsTab />
</Suspense>
);
}
use() 的两个特点:
- 可以在条件语句中调用 —— 这是它和
useContext的关键区别,useContext不允许放在条件语句里,而use()可以:
function Button({ showTheme }) {
if (showTheme) {
const theme = use(ThemeContext); // ✅ 合法
return <button className={theme}>Click</button>;
}
return <button>Click</button>;
}
- 支持从 Server Component 向 Client Component 传递 Promise —— 在 RSC 架构中,Server Component 可以创建 Promise 并作为 prop 传给 Client Component,Client Component 用
use()消费:
// ServerComponent (服务端)
async function Page() {
const commentsPromise = fetchComments(); // 不 await,直接传递 Promise
return (
<Suspense fallback={<div>Loading...</div>}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
);
}
// ClientComponent (客户端)
("use client");
function Comments({ commentsPromise }) {
const comments = use(commentsPromise); // 等待 Promise resolve
return comments.map(c => <p key={c.id}>{c.text}</p>);
}
避坑: 不要在渲染时直接创建 Promise 传给
use(),否则每次渲染都会创建新 Promise,导致无限挂起。Promise 应该来自缓存层或从父组件传入。
注意: 虽然
use()可以在条件语句中调用,但在消费 Promise 时,它推荐用于 Client Component。Server Component 中 React 官方建议使用async/await而非use()——原因:await从挂起点继续渲染,而use()会触发组件重新渲染,在服务端是不必要的开销。典型模式是 Server Component 创建 Promise 并作为 prop 传递,Client Component 用use()消费。
2.3 嵌套 Suspense:逐步揭晓的内容
当页面有多个异步组件时,嵌套 Suspense 可以让它们各自独立加载,互不拖累:
import { Suspense } from "react";
function ArtistPage({ artist }) {
return (
<>
<h1>{artist.name}</h1>
{/* 外层边界:Biography 和 Albums 共享 */}
<Suspense fallback={<BigSpinner />}>
<Biography artistId={artist.id} />
{/* 内层边界:Albums 独立加载 */}
<Suspense fallback={<AlbumsGlimmer />}>
<Albums artistId={artist.id} />
</Suspense>
</Suspense>
</>
);
}
时间线——用户看到什么:
0s → Biography 和 Albums 都在加载
→ 外层 Suspense 捕获 → 整个区域显示 BigSpinner
1s → Biography 数据到了
→ 外层 Suspense 的内容可以展示了
→ Biography 出现 ✅
→ Albums 还没好 → 内层 Suspense 捕获 → 显示 AlbumsGlimmer
3s → Albums 数据也到了
→ 内层 Suspense 替换 → Albums 出现 ✅
最终结果:
┌─────────────────────────────┐
│ Artist Name (标题) │
│ Biography 内容 (1s出) │
│ ┌───────────────────┐ │
│ │ Albums 列表 (3s出)│ │
│ └───────────────────┘ │
└─────────────────────────────┘
如果没有嵌套,把 Biography 和 Albums 放在同一个 Suspense 里,Biography 1 秒就好了,但也得等 Albums 3 秒,前 3 秒用户只看到一个 BigSpinner,什么都看不到。嵌套让快的先出来,慢的后补。
三、传统 SSR 的痛点与 React 18 的救赎
要理解 React 18 对 Suspense 的升级,我们需要先聊聊传统的服务端渲染(SSR)。
还是用餐厅的类比:传统 SSR 就像一家必须等所有菜做完才上桌的餐厅——
在传统的 SSR 模式下,网页从服务器到浏览器的过程遵循一种”全有或全无(All-or-Nothing)“的死板流程,它存在三个致命的木桶效应:
-
必须等所有数据查完:如果页面上有一个小组件的接口特别慢(比如猜你喜欢),整个服务器就得等它查完,才能把 HTML 返回给浏览器。就像点了一桌菜,凉菜 2 分钟好了也得等佛跳墙 1 小时才能上桌。
-
必须等所有 JS 加载完:浏览器收到了 HTML,但在所有的 JavaScript 脚本下载完成并解析之前,用户面对的是一个”看得见、动不了”的死网页。就像菜端上来了,但没有筷子——你能看到菜,但吃不了。
-
必须等所有组件注水完:浏览器开始执行 Hydration(注水/水合,指将 JS 事件绑定到 HTML 上的过程) 时,必须一次性把整个页面的组件全部注水完,期间用户点击任何地方都没有响应。就像给每道菜配筷子,必须所有筷子都摆好才能开始吃——哪怕你只想吃那盘凉菜。
四、React 18 突破:支持服务端的 Suspense
React 18 带来了底层架构的革命,核心就是通过 Suspense 实现了 Streaming HTML(流式 HTML) 和 Selective Hydration(选择性注水)。
这两大特性彻底打破了传统 SSR 的枷锁:
1. 流式传输:不等慢数据,先上车后补票
在 React 18 中,如果服务器遇到了被 <Suspense> 包裹的慢组件,它不会再卡住干等。服务器会先把已经准备好的页面主体 HTML 发送给浏览器,而把慢组件的位置留空,先塞一个 fallback 的 Loading 圈。
当服务器上的慢数据终于加载完后,React 会通过同一个 HTTP 连接,把该组件真正的 HTML 和一段微型的内联 JS 脚本”冲流(Stream)“发送到浏览器,直接把之前的 Loading 圈动态替换掉。
回到餐厅类比:菜好了就上,不齐也先上——凉菜先端,热菜后补,佛跳墙最后上。每道菜好了就替换桌上的”敬请期待”牌子。
对应的 API 也同步更新:
| 环境 | 流式渲染 API |
|---|---|
| Node.js | renderToPipeableStream(替代已废弃的 renderToNodeStream) |
| Edge(Deno / Cloudflare Workers) | renderToReadableStream |
旧的
renderToNodeStream不支持增量 Suspense 流式传输,因此被废弃。renderToString虽然仍可用,但对 Suspense 的支持非常有限。
2. 选择性注水:谁着急先给谁注水
当 JS 脚本到达浏览器后,React 不需要像以前那样傻傻地从头到尾给整个页面注水。它会优先给已经下载好、用户最需要的组件注水。
更神奇的是,如果用户在页面还没完全注水时,突然点击了被 <Suspense> 包裹的某个按钮,React 会敏锐地捕捉到这个用户交互,并把这个组件的注水优先级提升到最高,优先让它变得可交互。
回到筷子类比:不用等所有筷子都摆好——你碰了哪道菜,就先给那道菜配筷子。
五、使用注意事项(Caveats)
Suspense 很强大,但也有几条必须了解的行为边界:
5.1 首次挂起不保留状态
React 不会保留组件在首次挂载前就被挂起时的状态。如果组件还没完成首次渲染就被 Suspense 拦截了,之前积攒的状态会丢失。
大白话:组件还没”出生”就被挂起了,自然没有状态可言。
5.2 已展示内容的再次挂起
如果 Suspense 已经展示了真实内容,但子组件又触发了挂起,React 会重新显示 fallback —— 除非这次更新是通过 startTransition 或 useDeferredValue 触发的:
import { useTransition } from "react";
function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState("about");
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab); // 标记为 Transition,不会立即切到 fallback
});
}
// ...
}
使用 startTransition 标记的状态更新被视为”可中断的低优先级更新”,React 会保留当前内容直到新内容准备好,避免闪烁。
大白话:不用 startTransition 的话,切 tab 时旧内容瞬间消失、fallback 瞬间出现、新内容再替换 fallback——用户会看到”内容 → loading → 新内容”的闪烁。用了 startTransition,React 会保留旧内容直到新内容就绪,用户只看到”旧内容 → 新内容”的平滑切换。
5.3 Layout Effects 的清理与重触发
如果已可见的内容因再次挂起需要隐藏,React 会清理内容树中的 layout effects;当内容重新准备好时,会重新触发这些 effects。
六、总结:从”防守”到”掌控”
| 维度 | 传统模式 | React 18+ Suspense |
|---|---|---|
| 代码编写 | 满屏 if (isLoading),状态管理混乱 | 声明式包裹,组件聚焦核心业务逻辑 |
| 代码分割 | 手动动态 import + 状态管理 | React.lazy + Suspense 一站式搞定 |
| 数据获取 | useEffect + fetch + 手动 loading 状态 | use() + Suspense 声明式等待(React 19+) |
| SSR 首屏 | 遇到慢接口整页卡死,白屏时间长 | 流式渲染,快内容先行,慢组件流式补全 |
| 页面交互 | 注水过程是一块铁板,期间页面无响应 | 选择性注水,用户点哪里,哪里优先激活 |
React 18+ 的 Suspense 不再只是一个单纯的客户端”加载动画控制器”,它已经上升为跨越服务器和浏览器两端的、协同异步内容传输的调度中心。配合 React.lazy、use()、startTransition 等 API,它让现代网页在面对复杂网络和慢接口时,依然能表现出丝滑、极速的韧性。