React 基础体系 · 第 61/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 流式 SSR:Shell、Suspense、错误、缓存和断流恢复

React 的服务端渲染(Server-Side Rendering,SSR)通常被理解为“服务器生成 HTML,浏览器拿到 HTML 后执行 JavaScript 完成 hydration”。传统 SSR 往往要等整棵组件树渲染完成后才发送响应;流式 SSR 则允许服务器先发送已经完成的部分,再把异步内容逐段补充到浏览器。

流式 SSR 不是简单地把 renderToString 换成“边算边写”。它同时改变了:

  • HTML 何时可以发送;
  • HTTP 状态码和响应头何时确定;
  • Suspense 边界如何作为异步分片;
  • 服务端错误和客户端错误分别由谁处理;
  • 数据缓存的作用域和失效策略;
  • 服务端渲染中断后,浏览器到底能否恢复。

本文以 React 19、现代 TypeScript 和 Node.js HTTP 服务为背景,重点讨论 React DOM 的传统 SSR API。React Server Components、Next.js App Router 等框架会在此基础上增加自己的数据缓存、路由和编译约束,但不能改变浏览器 HTTP 响应已经发送这一事实。


一、流式 SSR 要解决什么问题

假设页面包含三个部分:

  1. 不依赖远程数据的导航栏;
  2. 需要 200 ms 查询的商品列表;
  3. 需要 2 s 查询的推荐模块。

如果服务端必须等待所有内容完成,响应时间至少接近:

T完整 SSRmax(T导航,T商品,T推荐)T_{\text{完整 SSR}} \approx \max(T_{\text{导航}}, T_{\text{商品}}, T_{\text{推荐}})

在这个例子中,页面要等接近 2 s 才能开始发送。

流式 SSR 把页面拆成“可以立即发送的部分”和“需要等待的部分”。理想情况下:

T首字节TShellT_{\text{首字节}} \approx T_{\text{Shell}}

其中:

  • T_first_byte 是浏览器收到首个响应字节的时间;
  • T_Shell 是初始页面外壳生成所需的时间;
  • 慢速内容不再阻塞整个文档。

但流式 SSR 并不会让远程请求本身变快。它只是把等待从“阻塞整页”变成“阻塞某个 Suspense 边界”。


二、Shell 到底是什么

2.1 定义

在 React 流式 SSR 中,Shell 是不需要等待尚未完成的 Suspense 内容,就可以生成的初始页面结构。

例如:

function App() {
  return (
    <html>
      <body>
        <Header />

        <main>
          <Suspense fallback={<ProductSkeleton />}>
            <ProductList />
          </Suspense>

          <Suspense fallback={<RecommendationSkeleton />}>
            <Recommendations />
          </Suspense>
        </main>
      </body>
    </html>
  );
}

如果:

  • Header 同步完成;
  • ProductList 挂起;
  • Recommendations 挂起;

那么 Shell 通常包括:

<html>
  <body>
    <header>...</header>
    <main>
      <!-- ProductList 的 fallback -->
      <!-- Recommendations 的 fallback -->
    </main>
  </body>
</html>

Shell 不等于完整 HTML,也不等于“所有组件的 fallback”。更准确地说:

  • Suspense 边界之外能完成的内容属于 Shell;
  • Suspense 边界之内如果暂时不能完成,则先输出其 fallback
  • 等异步内容完成后,React 再通过流中的隐藏节点和脚本,把真实内容插入对应位置。

2.2 onShellReadyonAllReady

Node.js 环境下,renderToPipeableStream 会返回一组回调:

const stream = renderToPipeableStream(<App />, {
  onShellReady() {
    // Shell 已经准备好,可以开始发送响应
  },

  onAllReady() {
    // 整棵树都准备好,包括所有 Suspense 内容
  },

  onShellError(error) {
    // Shell 本身无法生成
  },

  onError(error) {
    // 记录服务端渲染过程中的错误
  },
});

这两个回调的差异决定了服务器采用哪种发送策略:

  • onShellReadypipe:使用流式 SSR;
  • onAllReadypipe:等待所有内容完成后再发送,更接近传统 SSR;
  • onShellError:说明连初始页面都无法生成,通常需要返回错误页或静态兜底页。

Shell 可发送的必要条件不是“所有组件都成功”,而是“根树能够在 Suspense 边界保护下形成一个可用初始界面”。

2.3 一个关键边界:Shell 生成后状态码很难改变

HTTP 响应头和状态码通常在第一次写出响应体时提交。假设服务端先发送:

HTTP/1.1 200 OK
Content-Type: text/html

随后某个推荐模块查询失败,服务器不能再把同一个响应改成:

