
一句话:
Suspense的语义是「在等待某件事完成时显示 fallback」——目前最常用于「懒加载(lazy)」,同时是「并发渲染 + 流式 SSR + React 19 的use」的基础设施。
一、Suspense 的语义
import { Suspense } from 'react';
<Suspense fallback={<Skeleton />}>
<Child /> {/* 子树「挂起」时显示 fallback */}
</Suspense>
「挂起(suspend)」是什么意思?
子组件在渲染过程中「抛出一个 Promise」→ React 捕获它 →
① 在「最近的 Suspense 边界」渲染 fallback
② 等 Promise resolve 后「重试渲染」子树
③ 成功后显示真正的内容
⭐ 所以 Suspense 是「声明式的等待」:
你不需要自己写 `if (loading) return <Skeleton />`,
只要让「数据层」在未就绪时抛 Promise,边界就会自动接管。
它解决的三个问题:
① 懒加载(React.lazy):代码还没下载完时显示 fallback
② 数据获取:数据还没就绪时显示 fallback(需要「支持 Suspense 的数据层」)
③ 流式 SSR:慢的部分后到,先给用户看到已就绪的内容
二、与 React.lazy 配合(最常见的用法)
import { lazy, Suspense } from 'react';
const HeavyChart = lazy(() => import('./HeavyChart'));
function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
);
}
⚠️ 三个细节:
① `React.lazy` 必须配 `Suspense`(否则报错)
② `fallback` 要「有高度」(否则内容出现时布局跳动 CLS)
③ Suspense 的「位置」决定了「等待期间影响的范围」——
放在最外层 → 整页骨架;放在内容区 → 只有内容区骨架(见 6.8)
// ✅ 更细的粒度:多个独立区域各自 Suspense
function Dashboard() {
return (
<div>
<Suspense fallback={<KpiSkeleton />}>
<KpiCards /> {/* 各自加载、各自占位 */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
</div>
);
}
// ⭐ 好处:KpiCards 先就绪就先显示(不必等图表)
三、与数据获取配合
// ① 数据路由(React Router):loader 让数据「渲染前」就绪
// 这种方式【不需要 Suspense】——数据已就位才渲染
const router = createBrowserRouter([
{ path: '/users', element: <Users />, loader: () => fetchUsers() },
]);
// ② TanStack Query 的 Suspense 模式
import { useSuspenseQuery } from '@tanstack/react-query';
function Comments({ postId }: { postId: string }) {
const { data } = useSuspenseQuery({
queryKey: ['comments', postId],
queryFn: () => fetchComments(postId),
});
// ⭐ 没有 isLoading 分支——未就绪时「挂起」,由外层 Suspense 接管
return <ul>{data.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}
function Page({ postId }: { postId: string }) {
return (
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={postId} />
</Suspense>
);
}
// ③ React 19 的 use():直接读 Promise
import { use, Suspense } from 'react';
function Comments({ promise }: { promise: Promise<Comment[]> }) {
const comments = use(promise); // ⭐ 未就绪时挂起
return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}
function Page() {
// ⚠️ Promise 必须「稳定」(不能在渲染中创建,否则每次都新建)
const [promise] = useState(() => fetchComments());
// 或由「上层/缓存」提供
return (
<Suspense fallback={<CommentsSkeleton />}>
<Comments promise={promise} />
</Suspense>
);
}
| 方式 | 是否用 Suspense | 说明 |
|---|---|---|
路由 loader |
❌(数据前置) | 渲染前就绪,最彻底 |
useSuspenseQuery |
✅ | Query 的 Suspense 集成 |
React 19 use(promise) |
✅ | 直接读 Promise |
手写 useEffect + state |
❌(自己管 loading) | 传统方式 |
四、边界划分与嵌套
// ✅ 边界要「独立、慢的部分单独放」
function Page() {
return (
<>
<Header /> {/* 同步渲染(快) */}
<Suspense fallback={<ProfileSkeleton />}>
<Profile userId={userId} /> {/* 中等速度 */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={postId} /> {/* 慢(单独边界 → 不阻塞上面的)*/}
</Suspense>
</>
);
}
划分边界的四条原则:
① 【按「独立的数据块」划分】——一个边界只负责一块「可独立就绪」的内容
② 【慢的单独放】——否则它会拖住同一边界里的其它内容
③ 【fallback 要有合适高度】——避免内容出现时布局跳动(CLS)
④ 【嵌套要有意义】——内层边界可以「先显示部分内容」
// ✅ 嵌套 Suspense 的典型场景:外层先显示骨架,内层再细化
<Suspense fallback={<PageSkeleton />}>
<PageLayout>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<Content />
</Suspense>
</PageLayout>
</Suspense>
⚠️ 嵌套的规则:
① 内层挂起时「只显示内层的 fallback」(不冒泡到外层)
② 所以「外层已经有内容」时,内层挂起不会让整页回到骨架
③ 反之如果「没有内层边界」,挂起会冒泡到外层 → 整个外层都显示 fallback
五、避免「闪 loading」
// ❌ 问题:切换 id 时「立刻」显示 fallback(内容闪一下骨架)
function Detail({ id }: { id: string }) {
return (
<Suspense fallback={<Skeleton />}>
<Content id={id} />
</Suspense>
);
}
// 从 /1 切到 /2:Content 挂起 → 立刻变成 Skeleton → 数据到了再变回内容
// ✅ 解法一:useDeferredValue 延迟「使用新 id」,让旧内容多留一会儿
function Detail({ id }: { id: string }) {
const deferredId = useDeferredValue(id);
return (
<Suspense fallback={<Skeleton />}>
<Content id={deferredId} /> {/* ⭐ 先用旧 id 的内容顶着 */}
</Suspense>
);
}
// ✅ 解法二:TanStack Query 的 placeholderData(保留上一份数据)
const { data, isPlaceholderData } = useQuery({
queryKey: ['detail', id],
queryFn: () => fetchDetail(id),
placeholderData: (prev) => prev, // ⭐ 参数变化时保留上一份
});
return (
<div style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>{/* 内容 */}</div>
);
// ✅ 解法三:数据路由下用 useNavigation 显示「顶部进度条」(不清空内容)
function Layout() {
const navigation = useNavigation();
return (
<>
{navigation.state === 'loading' && <TopProgressBar />}
<Outlet />
</>
);
}
六、错误处理
// ⚠️ Suspense 只处理「等待」,【不处理错误】
// 数据加载失败时应该抛出错误 → 由 ErrorBoundary 接管
// ✅ 正确的组合:ErrorBoundary 在外、Suspense 在内
function Page() {
return (
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<Skeleton />}>
<Content />
</Suspense>
</ErrorBoundary>
);
}
⚠️ 顺序为什么是「ErrorBoundary 在外、Suspense 在内」?
① 如果「加载失败」,错误要从内层冒泡到 ErrorBoundary
② 如果「ErrorBoundary 在内」,它的 fallback(错误 UI)会被外层的 Suspense 处理
→ 语义混乱(错误被当成「正在加载」)
③ 所以标准写法是「外层 ErrorBoundary 兜错、内层 Suspense 兜等待」
// ✅ 「加载失败 + 重试」的完整写法
function LazySection({ children }: { children: React.ReactNode }) {
return (
<ErrorBoundary
fallback={({ resetErrorBoundary }) => (
<div role="alert">
<p>内容加载失败</p>
<button onClick={resetErrorBoundary}>重试</button>
</div>
)}
>
<Suspense fallback={<Skeleton height={300} />}>{children}</Suspense>
</ErrorBoundary>
);
}
七、SSR 中的 Suspense(流式渲染)
// ✅ React 18+ 的流式 SSR:shell 先发送,慢的部分后补
// 服务端
import { renderToPipeableStream } from 'react-dom/server';
renderToPipeableStream(
<App />,
{
onShellReady() {
res.setHeader('content-type', 'text/html');
pipe(res); // ⭐ 外壳就绪就发送
},
onError(err) {
console.error(err); // 服务端要记录错误并决定是否隐藏
},
}
);
// 应用里用 Suspense 划分「可后到的部分」
function App() {
return (
<html>
<body>
<Header />
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* ⭐ 慢的部分:服务端先给占位,数据就绪后流式补上 */}
</Suspense>
</body>
</html>
);
}
流式 SSR 的三步:
① 服务端渲染「不挂起的部分」(shell)→ 立刻发送
② 渲染到挂起的位置时,输出「fallback 的 HTML」+ 一段「后续会替换它」的脚本
③ 数据就绪后,把「真正的 HTML」+ 替换脚本流式发送 → 客户端无缝替换
⭐ 收益:TTFB 更早、首屏更快可见、慢的部分不阻塞整体
⚠️ 前提:用 renderToPipeableStream(而不是 renderToString)
// ✅ 客户端水合也能「选择性」进行(Selective Hydration)
// React 18+ 会自动:用户点击某个还没水合的区域时,优先水合它
// (需要框架/Suspense 边界的配合,业务代码通常不用手动处理)
八、常见坑
// ① React.lazy 没配 Suspense(报错)
<LazyComp /> // ❌
// ② fallback 没有高度(内容出现时布局跳动)
<Suspense fallback={null}> // ⚠️
<Suspense fallback={<Skeleton height={300} />}> // ✅
// ③ 把 Suspense 放在最外层(路由切换时整页闪骨架)
// ④ 边界划分太粗(一个慢请求拖住整块内容)
// ⑤ 边界划分太细(到处都是骨架,视觉噪音大)
// ⑥ 切换参数时「立刻显示 fallback」(应该用 useDeferredValue / placeholderData)
// ⑦ 用 Suspense 处理「错误」(它只管等待,错误要 ErrorBoundary)
// ⑧ 把 ErrorBoundary 放在 Suspense 内层(语义混乱)
// ⑨ 在渲染中创建 Promise 传给 use()(每次渲染都新建 → 无限挂起)
const data = use(fetchData()); // ❌
// ⑩ 认为「Suspense 会自动帮你取数据」(它只是「等待」,取数据要数据层配合)
// ⑪ 在「非并发模式」(legacy ReactDOM.render)下期待新的 Suspense 行为
// ⑫ 用 Suspense 包「同步组件」并期待它挂起(不会)
// ⑬ 忘记「SSR 需要 renderToPipeableStream」(用旧 API 得不到流式收益)
// ⑭ 在「已挂起」的边界里放「必须立即显示的关键内容」
// ⑮ 嵌套 Suspense 却没意识到「内层 fallback 会替换掉外层的部分内容」
// ⑯ 认为「Suspense 能替代 loading 状态管理」(数据路由/Query 的方案各不同)
// ⑨ 的正确写法
function Page() {
// 方式一:用 useState 惰性初始化(只创建一次)
const [promise] = useState(() => fetchComments());
// 方式二:由「缓存/上层」提供(推荐——避免重复请求)
// 方式三:用框架/RSC 的能力(Next.js 里在 Server Component 里 await)
return <Suspense fallback={<Skeleton />}><Comments promise={promise} /></Suspense>;
}
九、面试延伸
- 「
Suspense是做什么的?」
Suspense的语义是「在等待某件事完成时显示 fallback」——它的机制是:子组件在渲染过程中「抛出一个 Promise」时,React 会捕获它,在最近的 Suspense 边界渲染fallback,等 Promise resolve 后「重试渲染」子树。它主要解决三类问题:① 懒加载(React.lazy);② 数据获取(需要支持 Suspense 的数据层,如useSuspenseQuery、React 19 的use);③ 流式 SSR(慢的部分后到)。 -
「
Suspense和loading状态有什么区别?」
① 声明式 vs 命令式——Suspense是「声明式的等待」(数据层负责throw Promise,边界负责显示 fallback),不需要自己写if (loading);② 粒度——Suspense是「边界级」的(一个边界内的多个组件共享一个 fallback),而手动loading通常是「组件级」的;③ 与并发/流式渲染集成——Suspense是 React 内部机制的一部分(支持「不阻塞已就绪内容」「流式 SSR」「选择性水合」),手动 loading 无法获得这些能力。 -
「
Suspense能帮你取数据吗?」
不能。它只是「等待的容器」——它负责「挂起时显示什么」,不负责「怎么取数据、怎么缓存、怎么重试」。数据获取需要「支持 Suspense 的数据层」:① React Router 的loader(渲染前取数据,其实不需要 Suspense);② TanStack Query 的useSuspenseQuery(缓存 + 重试 + 挂起);③ React 19 的use(promise)(配合「稳定的 Promise」,如由 Server Component 或缓存提供);④ 框架能力(Next.js 的 Server Components 里直接await)。 -
「Suspense 的边界应该放在哪里?」
四条原则:① 按「独立的数据块」划分——一个边界只负责「可独立就绪」的一块内容;② 慢的单独放——否则它会「挂起整个边界」,拖住同一块里的其它内容;③ fallback 要有确定高度(避免内容出现时的布局跳动 CLS);④ 不要放在最外层(会让「整页显示骨架」,包括导航栏)。典型结构是「页面骨架同步渲染 + 每个数据区块各自一个 Suspense」。 -
「怎么避免
Suspense导致的『闪 loading』?」
三种方式:①useDeferredValue——延迟「使用新参数」,让旧内容多顶一会儿(<Content id={deferredId} />);② TanStack Query 的placeholderData——参数变化时保留上一份数据(配合「半透明」表达正在更新);③ 数据路由的useNavigation——显示「顶部进度条」而不清空内容。核心思路是「有旧数据就先显示旧数据」,而不是「立刻切到 fallback 再切回来」。 -
「
Suspense和ErrorBoundary应该怎么组合?」
标准顺序是「ErrorBoundary 在外、Suspense 在内」:<ErrorBoundary fallback={<ErrorView />}> <Suspense fallback={<Skeleton />}> <Content /> </Suspense> </ErrorBoundary>理由:① 失败时错误要从内层冒泡到 ErrorBoundary(它负责显示错误 UI);② 等待时由内层 Suspense 负责显示骨架。如果反过来(Suspense 在外),那么 ErrorBoundary 的「错误 UI」会被当成「正在加载的内容」由外层 Suspense 处理,语义混乱且可能一直显示 fallback。
-
「流式 SSR 是怎么回事?」
流式 SSR(Streaming SSR)指「服务端边渲染边把 HTML 发给浏览器」:① 先把「不挂起的部分(shell)」渲染完并立即发送(TTFB 更早);② 遇到Suspense边界里「慢的部分」时,先发送它的 fallback HTML + 一段脚本;③ 数据就绪后,把「真正的内容 HTML」+ 替换脚本流式发送,浏览器无缝替换。实现上要用renderToPipeableStream(替代renderToString),配合<Suspense>边界划分。它的收益是「首屏更快可见 + 慢的部分不阻塞整体 + 可交互时间更早」。 -
「为什么
use(promise)里的 Promise 必须稳定?」
因为use会把「这个 Promise」作为「挂起的依据」——如果它在每次渲染时都被重新创建(如use(fetchData())),React 就会「每次都拿到一个新的未完成 Promise」,于是永远挂起(表现为无限 loading)。所以 Promise 必须由「稳定的来源」提供:① 用useState(() => fetchData())惰性初始化(只创建一次);② 由「上层组件/缓存」提供;③ 由框架(RSC / Server Component)提供。这也是「Suspense 数据获取需要用专门数据层」的原因之一。
一句话速记
Suspense= 「等待某件事完成时显示 fallback」(子组件throw Promise→ 边界接管 → resolve 后重试渲染);主要用途是「React.lazy懒加载」「支持 Suspense 的数据层(useSuspenseQuery、React 19use)」「流式 SSR」;它不负责取数据(只负责等待),也不处理错误——所以标准组合是「ErrorBoundary 在外、Suspense 在内」;边界按「独立数据块」划分、慢的单独放、fallback 要有高度、别放最外层;避免闪 loading 用useDeferredValue/placeholderData/useNavigation顶部进度条(有旧数据就先显示旧数据);use(promise)的 Promise 必须稳定(否则无限挂起)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!