发布日期

第 18 讲:Suspense 边界与 loading.tsx:流式渲染的关键武器

Suspense 边界、loading.tsx 与流式渲染的分块发送机制

上一讲我们看到 streaming SSR 的实质是 react-dom Fizz 在 <Suspense> 处暂停、写入 fallback、然后等子树 ready 再续上。但作为 App Router 用户,你写得最多的是 loading.tsx——它从来没出现 <Suspense> 的字眼,却"恰好"实现了 fallback。这种"魔法"的实现,就是本讲的主线。

学习目标

读完本讲,你能:

  1. 解释 Next.js 是 如何把 loading.tsx 自动包成 <Suspense> 的;以及 <Suspense> 在哪一层被注入。
  2. 区分 loading.tsx(segment 级 fallback)、手写 <Suspense fallback={...}>(任意层 fallback)、error.tsx(错误边界)三者职责。
  3. 看懂 LoadingBoundaryProvider + LoadingBoundary 的两阶段设计,以及为什么并行路由的 fallback 会被"提升"。
  4. 解释 prefetch 时 loading.tsx 的特殊作用——它决定了 prefetch 走 partial 还是 full。
  5. 在生产排障时定位"为什么 fallback 卡住""为什么子树先完成的页面反而被父级 Suspense 吃掉"等典型 issue。

本讲对应代码:

  • packages/next/src/server/app-render/create-component-tree.tsx(生成 loadingDataLoadingBoundaryProvider 包裹)
  • packages/next/src/client/components/layout-router.tsxLoadingBoundary 真正渲染 <Suspense>
  • packages/next/src/server/app-render/has-loading-component-in-tree.tsx(决定 prefetch 是否在此截断)

一、为什么需要 Suspense

回顾几个事实:

  1. 服务端 React 在 RSC 渲染期间 会按 <Suspense> 把树切分成"先到的 / 等待的"两部分。先到的部分可以立刻流出;等待的部分以 $L<id> 占位,等真正完成再补回。
  2. 服务端 react-dom(Fizz)渲染期间 也用 <Suspense> 切分 HTML 流:未完成的部分先输出 fallback DOM,完成后通过 <template>+<script> 注入替换。
  3. 客户端 React 在 hydrate 时一样把 <Suspense> 当作 boundary:等子树 chunk 加载完才挂载。

也就是说:Suspense 是 RSC 流、HTML 流、Hydration 三个阶段共享的"暂停 + 续上"的标记。任何 await 在 Suspense 之外,都会让父链全部阻塞,整页 TTFB 变差。

二、loading.tsx 的实现:自动注入

打开 create-component-tree.tsx 第 200 行附近:

202:packages/next/src/server/app-render/create-component-tree.tsx
  const [Loading, loadingStyles, loadingScripts] = loading

loading 是从 LoaderTree(第 8 讲)解析出来的 loading.tsx 对应模块条目。它在 parseLoaderTree 时就被识别出来,作为四元组 [Component, styles, scripts] 之一放入 segment。

第 775 行起:

797:packages/next/src/server/app-render/create-component-tree.tsx
  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 数据传给子节点

543:packages/next/src/client/components/layout-router.tsx
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.tsxlayout.tsx 在同一目录 → 它们对应同一个 CacheNode。
  • loading.tsx 是用来包 layout 的 children(即下一级 segment)的 Suspense fallback,不是包 layout 自己。
  • 所以 Provider 不直接渲染 <Suspense>,而是把数据放到 context 里,等 child segment 的 LoadingBoundary 来读。

四、LoadingBoundary:真正注入 Suspense

600:packages/next/src/client/components/layout-router.tsx
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

13:packages/next/src/server/app-render/has-loading-component-in-tree.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)
  )
}

策略:

  1. walkTreeWithFlightRouterState(第 16 讲讲过的"按 router state 裁剪"逻辑)走到一个 segment 时,会检查"是否有 loading 边界可以截断"。
  2. 如果有 loading.tsx(无论在当前 segment 还是更下层),prefetch 可以在这里截断——客户端预取到 loading.tsx 这一层就够了,再往下的内容等真正导航时再渲染。
  3. 如果没有 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 个对照场景:

  1. /with-loading:标准 loading.tsx,看导航期间 skeleton。
  2. /no-loading:相同结构但删了 loading.tsx,对比导航无 fallback。
  3. /parallel:两个 slot 各自慢,演示有/无 slot loading 的差异。
  4. /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 状态。

十五、本讲小结

  1. loading.tsx 不是黑魔法:它会被 create-component-tree 包成 loadingData,注入到 LoadingBoundaryProvider,再由子级 LoadingBoundary 真正渲染 <Suspense>
  2. <Suspense> 是 RSC + SSR + Hydration 三阶段共享的"暂停点",理解它的位置就能预判 fallback 在哪一层显示。
  3. prefetch 与 loading.tsx 的强耦合:缺 loading.tsx 会让 prefetch 从 partial 变成 full,对慢 API 危害大。
  4. 并行路由的每个 slot 应该独立 Suspense;否则 fallback 会"提升",导致整 layout 一起回退。
  5. 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 如何串联静态与动态阶段。