HTTP/1.1 500 Internal Server Error

因此,流式 SSR 中必须在 Shell 阶段决定:

  • 成功页面是否返回 200
  • 是否设置 Cache-Control
  • 是否允许 CDN 缓存;
  • 是否需要重定向或返回错误页。

如果路由鉴权、权限判断或资源是否存在会决定状态码,就应尽量在 Shell 发送前完成。否则可能出现“页面主体返回 200,但页面内部显示无权限”的语义不一致。


三、Suspense 如何成为流式分片边界

3.1 Suspense 的两个职责

在流式 SSR 中,Suspense 同时承担两个职责:

  1. 渲染控制:子树未准备好时显示 fallback
  2. 传输切分:允许 Shell 先发送,子树完成后再补发。
<Suspense fallback={<Loading />}>
  <SlowPanel />
</Suspense>

如果 SlowPanel 在服务端渲染期间抛出一个 Promise,React 会认为该子树暂时未完成:

function SlowPanel() {
  const data = resource.read(); // 未完成时抛出 Promise
  return <section>{data.title}</section>;
}

React 不会把这个 Promise 当成普通异常,而是:

  1. 暂停当前 Suspense 子树;
  2. 渲染 fallback
  3. 继续寻找其他可以完成的内容;
  4. 通过流先发送 Shell;
  5. Promise 完成后生成真实内容;
  6. 发送让浏览器替换 fallback 的流式指令。

3.2 一个最小的 Promise 资源

在传统 SSR 中,组件不能直接在普通客户端组件函数里使用任意 async function 作为组件。更通用的方式是把 Promise 包装成具有 read() 方法的资源:

type Resource<T> = {
  read(): T;
};

function createResource<T>(promise: Promise<T>): Resource<T> {
  let status: "pending" | "fulfilled" | "rejected" = "pending";
  let value: T;
  let error: unknown;

  promise.then(
    (result) => {
      status = "fulfilled";
      value = result;
    },
    (reason) => {
      status = "rejected";
      error = reason;
    },
  );

  return {
    read() {
      if (status === "pending") {
        throw promise;
      }

      if (status === "rejected") {
        throw error;
      }

      return value;
    },
  };
}

使用方式:

type Product = {
  id: string;
  name: string;
};

