跳到主要内容

React 流式渲染

目录

为什么要流式渲染

传统 renderToString 必须等整棵树(在同步子树意义上)可序列化后,才一次性吐出完整 HTML。若某处数据慢(数据库、下游 API),首字节时间(TTFB) 会被拖长,用户长时间面对白屏。

流式渲染把 React 树变成 分块输出的 HTML 字节流:先发出 外壳(shell)——布局、静态内容、加载占位——再在异步边界就绪后继续往同一响应里 追加片段(常配合 <Suspense>)。用户更快看到「页面骨架」,感知性能更好。

方式TTFB 特征适用
renderToString通常要等整页数据就绪简单页、全同步数据
renderToPipeableStream / renderToReadableStream可先发出 shell,再流式补全有慢数据、希望渐进展示
纯 CSR首包小,但内容晚后台工具、SEO 弱场景

整体图景:从一次性 HTML 到渐进式字节流

时间轴直觉(同一次 HTTP 响应内):

核心 API 对照

API运行环境输出类型典型用途
renderToStringNode字符串简单 SSR、邮件模板
renderToPipeableStreamNode(stream 模块)Node WritableExpress / Fastify / 自建 Node 服务
renderToReadableStreamWeb Streams 环境ReadableStreamCloudflare 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 兜底页,不要再对同一 res pipe。

最小注释示例(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);

要点:

  1. pipe(res) 只调一次,放在 onShellReady 是官方推荐的主路径。
  2. bootstrapScriptsbootstrapModules 用于注入客户端 bundle,保证流式 HTML 与水合脚本一致。
  3. 使用 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/serverReact.lazy 动态导入、use(React 19)配合可挂起数据源不默认支持随意写 async function 组件当 Server Component;需框架或自建 RSC
Next.js App Routerasync 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.jsasync 页面/组件 的写法见下文专节。

水合(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 大致做三件事:

  1. 比对:用与 SSR 同一份组件树(同一套 App 结构)在客户端再渲染一遍,生成虚拟树。
  2. 复用 DOM:尽量 不销毁服务端已经输出的 DOM,而是把 Fiber 与现有节点 关联 起来(与「从零 createRoot 再画一遍」不同)。
  3. 激活:为对应 DOM 绑定事件监听器、useEffectuseState 的初始值与首屏一致,后续更新才完全在客户端驱动。

首屏 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+)

  1. 服务端(或 HTML 模板)里保证有 固定根节点,例如 <div id="root">...</div>,且内部结构与 hydrateRoot 时传入的 App 首屏输出一致
  2. 客户端入口只负责:import 与 SSR 相同的 App(及 Router 等包装),调用 hydrateRoot(document.getElementById('root')!, <App />)
  3. 避免首屏 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()、随机 idkey={Math.random()} 禁止
脚本使用 renderToPipeableStreambootstrapScripts 或手动插入与 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}`);
});

本地联调提示

  1. Vitewebpack 分别打包 client/entry-client.tsxdist/client/assets/client.jsserver/index.tsxtsxesbuild 编译运行。
  2. 打开 DevTools → Network → Doc,查看 HTML 响应为 chunked,且先后出现 fallback 与真实片段(不同浏览器展示方式略有差异)。

Next.js App Router:默认流式与写法约定

Next.js 13+ App Router 中,路由段默认支持流式 HTMLServer 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 + ExpressNext.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误用 renderToPipeableStreamrenderToReadableStream 或跑在 Node

延伸阅读

  • 本站 服务端渲染:SSR 总览与传统 renderToString
  • 本站 [Server Components](../../前端/前端框架/react/React 19新特性/Server Components.md):RSC 概念与客户端边界(与 Next 强相关)。

版本说明:流式 API 以 React 18+ 为准;Next.js 示例以 App Routerapp/)为准。升级大版本时请对照官方迁移指南核对 bootstrapScripts、错误边界与缓存语义。