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 要解决什么问题
假设页面包含三个部分:
- 不依赖远程数据的导航栏;
- 需要 200 ms 查询的商品列表;
- 需要 2 s 查询的推荐模块。
如果服务端必须等待所有内容完成,响应时间至少接近:
在这个例子中,页面要等接近 2 s 才能开始发送。
流式 SSR 把页面拆成“可以立即发送的部分”和“需要等待的部分”。理想情况下:
其中:
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 onShellReady 和 onAllReady
Node.js 环境下,renderToPipeableStream 会返回一组回调:
const stream = renderToPipeableStream(<App />, {
onShellReady() {
// Shell 已经准备好,可以开始发送响应
},
onAllReady() {
// 整棵树都准备好,包括所有 Suspense 内容
},
onShellError(error) {
// Shell 本身无法生成
},
onError(error) {
// 记录服务端渲染过程中的错误
},
});
这两个回调的差异决定了服务器采用哪种发送策略:
- 在
onShellReady中pipe:使用流式 SSR; - 在
onAllReady中pipe:等待所有内容完成后再发送,更接近传统 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 同时承担两个职责:
- 渲染控制:子树未准备好时显示
fallback; - 传输切分:允许 Shell 先发送,子树完成后再补发。
<Suspense fallback={<Loading />}>
<SlowPanel />
</Suspense>
如果 SlowPanel 在服务端渲染期间抛出一个 Promise,React 会认为该子树暂时未完成:
function SlowPanel() {
const data = resource.read(); // 未完成时抛出 Promise
return <section>{data.title}</section>;
}
React 不会把这个 Promise 当成普通异常,而是:
- 暂停当前
Suspense子树; - 渲染
fallback; - 继续寻找其他可以完成的内容;
- 通过流先发送 Shell;
- Promise 完成后生成真实内容;
- 发送让浏览器替换 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/server的renderToPipeableStream;- 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 树供错误边界更新状态。因此:
- 服务端错误首先由
onError、onShellError等 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 --> [*]
需要特别注意三种不同的“完成”:
- ShellReady:可以发送初始页面;
- Completed:所有服务端内容都已经流出;
- ClientRecovery:部分内容由浏览器重新渲染,不能等同于服务端完成。
八、renderToPipeableStream 与 renderToReadableStream
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 时,应根据运行时提供的 Request、Response 和取消信号编写代码。
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 自动确认和重放。
所谓“断流恢复”通常指另一种恢复:
- 浏览器已经收到部分 Shell;
- 服务端剩余渲染被中止或某个边界失败;
- 客户端 JavaScript 启动;
- React 在浏览器中重新尝试渲染失败或未完成的边界;
- 数据请求成功后,边界恢复为真实内容。
这是一种客户端重新渲染恢复,不是网络层的断点续传。
11.4 真正的网络重连需要应用协议
如果业务要求“断网后继续接收服务器生成结果”,需要额外设计:
- SSE;
- WebSocket;
- 带事件 ID 的重放接口;
- 客户端保存已接收片段;
- 服务端按请求 ID 保存状态;
- 幂等和过期策略。
这已经超出 React SSR 的职责。React SSR 只负责一次文档响应中的组件流,不负责跨连接保存和恢复任意渲染状态。
十二、超时策略:为什么不能只设置一个全局超时
设定全局超时:
setTimeout(() => abort(), 10_000);
是必要但不充分的。因为“10 秒”可能同时影响三个不同阶段:
- Shell 生成超时;
- 某个 Suspense 边界超时;
- 客户端已经断开后的资源清理。
更精细的策略是:
路由决策超时:必须快速确定 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立即完成;ProductList100 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 语义的检查;
- 用
didError在onShellReady前确定初始状态; - 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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 微前端:路由、状态、样式、依赖隔离和迁移取舍
- 下一篇:React 水合诊断:不一致、事件绑定、客户端边界和调试
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论