function ProductList({
  resource,
}: {
  resource: Resource<Product[]>;
}) {
  const products = resource.read();

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

服务端组件:

function App({
  productResource,
}: {
  productResource: Resource<Product[]>;
}) {
  return (
    <html>
      <body>
        <Header />

        <main>
          <Suspense fallback={<p>正在加载商品……</p>}>
            <ProductList resource={productResource} />
          </Suspense>
        </main>
      </body>
    </html>
  );
}

这里的因果关系是:

resource.read()
  ├─ Promise 未完成 ──> Suspense 捕获 Promise ──> 输出 fallback
  ├─ Promise 完成 ────> 返回数据 ─────────────> 输出真实内容
  └─ Promise 失败 ────> 抛出 Error ────────────> 进入错误路径

3.3 嵌套边界决定最小阻塞范围

比较下面两种结构:

<Suspense fallback={<PageSkeleton />}>
  <Header />
  <ProductList />
  <Recommendations />
</Suspense>

与:

<Header />

<Suspense fallback={<ProductSkeleton />}>
  <ProductList />
</Suspense>

<Suspense fallback={<RecommendationSkeleton />}>
  <Recommendations />
</Suspense>

第一种结构中,只要任意一个子组件挂起,整个边界都显示 PageSkeleton。第二种结构允许商品区和推荐区独立流式到达。

如果第一个区域耗时 200 ms,第二个区域耗时 2 s:

  • 一个大边界:两者都可能等待到 2 s;
  • 两个独立边界:商品区约 200 ms 后先显示,推荐区继续显示 fallback。

因此,Suspense 边界不是单纯的加载动画位置,而是页面的异步隔离和传输粒度。

3.4 边界过细也有代价

边界并非越多越好。每个边界都可能产生:

  • 一个 fallback;
  • 一段流式控制信息;
  • 一次客户端替换;
  • 更多布局状态。

如果把一个本来应该整体显示的表格拆成几十个边界,用户可能看到大量局部跳变,HTML 和脚本开销也会上升。边界的合理粒度应与用户可理解的界面区域一致,例如“商品列表”“评论区”“推荐区”,而不是任意一个文本节点。


四、Node.js 中的完整流式 SSR 示例

下面示例使用:

  • React 19;
  • react-dom/serverrenderToPipeableStream
  • Node.js HTTP;
  • TypeScript;
  • Suspense
  • 服务端请求取消;
  • Shell 错误和边界错误处理。

4.1 服务端数据资源

// server-data.ts
export type Product = {
  id: string;
  name: string;
};

export function createProductResource(
  signal: AbortSignal,
): Resource<Product[]> {
  return createResource(
    fetch("https://api.example.com/products", { signal }).then(async (res) => {
      if (!res.ok) {
        throw new Error(`Product API returned ${res.status}`);
      }

      return (await res.json()) as Product[];
    }),
  );
}

export type Resource<T> = {
  read(): T;
};

export function createResource<T>(promise: Promise<T>): Resource<T> {
  let status: "pending" | "fulfilled" | "rejected" = "pending";
  let value: T;
  let error: unknown;

  promise.then(
    (result) => {
      status = "fulfilled";
      value = result;
    },
    (reason) => {
      status = "rejected";
      error = reason;
    },
  );

  return {
    read() {
      if (status === "pending") throw promise;
      if (status === "rejected") throw error;
      return value;
    },
  };
}

生产代码不能直接使用示例中的 api.example.com。运行前需要替换成真实服务,并确保 Node.js 进程能够访问该地址。

4.2 React 页面

// App.tsx
import { Suspense } from "react";
import type { Resource, Product } from "./server-data";

function Header() {
  return (
    <header>
      <a href="/">WR Store</a>
    </header>
  );
}

function ProductList({
  resource,
}: {
  resource: Resource<Product[]>;
}) {
  const products = resource.read();

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

function ProductErrorFallback() {
  return <p role="alert">商品暂时无法加载,请稍后重试。</p>;
}

export function App({
  productResource,
}: {
  productResource: Resource<Product[]>;
}) {
  return (
    <html lang="zh-CN">
      <head>
        <meta charSet="utf-8" />
        <title>商品列表</title>
      </head>
      <body>
        <Header />

        <main>
          <h1>商品</h1>

          <Suspense fallback={<p>正在加载商品……</p>}>
            <ProductList resource={productResource} />
          </Suspense>
        </main>
      </body>
    </html>
  );
}

这里的 ProductErrorFallback 还没有真正生效,因为 React 的错误边界不能简单用一个 fallback 属性替代。Suspense fallback 只处理“仍在等待”的 Promise,不处理已经失败的 Error。错误边界需要单独定义。

4.3 一个客户端错误边界

React 错误边界仍然使用类组件形式:

// ErrorBoundary.tsx
import React from "react";

type Props = {
  children: React.ReactNode;
  fallback: React.ReactNode;
};

type State = {
  hasError: boolean;
};

export class ErrorBoundary extends React.Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  componentDidCatch(error: unknown, info: React.ErrorInfo) {
    console.error("Client rendering error:", error, info);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }

    return this.props.children;
  }
}

将它放在 Suspense 外面:

<ErrorBoundary fallback={<ProductErrorFallback />}>
  <Suspense fallback={<p>正在加载商品……</p>}>
    <ProductList resource={productResource} />
  </Suspense>
</ErrorBoundary>

常见的组合关系是:

ErrorBoundary
└── Suspense
    └── 异步组件

其语义是:

  • Promise 未完成:Suspense 显示 loading;
  • Promise 失败或组件抛出 Error:ErrorBoundary 显示错误界面;
  • 客户端 hydration 或后续交互失败:ErrorBoundary 可以接住客户端错误。

但需要注意:错误边界主要依赖客户端渲染机制,不能把它当成服务端 HTTP 错误状态码的替代品。

4.4 Node HTTP 服务

// server.tsx
import http from "node:http";
import React from "react";
import { renderToPipeableStream } from "react-dom/server";
import { App } from "./App";
import { createProductResource } from "./server-data";

const server = http.createServer((req, res) => {
  if (req.url !== "/") {
    res.statusCode = 404;
    res.setHeader("Content-Type", "text/plain; charset=utf-8");
    res.end("Not Found");
    return;
  }

  const controller = new AbortController();

  let didError = false;
  let responseStarted = false;

  const { pipe, abort } = renderToPipeableStream(
    <App
      productResource={createProductResource(controller.signal)}
    />,
    {
      bootstrapScripts: ["/static/client.js"],

      onShellReady() {
        responseStarted = true;

        res.statusCode = didError ? 500 : 200;
        res.setHeader("Content-Type", "text/html; charset=utf-8");
        res.setHeader("Cache-Control", "private, no-store");

        pipe(res);
      },

      onAllReady() {
        // 流式模式下通常不在这里开始 pipe。
        // 这里可用于指标、日志或结束阶段观察。
      },

      onShellError(error) {
        console.error("Shell rendering failed:", error);

        if (!responseStarted) {
          res.statusCode = 500;
          res.setHeader("Content-Type", "text/html; charset=utf-8");
          res.end(
            "<!doctype html><h1>页面暂时无法生成</h1>",
          );
        }
      },

      onError(error) {
        didError = true;
        console.error("Streaming SSR error:", error);
      },
    },
  );

  const timeout = setTimeout(() => {
    console.error("SSR timeout");
    abort();
    controller.abort();
  }, 10_000);

  res.on("finish", () => {
    clearTimeout(timeout);
  });

  res.on("close", () => {
    clearTimeout(timeout);

    // 如果客户端提前断开,停止 React 继续生成,
    // 同时取消下游 fetch,避免浪费服务端资源。
    if (!res.writableFinished) {
      abort();
      controller.abort();
    }
  });
});

