React 流式渲染
目录
- 为什么要流式渲染
- 整体图景:从一次性 HTML 到渐进式字节流
- 核心 API 对照
- 纯 React(Node):renderToPipeableStream 详解
- 纯 React(Edge / 浏览器环境):renderToReadableStream
- Suspense 与「外壳 / 全部就绪」
- 水合(Hydration)与脚本注入
- 完整可运行示例:Express + React 18
- Next.js App Router:默认流式与写法约定
- 纯 React vs Next.js:职责划分
- 常见问题与排错
- 延伸阅读
为什么要流式渲染
传统 renderToString 必须等整棵树(在同步子树意义上)可序列化后,才一次性吐出完整 HTML。若某处数据慢(数据库、下游 API),首字节时间(TTFB) 会被拖长,用户长时间面对白屏。
流式渲染把 React 树变成 分块输出的 HTML 字节流:先发出 外壳(shell)——布局、静态内容、加载占位——再在异步边界就绪后继续往同一响应里 追加片段(常配合 <Suspense>)。用户更快看到「页面骨架」,感知性能更好。
| 方式 | TTFB 特征 | 适用 |
|---|---|---|
renderToString | 通常要等整页数据就绪 | 简单页、全同步数据 |
renderToPipeableStream / renderToReadableStream | 可先发出 shell,再流式补全 | 有慢数据、希望渐进展示 |
| 纯 CSR | 首包小,但内容晚 | 后台工具、SEO 弱场景 |
整体图景:从一次性 HTML 到渐进式字节流
时间轴直觉(同一次 HTTP 响应内):
核心 API 对照
| API | 运行环境 | 输出类型 | 典型用途 |
|---|---|---|---|
renderToString | Node | 字符串 | 简单 SSR、邮件模板 |
renderToPipeableStream | Node(stream 模块) | Node Writable | Express / Fastify / 自建 Node 服务 |
renderToReadableStream | Web Streams 环境 | ReadableStream | Cloudflare Workers、Deno、部分 Edge |
hydrateRoot(React 18+) | 浏览器 | — | 对流式产生的 HTML 做选择性水合 |
React 官方包名:服务端 API 在 react-dom/server(Node 入口为 react-dom/server.node,打包工具通常会解析正确)。
纯 React(Node):renderToPipeableStream 详解
回调语义:onShellReady vs onAllReady
onShellReady:外壳(含 fallback)已就绪,应在此处pipe(response),让浏览器尽早开始下载 HTML。onAllReady:全部挂起的边界都完成;若你在此处才 pipe,行为会接近「等一整页」——失去流式首包优势,仅在有明确需求时使用。onShellError:外壳渲染失败(例如根组件同步抛错),应返回 500 兜底页,不要再对同一respipe。
最小注释示例(Express)
// server/stream.tsx —— 仅演示 API 形态,完整项目见下文「完整可运行示例」
import express from "express";
import React from "react";
import { renderToPipeableStream } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import App from "../src/App";
const app = express();
app.get("*", (req, res) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(
<StaticRouter location={req.url}>
<App />
</StaticRouter>,
{
// 浏览器端入口,会按顺序插入 <script defer>
bootstrapScripts: ["/assets/client.js"],
onShellReady() {
res.statusCode = didError ? 500 : 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
// 从这里开始把 HTML 流写入响应;后续 Suspense 完成会追加 chunk
pipe(res);
},
onShellError(err) {
console.error("shell error", err);
res.statusCode = 500;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end("<!doctype html><p>Server error</p>");
},
onError(error) {
didError = true;
// 边界内错误:流可能已开始,记录日志即可;是否向用户展示由产品设计决定
console.error("stream render error", error);
},
}
);
// 客户端断开时中止渲染,释放资源
res.on("close", () => abort());
});
app.listen(3000);
要点:
pipe(res)只调一次,放在onShellReady是官方推荐的主路径。bootstrapScripts与bootstrapModules用于注入客户端 bundle,保证流式 HTML 与水合脚本一致。- 使用
res.on('close', abort)避免用户取消请求后服务端仍算力空转。
纯 React(Edge / 浏览器环境):renderToReadableStream
在提供 Web Streams 的运行时(如部分 Edge Runtime),使用 renderToReadableStream,再把 ReadableStream 交给 new Response(stream) 返回。
// 伪代码:适配 fetch handler 形态
import { renderToReadableStream } from "react-dom/server.browser"; // 具体路径以构建工具解析为准
import App from "./App";
export default {
async fetch() {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ["/client.js"],
});
return new Response(stream, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
},
};
注意:不同打包器对 react-dom/server.browser 的解析不同,需查阅当前 React 版本文档;Node 服务请优先用 renderToPipeableStream。
Suspense 与「外壳 / 全部就绪」
流式 SSR 的「分段」几乎总是通过 <Suspense fallback={...}> 声明异步边界:
- 边界 外 的同步内容进入 第一屏 shell。
- 边界 内 若在服务端尚未就绪,则先输出 fallback 的 HTML。
- 就绪后 React 向同一文档流 插入后续 HTML(内部依赖流式协议与注释占位,无需手写)。
重要区分:
| 栈 | Suspense 内常见写法 | 说明 |
|---|---|---|
纯 React + react-dom/server | React.lazy 动态导入、use(React 19)配合可挂起数据源 | 不默认支持随意写 async function 组件当 Server Component;需框架或自建 RSC |
| Next.js App Router | async function 直接 await fetch | 框架把 Server Component 与流式响应接好 |
下面用 同步占位组件 表达边界形状(数据仍建议在 loader 或 Next 的 async 页面里获取):
// components/Page.tsx —— 概念示意:边界外先出,边界内后补
import React, { Suspense } from "react";
function SlowListFromProps({ items }: { items: { id: string; title: string }[] }) {
return (
<ul>
{items.map((x) => (
<li key={x.id}>{x.title}</li>
))}
</ul>
);
}
export function Page({ initialItems }: { initialItems: { id: string; title: string }[] }) {
return (
<main>
<h1>立即出现的标题</h1>
<Suspense fallback={<p>列表加载中…</p>}>
{/* 流式价值更多体现在「边界内会挂起」的子树;纯 Express 栈可配合 lazy/use;Next 中常用 async 子组件 */}
<SlowListFromProps items={initialItems} />
</Suspense>
</main>
);
}
说明:下一节 完整示例 使用 React.lazy + Suspense,在纯 Node 流式 SSR 下行为确定;Next.js 下 async 页面/组件 的写法见下文专节。
水合(Hydration)与脚本注入
服务端(或流式)渲染得到的是 没有 React 运行时 attached 的静态 HTML:用户能看见字,但 按钮不会点、状态不会变。水合(Hydration) 指在浏览器里执行客户端 JS 后,React 在已有 DOM 上「认领」节点:把虚拟树与真实 DOM 对齐,再挂上事件、状态与副作用,页面才变成可交互应用。
英文 Hydration 常直译为「水合」:把「干的」HTML 泡进 React 这棵「水」里,让组件真正活过来。
水合是什么
| 阶段 | 谁在做 | 用户看到什么 |
|---|---|---|
| SSR / 流式输出 | 服务器用 react-dom/server 生成 HTML | 首屏内容、SEO 可见 |
| 浏览器解析 | 原生 HTML 解析器构建 DOM | 仍 不可交互(无 React 事件) |
| 加载 bundle | 下载 client.js | 可能仍不可交互 |
| 水合 | hydrateRoot(container, <App />) | React 复用已有节点,绑定交互 |
没有水合、只有 SSR 时,页面像一张「印好的纸」;水合后,这张纸被 React 接管成活的 UI。
原理:从静态 DOM 到可交互
水合阶段,React 大致做三件事:
- 比对:用与 SSR 同一份组件树(同一套
App结构)在客户端再渲染一遍,生成虚拟树。 - 复用 DOM:尽量 不销毁服务端已经输出的 DOM,而是把 Fiber 与现有节点 关联 起来(与「从零
createRoot再画一遍」不同)。 - 激活:为对应 DOM 绑定事件监听器、
useEffect,useState的初始值与首屏一致,后续更新才完全在客户端驱动。
若 首屏 HTML 与客户端第一次渲染结果不一致(文本不同、标签层级不同、多了少了节点),会出现 hydration mismatch 警告,严重时 React 会丢弃服务端 DOM 再客户端重画,既伤性能又伤体验。
hydrateRoot 与 createRoot
| API | 场景 | 对已有 DOM |
|---|---|---|
createRoot(container).render(<App />) | 纯 CSR:容器里是空的或你要整树替换 | 清空/重建 为主 |
hydrateRoot(container, <App />)(React 18+) | 容器里 已有 SSR 输出的 HTML | 尽量复用 已有节点 |
流式 SSR、传统 renderToString SSR,只要首屏是服务端生成的,客户端入口都应使用 hydrateRoot(Next.js 等框架会替你调用等价逻辑)。
怎么用(React 18+)
- 服务端(或 HTML 模板)里保证有 固定根节点,例如
<div id="root">...</div>,且内部结构与hydrateRoot时传入的App首屏输出一致。 - 客户端入口只负责:
import与 SSR 相同的App(及 Router 等包装),调用hydrateRoot(document.getElementById('root')!, <App />)。 - 避免首屏
Date.now()/Math.random()/ 浏览器专有 API 导致与服务端渲染结果不一致;useEffect里再读浏览器环境是常见写法。
// client/entry-client.tsx —— 与下文「路由示例」一致:根组件树须与 StaticRouter 那次 SSR 对齐
import React from "react";
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
hydrateRoot(
document.getElementById("root")!,
<BrowserRouter>
<App />
</BrowserRouter>
);
最小示例:两端同一棵 App 树
下面用 renderToString 代替流式,把注意力只放在 「同一 App → 先 HTML 再 hydrate」;流式场景下 水合这一步完全相同,只是 HTML 到达方式变成 chunk。
共用的 App.tsx(服务端与客户端 import 同一份):
// App.tsx —— 首屏在服务端与客户端必须渲染出相同结构
import React, { useState } from "react";
export default function App() {
const [n, setN] = useState(0);
return (
<div id="app-root">
<p>计数: {n}</p>
{/* 水合前点击无效;水合后 +1 生效 */}
<button type="button" onClick={() => setN((x) => x + 1)}>
+1
</button>
</div>
);
}
服务端:生成带 #root 的 HTML 字符串:
// server-minimal.ts
import express from "express";
import React from "react";
import { renderToString } from "react-dom/server";
import App from "./App";
const app = express();
app.get("/", (_req, res) => {
const body = renderToString(<App />);
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(`<!DOCTYPE html>
<html><head><title>SSR+Hydrate</title></head>
<body>
<div id="root">${body}</div>
<script type="module" src="/client.js"></script>
</body></html>`);
});
app.listen(3000);
客户端:对 同一个 #root 里的水合:
// client-minimal.tsx
import React from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./App";
hydrateRoot(document.getElementById("root")!, <App />);
要点:renderToString(<App />) 放进 #root 的内容,必须与 hydrateRoot(..., <App />) 第一次渲染结果一致;上面 useState(0) 在两端首屏都是 0,因此能稳定水合。
与流式 SSR 的关系
- 水合时机:不论 HTML 是
renderToString一次性到达 还是renderToPipeableStream分块到达,只要浏览器解析出完整 DOM(含 Suspense 后续片段),bundle 执行后仍是一次hydrateRoot。 - 流式多出来的要求:后到的 HTML chunk 会插入文档,整棵树的最终结构仍须与客户端路由、组件树一致;bootstrap 脚本须在合适时机加载,避免在水合完成前用户操作导致状态与 DOM 错位。
- React 18 选择性水合:流式 + Suspense 时,React 可对 已就绪子树 分批水合,不必等整页 HTML 全部到齐(框架层细节以官方文档为准)。
脚本注入(bootstrapScripts)
流式输出的 HTML 与客户端 必须同源同结构,否则 hydrateRoot 会告警或退化为整树重绘。
| 项 | 建议 |
|---|---|
| 根节点 | 服务端与客户端用相同 id 的容器,例如 <div id="root"> |
| 入口 | hydrateRoot(document.getElementById("root")!, <BrowserRouter><App/></BrowserRouter>) |
| 随机/时间 | 首屏避免服务端与客户端不一致的 Date.now()、随机 id;key={Math.random()} 禁止 |
| 脚本 | 使用 renderToPipeableStream 的 bootstrapScripts 或手动插入与 pipe 顺序兼容的 defer/type="module" 脚本 |
bootstrapScripts: ['/assets/client.js'] 的作用:在流式管道里按约定插入 带 defer 的 script,保证用户看到 HTML 后尽快加载水合代码,且顺序与官方流式实现兼容。
完整可运行示例:Express + React 18
下面示例 刻意使用 React.lazy + Suspense,在 Node renderToPipeableStream 下可稳定演示「先 fallback、后补内容」的流式行为(不依赖框架自带的 Server Components)。
目录结构(建议):
project/
server/index.tsx # Express + renderToPipeableStream
src/App.tsx # 路由与 Suspense
src/LazyDashboard.tsx # lazy 动态导入
client/entry-client.tsx
package.json
src/LazyDashboard.tsx(被懒加载的「慢」模块):
import React, { useEffect, useState } from "react";
export function LazyDashboard() {
const [text, setText] = useState("客户端水合中…");
useEffect(() => {
setText("已水合,可交互");
}, []);
return (
<section>
<h2>Dashboard</h2>
<p>{text}</p>
</section>
);
}
src/App.tsx:
import React, { Suspense, lazy } from "react";
import { Routes, Route, Link } from "react-router-dom";
// React.lazy 在 SSR 流式场景下会触发 Suspense 边界 → 先输出 fallback HTML
const LazyDashboard = lazy(() => import("./LazyDashboard"));
function Home() {
return <p>首页:同步内容,会立刻出现在第一 chunk。</p>;
}
export default function App() {
return (
<div>
<nav>
<Link to="/">首页</Link> | <Link to="/dashboard">Dashboard</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/dashboard"
element={
<Suspense fallback={<p>Dashboard 模块流式加载中…</p>}>
<LazyDashboard />
</Suspense>
}
/>
</Routes>
</div>
);
}
server/index.tsx(核心:流式 pipe 到 Express res):
import path from "path";
import express from "express";
import React from "react";
import { renderToPipeableStream } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import App from "../src/App";
const app = express();
const port = 3000;
// 静态资源:构建后将 client bundle 输出为 /assets/client.js
app.use("/assets", express.static(path.join(__dirname, "../dist/client")));
app.get("*", (req, res) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(
<StaticRouter location={req.url}>
<App />
</StaticRouter>,
{
// 与 Vite/webpack 构建产物路径对齐
bootstrapScripts: [`/assets/client.js`],
onShellReady() {
res.statusCode = didError ? 500 : 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
pipe(res);
},
onShellError(err) {
console.error(err);
res.statusCode = 500;
res.end("<!doctype html><p>Error</p>");
},
onError(err) {
didError = true;
console.error("boundary error", err);
},
}
);
res.on("close", () => abort());
});
app.listen(port, () => {
console.log(`streaming server http://localhost:${port}`);
});
本地联调提示:
- 用 Vite 或 webpack 分别打包
client/entry-client.tsx→dist/client/assets/client.js,server/index.tsx用 tsx 或 esbuild 编译运行。 - 打开 DevTools → Network → Doc,查看 HTML 响应为 chunked,且先后出现 fallback 与真实片段(不同浏览器展示方式略有差异)。
Next.js App Router:默认流式与写法约定
Next.js 13+ App Router 中,路由段默认支持流式 HTML:Server Components 异步数据与 Suspense 会触发 RSC Payload + HTML 流(具体协议由框架封装,概念上与「分段输出」一致)。
loading.tsx(路由级即时 fallback)
在某一 route segment 目录下增加 loading.tsx,等价于对该段 自动包一层 Suspense,用户导航时 立即看到加载 UI,同时服务端继续拉取该段的 RSC 数据。
// app/dashboard/loading.tsx
export default function Loading() {
return <p>Dashboard 路由加载中…</p>;
}
page.tsx 内异步组件 + Suspense(细粒度流式)
// app/dashboard/page.tsx
import { Suspense } from "react";
async function SlowStats() {
await new Promise((r) => setTimeout(r, 800));
return <aside>统计:今日 PV 1000+</aside>;
}
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<p>同步文案会先出现在流式 HTML 的前面部分。</p>
<Suspense fallback={<p>统计模块流式加载…</p>}>
<SlowStats />
</Suspense>
</main>
);
}
客户端组件边界
需要浏览器 API 或 useState 时,在文件顶加 'use client',并尽量 把客户端子树缩小,以减少送到浏览器的 JS 体积。
// app/dashboard/Counter.tsx
"use client";
import { useState } from "react";
export function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>计数 {n}</button>;
}
// app/dashboard/page.tsx(片段)
import { Counter } from "./Counter";
// Server Component 引用 Client Component:Next 会自动处理边界
// <Counter /> 会在流式响应中带有对应 client reference
与纯 React 自建栈的差异(简要)
| 维度 | 纯 React + Express | Next.js App Router |
|---|---|---|
| 流式入口 | 手写 renderToPipeableStream + pipe | 框架内置 |
| 异步组件 | 需自建 RSC 或仅用 lazy/数据模式 | async Server Component 一等公民 |
| 路由与数据 | 自接 React Router + loader | 文件系统路由 + fetch 缓存规则 |
| 构建与水合 | 自建 Vite/webpack 双端 | next build 一体化 |
纯 React vs Next.js:职责划分
- 选纯 React:需要完全可控的 Node 层、嵌入现有后端、或教学理解原理。
- 选 Next.js:希望默认流式、RSC、路由与优化策略开箱即用,团队以约定换效率。
常见问题与排错
| 现象 | 可能原因 | 处理 |
|---|---|---|
| 白屏久、流式无效果 | 在 onAllReady 才 pipe,或根上无 Suspense | 改为 onShellReady pipe;为慢数据加边界 |
| hydrate 报错 | 服务端与客户端路由/数据不一致 | 统一 StaticRouter/BrowserRouter 路径与初始数据 |
| HTML 错乱 | 中间层缓冲整页 | 关闭反向代理对 HTML 的整页缓冲,启用 chunked |
| Edge 报错找不到 stream API | 误用 renderToPipeableStream | 换 renderToReadableStream 或跑在 Node |
延伸阅读
- 本站 服务端渲染:SSR 总览与传统
renderToString。 - 本站 [Server Components](../../前端/前端框架/react/React 19新特性/Server Components.md):RSC 概念与客户端边界(与 Next 强相关)。
版本说明:流式 API 以 React 18+ 为准;Next.js 示例以 App Router(app/)为准。升级大版本时请对照官方迁移指南核对 bootstrapScripts、错误边界与缓存语义。