- 发布日期
第 18 讲:Suspense 边界与 loading.tsx:流式渲染的关键武器
Suspense 边界、loading.tsx 与流式渲染的分块发送机制
上一讲我们看到 streaming SSR 的实质是 react-dom Fizz 在
<Suspense>处暂停、写入 fallback、然后等子树 ready 再续上。但作为 App Router 用户,你写得最多的是loading.tsx——它从来没出现<Suspense>的字眼,却"恰好"实现了 fallback。这种"魔法"的实现,就是本讲的主线。
学习目标
读完本讲,你能:
- 解释 Next.js 是 如何把
loading.tsx自动包成<Suspense>的;以及<Suspense>在哪一层被注入。 - 区分
loading.tsx(segment 级 fallback)、手写<Suspense fallback={...}>(任意层 fallback)、error.tsx(错误边界)三者职责。 - 看懂
LoadingBoundaryProvider+LoadingBoundary的两阶段设计,以及为什么并行路由的 fallback 会被"提升"。 - 解释 prefetch 时
loading.tsx的特殊作用——它决定了 prefetch 走 partial 还是 full。 - 在生产排障时定位"为什么 fallback 卡住""为什么子树先完成的页面反而被父级 Suspense 吃掉"等典型 issue。
本讲对应代码:
packages/next/src/server/app-render/create-component-tree.tsx(生成loadingData与LoadingBoundaryProvider包裹)packages/next/src/client/components/layout-router.tsx(LoadingBoundary真正渲染<Suspense>)packages/next/src/server/app-render/has-loading-component-in-tree.tsx(决定 prefetch 是否在此截断)
一、为什么需要 Suspense
回顾几个事实:
- 服务端 React 在 RSC 渲染期间 会按
<Suspense>把树切分成"先到的 / 等待的"两部分。先到的部分可以立刻流出;等待的部分以$L<id>占位,等真正完成再补回。 - 服务端 react-dom(Fizz)渲染期间 也用
<Suspense>切分 HTML 流:未完成的部分先输出 fallback DOM,完成后通过<template>+<script>注入替换。 - 客户端 React 在 hydrate 时一样把
<Suspense>当作 boundary:等子树 chunk 加载完才挂载。
也就是说:Suspense 是 RSC 流、HTML 流、Hydration 三个阶段共享的"暂停 + 续上"的标记。任何 await 在 Suspense 之外,都会让父链全部阻塞,整页 TTFB 变差。
二、loading.tsx 的实现:自动注入
打开 create-component-tree.tsx 第 200 行附近:
const [Loading, loadingStyles, loadingScripts] = loading
loading 是从 LoaderTree(第 8 讲)解析出来的 loading.tsx 对应模块条目。它在 parseLoaderTree 时就被识别出来,作为四元组 [Component, styles, scripts] 之一放入 segment。
第 775 行起:
let loadingElement = Loading
? createElement(Loading, {
key: 'l',
})
: null
const loadingFilePath = getConventionPathByType(tree, dir, 'loading')
if (isSegmentViewEnabled && loadingElement) {
if (loadingFilePath) {
loadingElement = createElement(
SegmentViewNode,
{
key: cacheNodeKey + '-loading',
type: 'loading',
pagePath: loadingFilePath,
},
loadingElement
)
}
}
const loadingData: LoadingModuleData = loadingElement
? [loadingElement, loadingStyles, loadingScripts]
: null
注意:
loadingElement是直接createElement(Loading, {key: 'l'})创造的 React Element。SegmentViewNode是 dev DevTools 插桩用的(让你能在 Segment Explorer 看到 loading.tsx 对应 segment)。- 最终
loadingData = [loadingElement, loadingStyles, loadingScripts]。
这个 loadingData 会作为 CacheNodeSeedData 的一个字段(第 8 讲讲过种子数据),在 RSC payload 里跟随 segment 一起发到客户端。
三、LoadingBoundaryProvider:把 loading 数据传给子节点
export function LoadingBoundaryProvider({
loading,
children,
}: {
loading: LoadingModuleData
children: React.ReactNode
}) {
// Provides the data needed to render a loading.tsx boundary, via context.
//
// loading.tsx creates a Suspense boundary around each of a layout's child
// slots. (Might be bit confusing to think about the data flow, but: if
// loading.tsx and layout.tsx are in the same directory, they are assigned
// to the same CacheNode.)
//
// This provider component does not render the Suspense boundary directly;
// that's handled by LoadingBoundary.
// ...
const parentContext = use(LayoutRouterContext)
if (parentContext === null) {
return children
}
它的核心动作:通过 context 把 loading 数据"下推"给孩子里第一个 LoadingBoundary 使用。这是一个 延迟决议 的设计:
loading.tsx和layout.tsx在同一目录 → 它们对应同一个 CacheNode。- 但
loading.tsx是用来包 layout 的 children(即下一级 segment)的 Suspense fallback,不是包 layout 自己。 - 所以 Provider 不直接渲染
<Suspense>,而是把数据放到 context 里,等 child segment 的LoadingBoundary来读。
四、LoadingBoundary:真正注入 Suspense
function LoadingBoundary({
name,
loading,
children,
}: {
name: ActivityProps['name']
loading: LoadingModuleData | null
children: React.ReactNode
}): JSX.Element {
if (loading !== null) {
const loadingRsc = loading[0]
const loadingStyles = loading[1]
const loadingScripts = loading[2]
return (
<Suspense
name={name}
fallback={
<>
{loadingStyles}
{loadingScripts}
{loadingRsc}
</>
}
// ...
>
{children}
</Suspense>
)
}
return <>{children}</>
}
到这里 React 终于看到一个真实的 <Suspense>!其 fallback 由 loading.tsx 的渲染结果 + 它的 styles/scripts 组成。
关键事实:
loading.tsx的 styles 是一组<link rel="stylesheet">元素,scripts 是一组<script>元素。React 会优先把它们插入<head>但仅在 fallback 显示期间——这就是为什么loading.tsx也支持 CSS module / global CSS,且不会污染页面正常状态。
把"Provider + Boundary"两步合起来:
LayoutRouter (segment A)
└─ LoadingBoundaryProvider loading={A 的 loading data}
└─ children:
LayoutRouter (segment B, A 的子段)
└─ LoadingBoundary loading={从 context 拿到 A 的 loading data}
└─ <Suspense fallback={A 的 loading.tsx}>
{B 的实际内容}
</Suspense>
注意这种"父级提供,子级消费"的解耦:当一个 segment 还没 ready 时,它的父级 loading.tsx 会被显示——这就是 Next.js 文档常说的"loading.tsx 自动包裹其子段"的实现。
五、loading.tsx vs 手写 Suspense:何时用哪个
loading.tsx 适合:
- 整个 segment 切换期间显示统一 skeleton。
- 与 layout 强绑定:导航到
/products子路由时,/products/loading.tsx能立刻显示。 - 与 prefetch 系统集成(见下一节)。
手写 <Suspense> 适合:
- 单 segment 内部不同 fetch 各自独立显示加载态(电商列表的每个商品独立等待)。
- 嵌套 fallback:父级 skeleton 里再嵌一个内部 spinner。
- 需要
<Suspense unstable_avoidThisFallback>等高级属性。
业务示例:商品详情页
/products/[slug],整体走loading.tsx显示骨架;详情页里"相关商品"区域慢,再用<Suspense fallback={<SkeletonList />}>包它。这样切换 slug 时立刻有骨架,详情已 ready 时相关商品仍可独立加载。
六、prefetch 与 hasLoadingComponentInTree
第 9 讲提过:客户端的 <Link prefetch> 在静态路由下做 partial prefetch(只取布局壳和 loading),动态路由(或 PPR 关闭)下视情况取完整数据。这背后的关键判断是 某个 segment 链路上是否存在 loading.tsx。
import type { LoaderTree } from '../lib/app-dir-module'
export function hasLoadingComponentInTree(tree: LoaderTree): boolean {
const [, parallelRoutes, { loading }] = tree
if (loading) {
return true
}
return Object.values(parallelRoutes).some((parallelRoute) =>
hasLoadingComponentInTree(parallelRoute)
)
}
策略:
- 当
walkTreeWithFlightRouterState(第 16 讲讲过的"按 router state 裁剪"逻辑)走到一个 segment 时,会检查"是否有 loading 边界可以截断"。 - 如果有 loading.tsx(无论在当前 segment 还是更下层),prefetch 可以在这里截断——客户端预取到 loading.tsx 这一层就够了,再往下的内容等真正导航时再渲染。
- 如果没有 loading.tsx,prefetch 必须走更深的层级,直到找到能截断的位置——或者直接 bail-out 整个 segment。
业务示例:你给
/dashboard/[teamId]加了loading.tsx,hover 同事头像 prefetch 时只会预拉骨架,不会预先 join 业务数据库。这样:
- prefetch 流量更少
- 数据更新及时(不会被 stale 缓存"冻住")
- 如果数据敏感(比如根据当前用户 session 才能取),prefetch 不会跨用户泄露
如果你删掉这个 loading.tsx,prefetch 行为会立刻变成"尝试预拉完整数据",对慢 API 的依赖会暴露在 hover 时。
七、Suspense 在并行路由里的"提升"
并行路由(第 8 讲)有个细节容易踩坑:当两个 slot 中其中一个 fallback 时,整个 layout 可能整体 fallback。
为什么?因为 React Suspense 的语义是 "找最近的 Suspense 边界"。如果 slot A 里没有自己的 <Suspense>,A 的 await 会冒到再上一层,如果 layout 自己被 <Suspense> 包了一层,那么 A 没准备好会让整个 layout 回退到 fallback——slot B 就算 ready 也看不见。
解决办法:给每个 slot 都加 loading.tsx 或显式 <Suspense>,让 fallback 局限在 slot 内部。
app/
├── @sidebar/
│ ├── page.tsx
│ └── loading.tsx ← sidebar 的 fallback
├── @main/
│ ├── page.tsx
│ └── loading.tsx ← main 的 fallback
└── layout.tsx
这样每个 slot 各自有 Suspense 边界,A 慢的时候 B 立刻可见。
八、Suspense 与 error.tsx 的关系
error.tsx 是基于 React Error Boundary 的实现,它和 Suspense 互不冲突:
- Suspense 处理的是 "Promise 还没 resolve"
- Error Boundary 处理的是 "渲染抛错或 Promise reject"
但有一个微妙规则:如果 Suspense fallback 自身抛错,最近的 Error Boundary 会捕获,整段树会回退到 error UI。所以建议在 loading.tsx 里写 极简、无副作用的 skeleton,避免里面再发 fetch、读 cookies。
九、Suspense 和 client component 的"边界"
App Router 的 <Suspense> 必须出现在 server component 里 才有效(流式收益最大化)。如果你把 <Suspense> 写在 client component 内部包一段 server component 的子树(理论上不行——server 不能在 client 里渲染),那就退化成普通 React Suspense(only 等待 promise/lazy)。
// ✅ 推荐:server component 内 Suspense 包 server children
export default function Page() {
return (
<main>
<Suspense fallback={<Skel />}>
<SlowServer />
</Suspense>
</main>
);
}
// ⚠️ 退化:client component 内 Suspense
("use client");
export default function ClientPage() {
return (
<Suspense fallback={<Skel />}>
<SomeAsyncClientComponent />{" "}
{/* 这里 SomeAsyncClientComponent 必须是 React.lazy 或返回 thenable */}
</Suspense>
);
}
第二种写法不会"流式输出 server 内容",因为 client component 里没有 RSC 渲染管道——所有内容必须在客户端 React 里 lazy-load。
十、useFormStatus / useTransition / useOptimistic 与 Suspense
React 19 引入了几个 client hook,它们和 Suspense 紧密相关:
useFormStatus
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "提交中…" : "提交"}
</button>
);
}
它读 最近祖先 <form> 的提交状态。Server Action 在 form action 里执行期间,pending=true,不需要你自己写 state。
useTransition
const [isPending, startTransition] = useTransition();
startTransition(() => router.push("/x"));
isPending 在导航期间为 true。客户端 router 内部就用 startTransition 让导航变成 transition——所以新 segment 还在 fetch 时整页不会闪烁,仍显示旧内容直到新内容 ready 或 loading.tsx 命中。
useOptimistic
const [optimistic, addOptimistic] = useOptimistic(state, reducer);
让 form 提交期间立刻显示乐观结果,等 server action resolve 后再修正。和 Suspense 的关系:optimistic state 不会触发 Suspense fallback——你看到的是 instant 的 UI 切换。
十一、name 属性与 SegmentViewNode:dev 工具支持
return (
<Suspense
name={name}
fallback={...}
<Suspense name={name}> 是 React 19 新支持的。name 是 dev 模式 React Suspense Profiler 能看见的标识,配合 Next.js 的 SegmentViewNode 能在 React DevTools "Components" 面板里直接定位是哪个 segment 的 loading。
排障小贴士:dev 模式下打开 React DevTools → Components → 找带
Suspense (loading)名字的节点,hover 后能看到对应 file path。这是唯一一种"反查 Suspense 来源"的可靠方式。
十二、Suspense 与缓存的交互
回到第 13 讲的 4 层缓存:
- Suspense 本身不参与缓存判定,但它影响 RSC 流的"分块"——每个 Suspense 子树会被序列化成独立的 chunk。
unstable_cache/'use cache'的 cache key 不受 Suspense 影响,但 Suspense 可以让"还没 hit cache 时浏览器先看到 fallback"。- PPR(下一讲)会更激进:把整页拆成 "static prelude(可 prerender)" + "dynamic holes(用
Postpone()标记)",Suspense 就是天然的 hole 边界。
十三、生产排障实战清单
| 现象 | 可能位置 | 排查方向 |
|---|---|---|
loading.tsx 一直显示,永远不切换 | server child 永远 await | 检查上游 fetch 是否 hung,加 AbortSignal 超时 |
| 切换路由时整页闪白 | 父级 layout 没用 transition / 没有 loading.tsx | 给父级加 loading.tsx 或在自定义导航里包 startTransition |
| 慢 API 时所有 slot 一起转圈 | slot 里没自己的 Suspense | 给每个 slot 加 loading.tsx 或 <Suspense> |
| prefetch 流量异常大 | 缺 loading.tsx 截断 | 在中间 segment 加 loading.tsx,让 prefetch 截断 |
Hydration failed because the server rendered HTML didn't match 在 fallback 处 | fallback 输出依赖随机/时间 | 让 loading.tsx 保持纯静态 |
| Server Action 提交后 UI 不动 | 没用 useFormStatus / useTransition | 用 optimistic/transition 包提交链路 |
| RSC 流传到一半 abort | 客户端 navigation cancel | 检查 react-router-cache 的 signal,确保 AbortSignal 一直生效 |
十四、配套 fixture:Suspense 行为对照
fixtures/lecture-18/ 提供 4 个对照场景:
/with-loading:标准loading.tsx,看导航期间 skeleton。/no-loading:相同结构但删了loading.tsx,对比导航无 fallback。/parallel:两个 slot 各自慢,演示有/无 slot loading 的差异。/transitions:自定义按钮触发 router transition,演示 useTransition 与 Suspense 的合作。
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-18
pnpm install
pnpm dev
观察点:
/with-loading切到/with-loading/products/iphone时,骨架立刻出现。/no-loading同样切换没有骨架,整页静默 1.5s 后才更新。/parallel中关掉某个 slot 的loading.tsx,那一侧会一直空白等待。/transitions中点击"导航"按钮,按钮上显示 pending 状态。
十五、本讲小结
loading.tsx不是黑魔法:它会被create-component-tree包成loadingData,注入到LoadingBoundaryProvider,再由子级LoadingBoundary真正渲染<Suspense>。<Suspense>是 RSC + SSR + Hydration 三阶段共享的"暂停点",理解它的位置就能预判 fallback 在哪一层显示。- prefetch 与 loading.tsx 的强耦合:缺 loading.tsx 会让 prefetch 从 partial 变成 full,对慢 API 危害大。
- 并行路由的每个 slot 应该独立 Suspense;否则 fallback 会"提升",导致整 layout 一起回退。
- React 19 的
useFormStatus/useTransition/useOptimistic是对 Suspense 的补充,让 form/导航有更细致的 pending UX。
下讲预告
第 19 讲《PPR:Partial Prerendering 的 postpone 与 resume》。我们会深入到 PPR 的核心机制:build 时怎么 prerender 出"static prelude + holes"、运行时怎么 resume 这些 holes、postpone() 的内部实现、renderResumeDataCache 如何串联静态与动态阶段。