server.listen(3000, () => {
  console.log("SSR server listening on http://localhost:3000");
});

这个示例中的关键点如下。

onShellReady

onShellReady 表示页面外壳已经可以发送。此时使用:

pipe(res);

React 会把生成的 HTML 和后续流式片段写入 Node 的响应对象。

onAllReady

onAllReady 表示所有 Suspense 内容都已经准备好。若在这里才 pipe,就失去了“尽早发送 Shell”的主要收益,但它适用于:

  • 爬虫专用 HTML;
  • 需要完整 HTML 后再缓存的场景;
  • 不希望用户看到渐进式内容的页面。

onShellError

如果根组件、文档结构或 Shell 外部组件抛出错误,React 可能无法生成一个可用的初始页面。此时应在没有发送响应的前提下返回错误页。

onError

onError 既用于记录错误,也用于标记最终响应是否应视为失败。但它不能保证响应状态码永远能改成 500,因为错误可能发生在 Shell 已经发送之后。


五、浏览器端 hydration 与流式 HTML 的关系

服务端 HTML 发送完成后,浏览器需要使用 hydrateRoot 将事件处理、状态和组件逻辑接回已有 DOM:

// client.tsx
import { hydrateRoot } from "react-dom/client";
import { App } from "./App";
import { ErrorBoundary } from "./ErrorBoundary";
import { createProductResource } from "./client-data";

const root = document;

hydrateRoot(
  root,
  <ErrorBoundary fallback={<p>页面加载失败,请刷新重试。</p>}>
    <App productResource={createProductResource()} />
  </ErrorBoundary>,
);

实际项目中,服务端和客户端传给 App 的数据资源必须具有一致的语义。常见方案有两种:

方案一:客户端重新获取

服务端输出已有内容,客户端 hydration 后再次请求同一数据。优点是实现简单,缺点是可能产生重复请求或 hydration 时数据不一致。

方案二:传输服务端数据快照

服务端把已获得的数据序列化到 HTML:

<script>
  window.__INITIAL_DATA__ = {
    products: [...]
  };
</script>

客户端从快照创建已完成资源,避免第一次重复请求。这要求:

  • 序列化数据必须经过安全编码,防止 XSS;
  • 数据必须与当前用户、语言和路由匹配;
  • 不应把令牌、密码或内部字段注入 HTML;
  • 快照结构需要版本管理。

流式 SSR 本身并不自动帮应用设计数据快照。React 负责组件树和流的协调,数据传输协议仍然是应用或框架的职责。


六、错误处理:Promise、异常、Shell 和客户端恢复

6.1 挂起不是错误

下面两种情况必须区分:

// 暂时未完成
throw pendingPromise;

// 已经失败
throw new Error("API failed");

第一种会被 Suspense 处理;第二种表示失败,需要错误边界或服务端错误回调处理。

如果把网络失败包装成永远 pending 的 Promise,页面会永久停留在 loading 状态;如果把正常等待错误地包装成 Error,页面会直接进入错误状态。异步资源必须明确区分:

pending  -> fallback
fulfilled -> 内容
rejected -> error fallback

6.2 Shell 之前发生错误

如果 Shell 尚未生成:

请求
  └─ React 开始渲染
      └─ 根组件抛出错误
          └─ 没有可用 Shell
              └─ 返回 500 错误页

此时服务器仍然可以设置:

HTTP/1.1 500 Internal Server Error

并返回一个独立的静态错误 HTML。

6.3 Shell 之后发生错误

如果 Shell 已经发送:

请求
  └─ 发送 200 Shell
      └─ 慢速 Suspense 子树渲染失败
          ├─ 服务端记录 onError
          ├─ 对应边界无法提供真实内容
          └─ 浏览器继续使用 fallback 或尝试客户端渲染

此时服务器一般不能修改已经提交的状态码。React 会把错误作为流式渲染过程的一部分通知客户端;如果该边界包含可在浏览器中重新渲染的组件,客户端可能尝试恢复。

“可能恢复”取决于多个条件:

  • 客户端 JavaScript 已经成功下载;
  • hydration 没有整体失败;
  • 组件在客户端可以运行;
  • 客户端数据请求能够成功;
  • 错误边界位于合适的位置;
  • 失败不是不可恢复的协议或脚本错误。

因此,不能把 onError 当成“React 会自动重试服务端请求”的保证。React 的错误回调负责通知和记录,数据重试需要由资源层、错误边界或业务交互实现。

6.4 错误边界的服务端限制

错误边界在服务端不能像浏览器端那样可靠地替换整棵错误子树。服务端没有一个已经存在、可交互的 React 树供错误边界更新状态。因此:

  • 服务端错误首先由 onErroronShellError 等 SSR 回调处理;
  • 客户端 hydration 后,错误边界才可以接住后续客户端渲染错误;
  • Suspense fallback 不是错误 UI;
  • HTTP 500 与页面内“加载失败”是两个不同层次的错误。

七、流式 SSR 的状态机

可以把一次 SSR 请求抽象成以下状态:

stateDiagram-v2
    [*] --> Rendering
    Rendering --> ShellReady: 根树可生成
    Rendering --> ShellFailed: Shell 外部错误
    ShellReady --> Streaming: pipe 开始
    Streaming --> Streaming: Suspense 子树完成
    Streaming --> PartialError: 子树错误
    Streaming --> Aborted: 超时或客户端断开
    Streaming --> Completed: 所有内容完成
    ShellFailed --> ErrorResponse
    PartialError --> ClientRecovery
    Aborted --> ClientRecovery
    Completed --> [*]
    ErrorResponse --> [*]
    ClientRecovery --> [*]

需要特别注意三种不同的“完成”:

  1. ShellReady:可以发送初始页面;
  2. Completed:所有服务端内容都已经流出;
  3. ClientRecovery:部分内容由浏览器重新渲染,不能等同于服务端完成。

八、renderToPipeableStreamrenderToReadableStream

React 为不同运行时提供了不同的流接口。

8.1 Node.js:renderToPipeableStream

适用于 Node.js 的可写流:

import { renderToPipeableStream } from "react-dom/server";

const { pipe, abort } = renderToPipeableStream(<App />, options);

它使用 Node 风格的 pipe,常见于:

  • Node HTTP;
  • Express;
  • Fastify 的 Node 适配层;
  • 自定义 SSR 服务。

8.2 Web Streams:renderToReadableStream

适用于支持 Web Streams 的运行时,例如部分边缘运行时:

import { renderToReadableStream } from "react-dom/server";

export default async function handler() {
  const stream = await renderToReadableStream(<App />, {
    bootstrapScripts: ["/static/client.js"],
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/html; charset=utf-8",
    },
  });
}

Web Streams 版本的生命周期与 Node 版本不同,不能直接把 pipe(res) 的代码照搬过去。部署到 Cloudflare Workers、Deno、Bun 或框架的 Edge Runtime 时,应根据运行时提供的 RequestResponse 和取消信号编写代码。

8.3 版本边界

react-dom/server 的具体回调、流接口和框架封装应以当前 React 版本文档为准。本文讨论的是 React 19 中稳定的流式 SSR 基本模型,不把某个框架的路由缓存、Server Components 协议或实验性 API 当成 React DOM SSR 本身的保证。


九、缓存:React 缓存、请求缓存和 HTTP 缓存不是一回事

“SSR 缓存”常被混用为多个概念。至少要区分以下四层。

9.1 请求内缓存

请求内缓存只在一次 HTTP 请求中有效,用于避免同一个资源被多个组件重复读取。

例如:

type RequestCache = Map<string, Resource<unknown>>;

function getProductResource(
  cache: RequestCache,
  signal: AbortSignal,
): Resource<Product[]> {
  const key = "products";

  const existing = cache.get(key);
  if (existing) {
    return existing as Resource<Product[]>;
  }

  const resource = createProductResource(signal);
  cache.set(key, resource);
  return resource;
}

同一个请求中:

function Page({ cache, signal }: Props) {
  const productResource = getProductResource(cache, signal);

  return (
    <>
      <Suspense fallback={<p>商品加载中</p>}>
        <ProductList resource={productResource} />
      </Suspense>

      <Suspense fallback={<p>摘要加载中</p>}>
        <ProductSummary resource={productResource} />
      </Suspense>
    </>
  );
}

两个组件共享同一个 Promise,避免重复请求。

为什么不能随意使用全局 Map

错误示例:

const globalCache = new Map<string, Resource<unknown>>();

如果 key 只有 "products",那么不同用户可能共享:

  • 登录态不同的数据;
  • 不同租户的数据;
  • 不同语言的数据;
  • 不同权限下的数据。

这会造成严重的数据泄露。请求内缓存应绑定请求生命周期,缓存 key 至少考虑:

用户身份 + 租户 + 语言 + 路由参数 + 查询参数 + 数据版本

9.2 React 的 cache

React 提供的 cache API 用于缓存函数结果,但其适用范围和行为依赖 React Server Components 等服务端组件环境。它不是一个通用的跨请求 CDN,也不是自动的数据库缓存。

不能因为写了:

const getUser = cache(async (id: string) => {
  return fetchUser(id);
});

就推断以下事情一定成立:

  • 结果会跨所有请求永久复用;
  • 结果会自动失效;
  • 浏览器和 Node 端都能使用同样语义;
  • 错误会自动重试;
  • 不同认证上下文会自动隔离。

在传统 renderToPipeableStream SSR 中,工程上通常仍需明确设计请求作用域、缓存 key、TTL、失效和取消逻辑。

9.3 数据缓存

数据缓存可以跨请求复用,例如:

应用进程内缓存
Redis
数据库查询缓存
框架 fetch 缓存
CDN API 缓存

它需要明确:

  • 是否允许跨用户共享;
  • TTL 是多少;
  • 数据更新后如何失效;
  • 失败结果是否缓存;
  • 请求取消时是否影响共享任务;
  • 缓存的是最终数据还是进行中的 Promise。

特别要谨慎缓存失败结果。短暂的上游超时如果被缓存 60 秒,会把瞬时故障放大成持续故障。更常见的策略是:

成功结果:按正常 TTL 缓存
客户端参数错误:可短暂缓存或不缓存
上游 5xx/超时:短 TTL,或直接不缓存
权限错误:按用户上下文处理,不进入公共缓存

9.4 HTTP 和 CDN 缓存

流式 HTML 可以被 HTTP 服务发送,但“可以流式发送”不等于“适合公共缓存”。

个性化页面通常应使用:

Cache-Control: private, no-store

公共页面才可能使用:

Cache-Control: public, s-maxage=60, stale-while-revalidate=300

但公共缓存仍需要考虑:

  • HTML 中是否含用户信息;
  • 是否包含 CSRF token;
  • 是否包含地区、语言或实验分组;
  • CDN 是否会缓冲响应;
  • CDN 是否支持分块传输;
  • 缓存的是完整响应还是在 Shell 阶段就开始缓存。

如果页面包含登录用户名,却返回 public,问题不是“缓存命中率不高”,而是可能把一个用户的 HTML 返回给另一个用户。


十、缓存和流式发送的时序冲突

考虑如下流程:

1. 请求到达
2. 从缓存读取商品数据
3. React 开始渲染
4. onShellReady
5. 返回 HTTP 200
6. 推荐数据查询失败

第 5 步之后,响应状态和大部分响应头通常已经提交。第 6 步即使发生严重错误,也只能:

  • 记录错误;
  • 在边界内显示失败状态;
  • 让客户端尝试恢复;
  • 结束或中止流。

因此,决定页面整体 HTTP 语义的数据不能全部放在 Shell 之后异步获取。例如:

  • 权限;
  • 是否存在;
  • 是否需要重定向;
  • 是否应返回 401 或 404;
  • 页面是否允许公共缓存。

一种常见做法是先执行路由级预检查:

const routeData = await loadRouteDecision(request);

if (routeData.kind === "redirect") {
  return redirect(routeData.location);
}

if (routeData.kind === "not-found") {
  return notFoundResponse();
}

// 确定可以返回页面后,再启动 React 流式渲染

这样做会增加首字节前的等待,但避免把本应通过 HTTP 表达的状态错误地降级成页面内部文案。


十一、断流:什么叫“恢复”

11.1 断流的来源

流可能因为以下原因中断:

  • 用户关闭页面;
  • 移动网络切换;
  • 代理或 CDN 超时;
  • Node 进程重启;
  • 服务端调用 abort()
  • 上游请求超时;
  • 客户端 JavaScript 下载失败;
  • 代理缓冲或连接限制。

服务端需要区分:

服务端主动取消
客户端提前断开
上游数据失败
代理层切断
浏览器解析失败

这些故障的处理方式不同,不能都归类为“接口报错”。

11.2 abort() 的作用

const { abort } = renderToPipeableStream(<App />, options);

// 超时
setTimeout(() => {
  abort();
}, 10_000);

abort() 会停止 React 继续完成服务端渲染。它不等于:

  • 杀死 Node 进程;
  • 自动取消所有数据库查询;
  • 自动撤销已经发送的字节;
  • 自动重新连接浏览器;
  • 自动重试失败 API。

因此必须把 React 的取消信号传给下游操作:

const controller = new AbortController();

fetch(url, {
  signal: controller.signal,
});

// 断流或超时时
controller.abort();

如果数据库驱动或 RPC 客户端不支持取消,React 停止生成后,数据库任务可能仍然继续消耗资源。

11.3 React 能否从断点继续传输

通常不能。React 流式 SSR 没有通用的“从第 N 个 HTML 字节继续下载”的断点续传协议。浏览器和服务器之间的 TCP 连接断开后,已经传输的 HTML 也不会由 React 自动确认和重放。

所谓“断流恢复”通常指另一种恢复:

  1. 浏览器已经收到部分 Shell;
  2. 服务端剩余渲染被中止或某个边界失败;
  3. 客户端 JavaScript 启动;
  4. React 在浏览器中重新尝试渲染失败或未完成的边界;
  5. 数据请求成功后,边界恢复为真实内容。

这是一种客户端重新渲染恢复,不是网络层的断点续传。

11.4 真正的网络重连需要应用协议

如果业务要求“断网后继续接收服务器生成结果”,需要额外设计:

  • SSE;
  • WebSocket;
  • 带事件 ID 的重放接口;
  • 客户端保存已接收片段;
  • 服务端按请求 ID 保存状态;
  • 幂等和过期策略。

这已经超出 React SSR 的职责。React SSR 只负责一次文档响应中的组件流,不负责跨连接保存和恢复任意渲染状态。


十二、超时策略:为什么不能只设置一个全局超时

设定全局超时:

setTimeout(() => abort(), 10_000);

是必要但不充分的。因为“10 秒”可能同时影响三个不同阶段:

  1. Shell 生成超时;
  2. 某个 Suspense 边界超时;
  3. 客户端已经断开后的资源清理。

更精细的策略是:

路由决策超时:必须快速确定 404、权限、重定向
Shell 超时:超时则返回完整错误页
边界数据超时:显示该区域错误或允许客户端重试
客户端断开:立即取消所有可取消的服务端任务

例如:

async function withTimeout<T>(
  promise: Promise<T>,
  ms: number,
): Promise<T> {
  let timer: NodeJS.Timeout | undefined;

  try {
    return await Promise.race([
      promise,
      new Promise<T>((_, reject) => {
        timer = setTimeout(() => {
          reject(new Error(`Timeout after ${ms}ms`));
        }, ms);
      }),
    ]);
  } finally {
    if (timer) clearTimeout(timer);
  }
}

这个函数只会让 Promise 失败,并不会自动取消底层 fetch。真正需要释放网络资源时,仍应结合 AbortController


十三、一个完整的故障路径

假设页面结构如下:

<Header />
<Suspense fallback={<ProductSkeleton />}>
  <ProductList />
</Suspense>
<Suspense fallback={<ReviewSkeleton />}>
  <Reviews />
</Suspense>

并且:

  • Header 立即完成;
  • ProductList 100 ms 后完成;
  • Reviews 失败;
  • Shell 在 20 ms 生成。

时序可能是:

0 ms     开始服务端渲染
20 ms    Shell 就绪,发送 Header 和两个 fallback
100 ms   发送 ProductList 的真实内容
150 ms   Reviews 请求失败
150 ms   onError 记录错误
浏览器   Reviews 区域保持错误 fallback,或在客户端重新渲染

此时最合理的结果通常不是整页 500,而是:

  • HTTP 层保持已经发送的状态;
  • 商品列表正常显示;
  • 评论区显示“评论暂时不可用”;
  • 客户端提供“重试”按钮。

如果评论属于页面核心内容,例如支付金额或权限信息,则不应把它放在允许静默失败的边界中。应在发送 Shell 前完成必要校验,或者让整个路由返回错误。


十四、常见错误实现及其失败原因

14.1 把所有内容包在一个 Suspense

<Suspense fallback={<FullPageLoading />}>
  <EntirePage />
</Suspense>

这会让整个页面成为一个不可分割的等待单元。即使导航栏和页面标题已经可用,也会一起被 fallback 阻塞。

14.2 以为 Suspense 会捕获所有错误

<Suspense fallback={<Loading />}>
  <ComponentThatThrowsError />
</Suspense>

Suspense 主要处理 Promise 挂起,不是通用错误边界。普通异常需要错误边界或 SSR 回调。

14.3 在响应开始后修改状态码

onShellReady() {
  res.statusCode = 200;
  pipe(res);
},

onError() {
  res.statusCode = 500;
}

如果 pipe 已经写出了内容,后面的 res.statusCode = 500 通常已经无效。正确做法是:

  • 在 Shell 前完成会影响 HTTP 语义的检查;
  • didErroronShellReady 前确定初始状态;
  • Shell 后使用页面内错误状态,不假装可以修改 HTTP 状态。

14.4 使用跨请求全局数据缓存

const cache = new Map<string, unknown>();

如果 key 没有用户、租户和语言维度,可能导致数据串用户。即使没有数据泄露,也可能发生租户之间的错误复用。

14.5 客户端和服务端初始数据不一致

服务端渲染的是:

商品 A、商品 B

客户端第一次渲染却拿到:

商品 A、商品 C

就可能出现 hydration mismatch。原因包括:

  • 数据在两次请求之间更新;
  • 服务端使用了用户 Cookie,客户端请求未带上;
  • 时间、随机数或地区环境不同;
  • 服务端和客户端缓存 key 不一致。

需要通过服务端数据快照、稳定请求参数或接受明确的客户端重新渲染边界来处理,而不是简单忽略警告。

14.6 代理缓冲导致“流式”看起来不流式

应用服务已经调用 pipe,但浏览器仍然等到几秒后才看到内容,常见原因是:

  • 反向代理缓冲;
  • CDN 聚合小分块;
  • 压缩层等待更多数据;
  • Node 响应没有及时 flush;
  • 网络链路对小块响应进行合并。

诊断时可以:

curl -N -D - http://localhost:3000/

观察响应头和内容是否逐步到达。-N 用于尽量关闭 curl 的输出缓冲,但不能绕过所有代理缓冲。还应分别直连应用服务和经过 CDN 的地址进行对比。


十五、生产诊断:从指标判断故障在哪一层

建议至少记录以下时间点:

request_start
route_data_ready
shell_ready
first_byte_written
first_suspense_segment
all_ready
response_finished
response_closed

由此可以区分:

Shell 很慢

request_start -> shell_ready 很长

说明慢点在:

  • 路由判断;
  • Shell 外部组件;
  • 未被 Suspense 包裹的同步数据;
  • 服务端 CPU 或线程池;
  • 上游请求阻塞了根树。

Shell 很快,但用户迟迟看不到内容

shell_ready 很快
浏览器显示很晚

更可能是:

  • 代理缓冲;
  • CDN 缓冲;
  • 压缩;
  • 网络传输问题;
  • 浏览器等待脚本或样式。

Shell 正常,某个边界始终 loading

说明该边界对应的 Promise:

  • 没有 resolve;
  • 没有 reject;
  • 被错误的缓存 Promise 永久复用;
  • 下游请求没有遵守取消或超时;
  • 客户端恢复时无法重新创建资源。

服务端完成,但 hydration 失败

重点检查:

  • 服务端与客户端的组件树是否一致;
  • 初始数据是否一致;
  • 是否使用随机数、当前时间或浏览器专属 API;
  • HTML 是否被代理或安全策略改写;
  • 客户端 bundle 是否加载成功。

十六、流式 SSR 的取舍

流式 SSR 的收益是更早发送可用内容,以及让不同异步区域独立完成;代价则是响应状态、缓存和错误语义更复杂。

可以用下面的判断来选择边界:

如果失败会决定 HTTP 状态码:
    尽量在 Shell 前完成

如果失败只影响页面局部:
    放入独立 Suspense + 客户端错误边界

如果内容允许公共复用:
    明确设计跨请求缓存和 Cache-Control

如果内容带用户身份:
    使用 private/no-store 或严格的变体缓存

如果客户端断开:
    abort React + abort 下游请求

如果要求跨连接恢复:
    额外设计 SSE/WebSocket/重放协议

最终应把 React 的职责边界看清楚:

  • Suspense 负责把“等待中的子树”隔离为 fallback;
  • 流式 SSR API 负责逐步生成和发送 HTML;
  • onShellReady 决定何时可以开始发送;
  • onShellError 处理无法生成初始页面的失败;
  • onError 提供服务端错误观测和状态记录;
  • abort() 停止继续服务端渲染;
  • 数据缓存、HTTP 缓存、重试和跨连接恢复仍需要应用或框架明确实现。

当 Shell、Suspense 边界、错误层级、缓存作用域和取消信号分别建模后,流式 SSR 就不再是“把 HTML 尽早写出去”的技巧,而是一套具有明确状态转换、故障路径和恢复边界的服务端渲染架构。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。