各位技术同仁,下午好!
今天,我们将深入探讨一个令人兴奋且极具潜力的前端性能优化技术——“Streaming SSR”,即流式服务端渲染。特别地,我们将聚焦于如何巧妙地利用 Web Streams API,在 HTML 传输过程中动态地插入 React Suspense 组件的 fallback 内容,从而显著提升用户体验和页面加载感知性能。
这是一个相对高级的话题,它融合了服务端渲染、React 并发特性以及底层的网络流处理。我们将从基础概念出发,逐步深入,辅以丰富的代码示例,力求将这一复杂机制阐述得清晰透彻。
一、SSR 的演进:从全量等待到渐进式呈现
在深入 Streaming SSR 之前,我们有必要回顾一下服务端渲染(SSR)的发展历程及其面临的挑战。
1.1 传统 SSR 的优势与局限
传统的 SSR 模式,无论是在 Node.js 环境下使用 ReactDOMServer.renderToString 还是 renderToStaticMarkup,其核心思想都是在服务器端将整个 React 应用渲染成一个完整的 HTML 字符串,然后一次性地发送给客户端。
优势:
- 首屏内容快速呈现 (FCP/LCP): 浏览器接收到完整的 HTML 后,可以直接解析和渲染,用户无需等待 JavaScript 加载和执行。
- SEO 友好: 搜索引擎爬虫可以直接抓取到完整的页面内容。
- 更好的无 JS 体验: 即使 JavaScript 加载失败或被禁用,用户也能看到基本页面内容。
局限性:
- 整体等待时间 (TTFB, TTVC, TTI): 服务器必须等待所有数据都加载完毕,才能开始渲染并返回完整的 HTML。这意味着即使页面的一部分内容已经准备好,用户也必须等待最慢的那部分数据。这会导致较长的“首次字节时间”(TTFB)和“首次有意义绘制”(FMP),以及“可交互时间”(TTI)。
- 水合阻塞 (Hydration Blocking): 当完整的 HTML 到达客户端后,React 需要重新在客户端“水合”(Hydrate)整个应用。水合过程是一个 CPU 密集型操作,它会遍历 DOM 树,附加事件监听器,并将客户端 React 状态与服务端渲染的 HTML 同步。在这个过程中,主线程会被阻塞,用户无法与页面进行交互,直到水合完成。
1.2 Streaming SSR 的崛起:解决痛点
为了解决传统 SSR 的“整体等待”和“水合阻塞”问题,Streaming SSR 应运而生。其核心思想是:不再等待所有数据就绪,而是将 HTML 分块,以流(Stream)的形式逐步发送给客户端。
这意味着:
- 更快的 TTFB 和 FCP: 服务器可以先发送页面“外壳”(shell)或骨架,包含页面的基本布局和不依赖异步数据的部分。用户可以更快地看到页面的基本结构。
- 渐进式水合 (Progressive Hydration): 结合 React 18+ 的并发特性和
Suspense,客户端可以对到达的 HTML 块进行局部水合,而不是等待整个页面。这允许用户在某些部分可交互时,另一些部分仍在加载或水合。 - 更好的用户体验: 用户在等待所有内容加载完成之前,就能看到页面内容并与部分元素进行交互,极大地提升了感知性能。
而 Web Streams API,正是实现 Streaming SSR 的关键技术基石。
二、Web Streams API 基础:数据流的利器
Web Streams API 提供了一种标准的方式来创建、组合和消费数据流。它允许你以异步、非阻塞的方式处理数据,这对于处理大量数据、网络通信以及我们今天的议题——流式传输 HTML——至关重要。
Web Streams API 主要围绕三种核心接口展开:
ReadableStream: 表示一个可读的数据源。数据可以从中读取。WritableStream: 表示一个可写的数据目的地。数据可以写入其中。TransformStream: 表示一个既可读又可写的数据转换器。它读取数据,对其进行处理,然后输出处理后的数据。
此外,还有一些辅助类:
TextEncoder: 将字符串转换为Uint8Array(字节数组)。TextDecoder: 将Uint8Array转换为字符串。Uint8Array: 表示一个 8 位无符号整数的数组,是处理二进制数据的基本单位。
2.1 ReadableStream 的基本使用
一个 ReadableStream 拥有一个底层的源(underlying source),它负责生成数据。你可以通过 new ReadableStream(underlyingSource) 来创建它。
// 示例:创建一个简单的 ReadableStream
const readableStream = new ReadableStream({
start(controller) {
// 当流被创建时调用
controller.enqueue(new TextEncoder().encode('Hello, '));
controller.enqueue(new TextEncoder().encode('Stream!'));
controller.close(); // 结束流
},
pull(controller) {
// 当流需要更多数据时调用 (可选,用于拉取模式)
},
cancel() {
// 当流被取消时调用
}
});
// 消费 ReadableStream
async function consumeStream() {
const reader = readableStream.getReader();
let result;
let fullString = '';
while (!(result = await reader.read()).done) {
fullString += new TextDecoder().decode(result.value);
}
console.log(fullString); // 输出: Hello, Stream!
}
consumeStream();
2.2 WritableStream 的基本使用
WritableStream 允许你将数据写入到某个目的地。
// 示例:创建一个简单的 WritableStream
const writableStream = new WritableStream({
start(controller) {
console.log('WritableStream started');
},
write(chunk) {
// 每次有数据写入时调用
console.log('Writing chunk:', new TextDecoder().decode(chunk));
},
close() {
console.log('WritableStream closed');
},
abort(reason) {
console.error('WritableStream aborted:', reason);
}
});
async function writeToStream() {
const writer = writableStream.getWriter();
await writer.write(new TextEncoder().encode('Data chunk 1'));
await writer.write(new TextEncoder().encode('Data chunk 2'));
await writer.close();
}
writeToStream();
2.3 TransformStream 的作用
TransformStream 是连接 ReadableStream 和 WritableStream 的桥梁。它有一个可写端(writable)和一个可读端(readable)。写入到 writable 的数据会经过转换逻辑,然后从 readable 端输出。
// 示例:创建一个将文本转换为大写的 TransformStream
class UppercaseTransformStream extends TransformStream {
constructor() {
let buffer = '';
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
super({
// TransformStream 的转换逻辑在 transform 方法中定义
transform(chunk, controller) {
buffer += textDecoder.decode(chunk, { stream: true }); // 累积数据
// 假设我们按行处理,这里简化为直接转换
const transformedChunk = buffer.toUpperCase();
controller.enqueue(textEncoder.encode(transformedChunk));
buffer = ''; // 处理后清空
},
flush(controller) {
// 流结束时处理剩余的 buffer
if (buffer.length > 0) {
controller.enqueue(textEncoder.encode(buffer.toUpperCase()));
}
}
});
}
}
async function useTransformStream() {
const sourceStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('hello '));
controller.enqueue(new TextEncoder().encode('world!'));
controller.close();
}
});
const uppercaseStream = new UppercaseTransformStream();
// 连接流:sourceStream -> uppercaseStream -> consumer
const reader = sourceStream.pipeThrough(uppercaseStream).getReader();
let result;
let fullString = '';
while (!(result = await reader.read()).done) {
fullString += new TextDecoder().decode(result.value);
}
console.log(fullString); // 输出: HELLO WORLD!
}
useTransformStream();
通过这些基础,我们已经为理解 Streaming SSR 奠定了基石。
三、Streaming SSR 的核心机制:renderToPipeableStream
React 18 引入了 ReactDOMServer.renderToPipeableStream,这是实现 Streaming SSR 的核心 API。它不再返回一个字符串,而是返回一个包含 pipe 方法的对象,这个 pipe 方法可以将 React 渲染的 HTML 内容以流的形式写入到任何 WritableStream。
3.1 renderToPipeableStream 的工作原理
renderToPipeableStream 函数签名大致如下:
import { renderToPipeableStream } from 'react-dom/server';
const { pipe, abort } = renderToPipeableStream(
<App />, // 你的 React 根组件
{
onShellReady() {
// 当页面的“外壳”(即不含 Suspense 内部异步内容的骨架 HTML)准备就绪时调用。
// 此时可以设置响应头并开始将流发送给客户端。
},
onShellError(error) {
// 当外壳渲染过程中发生错误时调用。
// 此时可以发送一个错误页面。
},
onAllReady() {
// 当所有内容(包括所有 Suspense 边界内的异步内容)都准备就绪时调用。
// 在某些场景下,你可能希望等待所有内容就绪再发送,但这样就失去了流的优势。
// 通常,我们会结合 onShellReady 使用。
},
onError(error) {
// 在渲染流的任何阶段发生错误时调用。
console.error('Streaming error:', error);
}
// 其他选项,如 bootstrapScripts, bootstrapModules, nonce 等
}
);
pipe 方法接收一个 Node.js Writable 流(例如 res 对象,即 HTTP 响应流)作为参数,并将 React 渲染的 HTML 块写入其中。
关键回调函数:
onShellReady: 这是我们最常使用的回调。一旦 React 能够渲染出页面的最外层结构(即不依赖任何Suspense内部异步数据的部分),它就会触发这个回调。此时,服务器应该立即设置 HTTP 响应头(Content-Type: text/html),然后调用pipe方法将流开始发送给客户端。onShellError: 如果在渲染页面外壳时发生错误,此回调会被触发。我们可以借此机会发送一个备用的错误页面,而不是等待整个流失败。onAllReady: 在所有Suspense边界内的异步数据都加载完毕,并且所有内容都渲染完成时触发。如果你的应用需要确保所有内容都加载完毕才开始传输,可以使用这个。但通常,为了利用流式传输的优势,我们会更早地开始传输(即在onShellReady中)。
3.2 Streaming SSR 流程概览
- 客户端发起请求: 浏览器向服务器请求页面。
- 服务器处理请求:
- 调用
renderToPipeableStream(<App />, { ... })。 - 当
onShellReady触发时,服务器设置Content-Type: text/html,res.statusCode = 200,然后调用pipe(res)将 React 生成的 HTML 流连接到 HTTP 响应流。 - React 会首先发送页面的“外壳”HTML。
- 当遇到
<Suspense>组件时,如果其内部组件的数据尚未准备好,React 会立即发送fallback内容以及一个<script>标签,用于客户端水合和替换。 - 当
Suspense内部组件的数据准备好时,React 会发送其真实的 HTML 内容,以及另一个<script>标签,指示客户端用真实内容替换之前发送的 fallback。
- 调用
- 客户端接收并渲染:
- 浏览器接收到第一个 HTML 块(外壳),并开始解析和渲染。
- 当接收到带有
Suspensefallback 的 HTML 块时,浏览器显示 fallback。 - 客户端的 React 水合器会逐步处理这些 HTML 块。当接收到后续的真实内容块时,React 会在客户端用真实内容替换掉 DOM 中对应的 fallback。
这个过程是渐进式的,用户可以更快地看到内容并与部分页面进行交互。
四、深入 Suspense 与 Streaming SSR 的协同
Suspense 是 React 并发模式的核心特性之一,它允许组件“暂停”渲染,直到其所需数据准备就绪。在客户端,这意味着在数据加载期间显示一个 fallback UI。在 Streaming SSR 中,Suspense 的作用被极大地扩展。
4.1 Suspense 在客户端和服务器端的行为对比
| 特性 | 客户端行为 | 服务器端行为 (Streaming SSR) |
|---|---|---|
| 数据加载 | 异步数据加载,如 fetch、Promise 等。 |
同样进行异步数据加载。 |
| 渲染中断 | 当子组件抛出 Promise 时,Suspense 捕获并显示 fallback。 |
当子组件抛出 Promise 时,renderToPipeableStream 捕获。 |
| HTML 输出 | 在数据就绪前不渲染子组件,仅显示 fallback。 |
立即发送 fallback HTML。 |
| 后续内容 | 数据就绪后,重新渲染子组件,替换 fallback。 |
数据就绪后,发送包裹在 <template> 和 <script> 标签内的真实内容 HTML。 |
| 交互性 | 在数据就绪前,fallback 区域不可交互。 |
服务器发送的 fallback 区域在客户端可交互(如果 fallback 本身有交互性),但其内部的实际组件在数据到达前不可交互。 |
| 水合机制 | ReactDOM.createRoot().render() 或 ReactDOM.hydrateRoot()。 |
ReactDOM.hydrateRoot() 会监听服务器发送的 <script> 标签,并根据指令进行局部水合和内容替换。 |
4.2 React 如何在服务器端处理 Suspense 边界
当 renderToPipeableStream 遇到一个 <Suspense> 边界时,它会进行以下处理:
- 检查内部组件是否准备就绪: React 尝试渲染
Suspense内部的子组件。 - 如果子组件未就绪(例如,抛出了一个 Promise):
- React 会立即渲染
Suspense的fallback内容,并将其作为第一个 HTML 块发送出去。 - 同时,React 会生成一个特殊的
<script>标签,其中包含一些元数据(如id),指示客户端这是一个Suspense占位符。 - 服务器继续渲染页面的其他部分,特别是那些不依赖于此
Suspense边界的同步内容。
- React 会立即渲染
- 当子组件的数据最终准备就绪时:
- React 会再次渲染
Suspense内部的子组件,这次它能成功渲染出真实的 HTML 内容。 - 这些真实的 HTML 内容会被包裹在一个
<template>标签中,并紧跟一个<script>标签。这个<script>标签会告诉客户端 React 运行时:“嘿,那个 ID 为 X 的Suspense占位符,现在可以被这个<template>里的真实内容替换了!” - 这些 HTML 块会作为后续数据流的一部分发送给客户端。
- React 会再次渲染
示例 HTML 输出片段:
<!-- 页面外壳,头部等 -->
<div id="root">
<!-- ... 同步内容 ... -->
<!-- Suspense Fallback 部分,当异步组件未就绪时 -->
<div id="S:R1">
<!-- 这是 Suspense 的 fallback 内容 -->
<p>正在加载用户数据...</p>
</div>
<script>
// 这段脚本告诉客户端,ID 为 R1 的 Suspense 边界现在是 fallback 状态
// 客户端 React 运行时会识别它
// __webpack_require__.r("R1", ["/static/js/main.js"]); (简化表示)
// 实际会更复杂,包含 React 内部的指令
</script>
<!-- ... 其他同步内容 ... -->
</div>
<!-- 稍后,当异步数据加载完成后,服务器会发送以下内容 -->
<template id="T:R1">
<!-- 这是异步组件的真实内容 -->
<div>
<h2>欢迎, John Doe!</h2>
<p>您的邮箱是: [email protected]</p>
</div>
</template>
<script>
// 这段脚本告诉客户端,用 T:R1 模板中的内容替换 S:R1 区域
// 并且对新内容进行水合
// __webpack_require__.r("R1", ["/static/js/main.js"], true); (简化表示)
// 实际会包含 React 内部的 DOM 操作指令
</script>
客户端的 React 运行时 (ReactDOM.hydrateRoot) 会在接收到这些 <script> 标签时,自动解析它们并执行相应的 DOM 替换和水合操作。这个过程是高度优化的,并且是渐进式的,不会阻塞主线程。
五、动态插入 Suspense Fallback 的实践
现在,我们来探讨如何在实际应用中利用 Web Streams API 结合 Suspense 实现动态内容插入。这里主要有两种策略:
- 利用 React 内置的
Suspense和renderToPipeableStream机制(推荐且主流)。 - 自定义
TransformStream拦截和修改流(适用于更高级、非 React 控制的场景)。
5.1 方案一:利用 React 内置机制实现 Streaming SSR 与 Suspense
这是实现动态 Suspense fallback 的标准且推荐方式。你无需手动操作 HTML 流来插入 fallback,React 已经为你做了这一切。你只需要正确配置 React 组件和服务器。
场景设定: 我们有一个 React 应用,其中包含一个模拟异步加载的用户信息组件。我们希望在用户数据加载期间显示一个 Suspense fallback。
5.1.1 React 客户端组件 (src/App.js)
// src/App.js
import React, { Suspense } from 'react';
import './App.css';
// 模拟异步数据加载
const fetchData = (delay) => {
return new Promise(resolve => {
setTimeout(() => {
resolve({
name: 'Alice Smith',
email: '[email protected]',
id: Math.random().toString(36).substring(7)
});
}, delay);
});
};
// 创建一个用于 Suspense 的可抛出 Promise 的资源管理器
// 实际应用中会使用 react-query, swr, Relay 等库
let userResource = null;
const createUserResource = (delay) => {
if (!userResource) {
let suspender = fetchData(delay).then(data => {
userResource = {
read() {
return data;
}
};
});
userResource = {
read() {
throw suspender;
}
};
}
return userResource;
};
// 异步加载的用户信息组件
function UserInfo({ delay }) {
const user = createUserResource(delay).read(); // 这会触发 Suspense
return (
<div className="user-info">
<h3>用户详情 ({user.id})</h3>
<p>姓名: {user.name}</p>
<p>邮箱: {user.email}</p>
</div>
);
}
// 另一个模拟异步加载的评论组件
let commentsResource = null;
const createCommentsResource = (delay) => {
if (!commentsResource) {
let suspender = fetchData(delay).then(data => {
commentsResource = {
read() {
return [{ id: 1, text: `Comment 1 by ${data.name}` }, { id: 2, text: `Comment 2 by ${data.name}` }];
}
};
});
commentsResource = {
read() {
throw suspender;
}
};
}
return commentsResource;
};
function CommentsList({ delay }) {
const comments = createCommentsResource(delay).read();
return (
<div className="comments-list">
<h4>最新评论</h4>
<ul>
{comments.map(comment => (
<li key={comment.id}>{comment.text}</li>
))}
</ul>
</div>
);
}
function App() {
return (
<div className="App">
<h1>欢迎来到 Streaming SSR 演示!</h1>
<p>这是一个同步渲染的段落。</p>
<Suspense fallback={<div className="loading">加载用户数据中...</div>}>
<UserInfo delay={3000} /> {/* 模拟3秒加载 */}
</Suspense>
<p>这是一个在 Suspense 之间的同步内容。</p>
<Suspense fallback={<div className="loading">加载评论中...</div>}>
<CommentsList delay={1500} /> {/* 模拟1.5秒加载 */}
</Suspense>
<p>页面底部同步内容。</p>
</div>
);
}
export default App;
5.1.2 React 客户端入口 (src/index.js)
// src/index.js (客户端入口)
import React from 'react';
import { hydrateRoot } from 'react-dom/client'; // 导入 hydrateRoot
import App from './App';
// 使用 hydrateRoot 进行客户端水合
// hydrateRoot 会尝试将 React 组件挂载到服务端渲染的 DOM 结构上
// 并且能够处理 Suspense 发送的流式更新
const root = hydrateRoot(document.getElementById('root'), <App />);
5.1.3 Node.js 服务器 (server.js)
我们将使用 Express 作为服务器框架。
// server.js
import express from 'express';
import React from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import path from 'path';
import fs from 'fs';
import App from './src/App'; // 导入我们的 React App 组件
const app = express();
const PORT = 3000;
// 假设客户端打包后的静态文件在 /build 目录下
app.use(express.static(path.resolve(__dirname, 'build')));
// 读取客户端 HTML 模板
const templatePath = path.resolve(__dirname, 'build', 'index.html');
const htmlTemplate = fs.readFileSync(templatePath, 'utf-8');
app.get('/', (req, res) => {
res.setHeader('Content-Type', 'text/html');
let didError = false;
// renderToPipeableStream 返回一个包含 pipe 方法的对象
const { pipe, abort } = renderToPipeableStream(<App />, {
// 当页面外壳准备好时调用
onShellReady() {
// 如果外壳没有错误,发送状态码并开始传输 HTML
res.statusCode = didError ? 500 : 200;
// 分割模板,插入 React 渲染内容
const [header, footer] = htmlTemplate.split('<div id="root"></div>');
res.write(header); // 发送 HTML 头部
res.write('<div id="root">'); // 插入 React 根 div
// 将 React 渲染流连接到响应流
// 注意:res 对象是一个 Node.js WritableStream
pipe(res);
// 当 React 流结束时,发送 HTML 尾部
res.write('</div>');
res.write(footer);
},
// 当外壳渲染出错时调用
onShellError(err) {
console.error('Shell Error:', err);
didError = true;
res.statusCode = 500;
res.send('<!doctype html><p>Loading error...</p>'); // 发送一个简单的错误页面
},
// 当所有内容(包括所有 Suspense 边界内的异步内容)都准备就绪时调用
onAllReady() {
// 在此示例中,我们主要依赖 onShellReady 进行流式传输。
// 如果你只希望在所有内容都准备好才发送,可以将 pipe(res) 放在这里,
// 但这样会失去 Streaming SSR 的优势。
},
onError(err) {
didError = true;
console.error('Streaming Error:', err);
}
});
// 如果客户端在流完成之前断开连接,或者发生其他错误,可以中止流
req.on('close', () => {
abort();
});
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
构建步骤:
- 创建一个新的 React 项目 (
npx create-react-app streaming-ssr --template cra-template-pwa --use-npm) - 安装 Express (
npm install express) - 将
src/App.js和src/index.js替换为上述代码。 - 在项目根目录创建
server.js文件。 - 在
package.json中添加type: "module"以支持 ES Modules,或者使用 Babel/Webpack 配置 Node.js 服务器。为了简单起见,我们直接使用 ESM。 - 运行
npm run build打包客户端应用。 - 运行
node server.js启动服务器。
运行效果:
当你访问 http://localhost:3000 时,你会立即看到页面的头部、同步内容和两个 Suspense 的 fallback (加载用户数据中... 和 加载评论中...)。大约 1.5 秒后,加载评论中... 会被评论列表替换。再过 1.5 秒(总共 3 秒),加载用户数据中... 会被用户详情替换。整个过程是渐进的,用户可以实时看到内容的更新。
Web Streams API 在哪里发挥作用?
虽然我们没有直接实例化 ReadableStream 或 TransformStream,但 res 对象(Express 的响应对象)本身就是一个 Node.js 的 WritableStream。renderToPipeableStream 的 pipe 方法正是将其内部的 ReadableStream 连接到了这个 WritableStream。
React 18 在服务器端生成 HTML 块,这些块被推送到一个内部的 ReadableStream。然后,这个流通过 pipe(res) 方法被读取,并将数据块写入到 HTTP 响应中,从而实现了流式传输。
5.2 方案二:自定义 TransformStream 拦截和修改流(高级定制)
这种方法不是用来直接管理 Suspense 的 fallback(因为 React 已经做得很好),而是当你需要在 React 生成的 HTML 流中,在非 React 控制的区域进行自定义修改或插入内容时使用。例如,插入动态的广告脚本、全局的样式标签、或者对某些占位符进行替换。
注意: 直接修改 React 生成的 HTML 流需要非常小心,特别是涉及到 Suspense 边界和水合机制时,错误的操作可能会破坏 React 的客户端水合过程。因此,这种方法通常用于在 React 根组件之外或在已知安全区域进行操作。
场景: 在 React 渲染的 HTML 流中,我们想在 <div id="root"> 的上方动态插入一个自定义的 banner 广告。
5.2.1 自定义 TransformStream
我们需要创建一个继承自 stream.Transform 的 Node.js Transform Stream(Web Streams API 也有 TransformStream,但 Node.js 环境下通常使用 stream 模块)。
// src/CustomHtmlTransformer.js
import { Transform } from 'stream';
import { TextDecoder, TextEncoder } from 'util'; // Node.js 内置的 TextDecoder/Encoder
class CustomHtmlTransformer extends Transform {
constructor(insertContent) {
super();
this.decoder = new TextDecoder('utf-8');
this.encoder = new TextEncoder('utf-8');
this.buffer = ''; // 用于累积不完整的 HTML 块
this.insertContent = insertContent;
this.inserted = false; // 确保只插入一次
}
_transform(chunk, encoding, callback) {
this.buffer += this.decoder.decode(chunk, { stream: true });
// 查找我们想要插入内容的位置
const placeholder = '<div id="root">'; // 我们想在 #root div 前插入内容
let index = this.buffer.indexOf(placeholder);
if (index !== -1 && !this.inserted) {
// 找到占位符,插入内容
const beforePlaceholder = this.buffer.substring(0, index);
const afterPlaceholder = this.buffer.substring(index);
this.push(this.encoder.encode(beforePlaceholder)); // 推送占位符之前的内容
this.push(this.encoder.encode(this.insertContent)); // 推送要插入的自定义内容
this.push(this.encoder.encode(afterPlaceholder)); // 推送占位符及之后的内容
this.buffer = ''; // 清空缓冲区,因为我们已经处理了这部分
this.inserted = true;
} else {
// 如果没有找到占位符或已插入,继续推送当前缓冲区(如果足够长)
// 这里的逻辑可以根据实际情况更复杂,例如按行或按特定标签处理
// 为了简单,我们只在找到占位符时处理,否则累积
if (this.buffer.length > 1024) { // 避免缓冲区过大
this.push(this.encoder.encode(this.buffer));
this.buffer = '';
}
}
callback();
}
_flush(callback) {
// 当流结束时,将剩余的缓冲区内容推送到输出
if (this.buffer.length > 0) {
this.push(this.encoder.encode(this.buffer));
}
callback();
}
}
export default CustomHtmlTransformer;
5.2.2 Node.js 服务器 (server.js) 结合 CustomHtmlTransformer
// server.js (修改版)
import express from 'express';
import React from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import path from 'path';
import fs from 'fs';
import App from './src/App';
import CustomHtmlTransformer from './src/CustomHtmlTransformer'; // 导入自定义转换流
const app = express();
const PORT = 3000;
app.use(express.static(path.resolve(__dirname, 'build')));
const templatePath = path.resolve(__dirname, 'build', 'index.html');
const htmlTemplate = fs.readFileSync(templatePath, 'utf-8');
app.get('/', (req, res) => {
res.setHeader('Content-Type', 'text/html');
let didError = false;
const customBanner = `
<div style="background-color: #ffeb3b; padding: 10px; text-align: center; border-bottom: 2px solid #fbc02d;">
<p><strong>✨ 限时优惠!</strong> 全场商品 8 折,立即抢购!</p>
</div>
`;
const { pipe, abort } = renderToPipeableStream(<App />, {
onShellReady() {
res.statusCode = didError ? 500 : 200;
const [header, footer] = htmlTemplate.split('<div id="root"></div>');
res.write(header); // 发送 HTML 头部
// 创建自定义转换流实例
const transformer = new CustomHtmlTransformer(customBanner);
// 将 React 渲染流 pipe 到我们的 transformer
// 然后 transformer 的输出再 pipe 到响应流
// ReactStream -> CustomHtmlTransformer -> ExpressResponseStream
res.write('<div id="root">'); // 插入 React 根 div 的开始标签
pipe(transformer).pipe(res); // 连接流
// 注意:这里我们让 ReactStream 负责关闭底层的 writable,
// 所以 CustomHtmlTransformer 会在 ReactStream 结束时自动 flush。
// 当 ReactStream 完成时,会触发 CustomHtmlTransformer 的 _flush
// 然后再写入 res.write('</div>') 和 footer。
// 这种方式可能需要调整 CustomHtmlTransformer 的逻辑,确保它不会在 <div id="root"> 内部再次插入。
// 更安全的做法是,在 transformer 外部插入 <div id="root"> 的开始和结束标签。
// 修正后的流连接逻辑:
// 将整个模板头部(包括 <div id="root"> 的开始部分)发送,
// 然后在 `CustomHtmlTransformer` 中处理 `<!-- REACT_APP_CONTENT -->` 这样的占位符
// 或者更简单,直接在 React 根 div 外部插入。
// 重新思考:我们的目标是在 <div id="root"> 前插入。
// 那么,我们应该在发送模板头部时,就通过 transformer 来处理。
// 但 renderToPipeableStream 只能 pipe React 渲染的内容。
// 所以,我们需要将整个响应流通过 transformer。
// 方案:让 transformer 处理整个 HTML 模板和 React 流。
const fullTransformer = new CustomHtmlTransformer(customBanner);
// 首先写入模板的头部,然后通过 transformer
fullTransformer.write(new TextEncoder().encode(header)); // 写入头部
fullTransformer.write(new TextEncoder().encode('<div id="root">')); // 写入 React 根 div 开始
// 将 React 流 pipe 到 fullTransformer
pipe(fullTransformer);
// 当 React 流完成时,fullTransformer 会收到结束信号,并刷新其缓冲区。
// 然后,我们可以手动写入 React 根 div 结束和 footer。
fullTransformer.on('end', () => {
fullTransformer.write(new TextEncoder().encode('</div>')); // 写入 React 根 div 结束
fullTransformer.write(new TextEncoder().encode(footer)); // 写入 HTML 尾部
fullTransformer.end(); // 结束 fullTransformer
});
fullTransformer.pipe(res); // fullTransformer 的输出 pipe 到响应流
},
onShellError(err) {
console.error('Shell Error:', err);
didError = true;
res.statusCode = 500;
res.send('<!doctype html><p>Loading error...</p>');
},
onError(err) {
didError = true;
console.error('Streaming Error:', err);
}
});
req.on('close', () => {
abort();
});
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
关于 CustomHtmlTransformer 的改进:
上述 CustomHtmlTransformer 在处理 fullTransformer.write() 和 pipe(fullTransformer) 的混合写入时,可能会遇到顺序和 _flush 的时机问题。更健壮的模式是:
- 将整个 HTML 模板(包含
<div id="root"></div>占位符)视为一个字符串。 - 在
<div id="root"></div>处分割模板。 - 将第一部分(头部 + 我们的 banner)直接写入
res。 - 将 React 流 pipe 到
res。 - React 流结束后,将第二部分(尾部)写入
res。
这样更符合 renderToPipeableStream 的设计意图,也更安全。
更安全的 server.js 结合 CustomHtmlTransformer (推荐的自定义插入方式):
// server.js (更安全的自定义插入方式)
import express from 'express';
import React from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import path from 'path';
import fs from 'fs';
import App from './src/App';
const app = express();
const PORT = 3000;
app.use(express.static(path.resolve(__dirname, 'build')));
const templatePath = path.resolve(__dirname, 'build', 'index.html');
const htmlTemplate = fs.readFileSync(templatePath, 'utf-8');
app.get('/', (req, res) => {
res.setHeader('Content-Type', 'text/html');
let didError = false;
const customBanner = `
<div style="background-color: #ffeb3b; padding: 10px; text-align: center; border-bottom: 2px solid #fbc02d;">
<p><strong>✨ 限时优惠!</strong> 全场商品 8 折,立即抢购!</p>
</div>
`;
const [headerPart, footerPart] = htmlTemplate.split('<div id="root"></div>');
const { pipe, abort } = renderToPipeableStream(<App />, {
onShellReady() {
res.statusCode = didError ? 500 : 200;
// 1. 发送 HTML 模板的头部部分
res.write(headerPart);
// 2. 在 <div id="root"> 之前插入自定义内容
res.write(customBanner);
// 3. 插入 <div id="root"> 的开始标签
res.write('<div id="root">');
// 4. 将 React 渲染流 pipe 到响应流
pipe(res);
// 5. 在 React 流结束时,发送 <div id="root"> 的结束标签和 HTML 模板的尾部
// 注意:pipe(res) 会在内部管理 res 的结束,所以这里不需要 res.end()
// 但我们需要确保在 React 流结束后,再发送剩余的 HTML
// 这通常通过 `onAllReady` 或 `onShellReady` 之后的异步操作来管理
// 更常见的做法是让 `pipe(res)` 负责整个 HTML 的写入,
// 并在模板中预留占位符让 React 填充。
// 为了确保顺序,我们可以创建一个中间 WritableStream 来捕获 React 的输出,
// 然后再将其与我们的自定义内容组合。
// 但这会增加复杂性。对于简单的头部/尾部插入,直接写入更简单。
// 实际上,pipe(res) 会处理整个 React 流,当它完成时,它会触发 res 的 'finish' 事件。
// 我们可以在这里监听,但更直接的方式是在模板中包含好 <div id="root"> 标签,
// 让 React 填充其内部。
// 让我们回到最简单且最推荐的方式:
// 将整个模板分成三部分:`header` + `div id="root"` + `footer`
// 然后在 `div id="root"` 内部插入 React 内容。
// 如果要在 `div id="root"` 外部插入,那就直接在 `header` 或 `footer` 字符串中拼接。
// 如下所示:
const finalHeader = headerPart + customBanner + '<div id="root">';
const finalFooter = '</div>' + footerPart;
res.write(finalHeader);
pipe(res); // React 流会写入到 <div id="root"> 内部
res.write(finalFooter); // 当 React 流完成时,发送剩余的 HTML
// 注意:当 React 流完成时,它会关闭底层的 writable stream (`res`)
// 所以 `res.write(finalFooter)` 可能不会被执行,或者会抛出错误。
// 这是一个挑战:如何确保在 React 流结束之后,再写入额外内容?
// 解决方案是使用一个自定义的 `WritableStream` 作为 `pipe` 的目标,
// 并在该 `WritableStream` 的 `close` 事件中写入剩余内容。
// 改进后的 `onShellReady` 逻辑:
const customWritable = new (require('stream').Writable)({
write(chunk, encoding, callback) {
res.write(chunk, encoding, callback);
},
final(callback) { // 当 customWritable 收到结束信号时调用
res.write(finalFooter); // 写入剩余的 HTML 尾部
res.end(); // 结束 HTTP 响应
callback();
}
});
res.write(finalHeader);
pipe(customWritable); // React 流 pipe 到我们的自定义 WritableStream
},
onShellError(err) {
console.error('Shell Error:', err);
didError = true;
res.statusCode = 500;
res.send('<!doctype html><p>Loading error...</p>');
},
onError(err) {
didError = true;
console.error('Streaming Error:', err);
// 可以在这里发送一个错误片段,但 HTTP 头已经发送,无法改变状态码
}
});
req.on('close', () => {
abort();
});
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
这个修正后的 onShellReady 逻辑才是处理这种混合写入的正确姿势:使用一个中间的 Node.js Writable 流来捕获 pipe 的输出,并在该流的 final 方法中处理 res.end() 和写入剩余的 HTML。
5.3 两种方案的适用场景总结
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| React 内置机制 | 简单、安全、高效。React 自动处理 Suspense 边界的 fallback 插入、内容替换和水合。 |
只能在 React 组件树内部生效。无法在 React 根组件之外或流的任意位置插入非 React 内容。 | 大部分 Streaming SSR 场景,特别是涉及异步数据加载和 Suspense 的优化。 |
| 自定义 TransformStream | 灵活性极高,可以在流的任意位置插入、修改、过滤内容。 | 复杂,容易出错,特别是与 React 水合机制结合时。性能开销可能较大(解码/编码字符串)。 | 在 React 根组件之外插入全局脚本/样式、广告、SEO 元数据,或对整个 HTML 流进行非 React 相关的通用处理。不推荐直接用于管理 Suspense fallback。 |
六、客户端水合与动态内容替换
当流式 HTML 到达客户端时,浏览器会逐步解析它。对于 Suspense 生成的特殊标记,客户端的 React 运行时 (ReactDOM.hydrateRoot) 会发挥关键作用。
- 初始解析: 浏览器接收到第一个 HTML 块,解析并渲染页面的外壳和
Suspense的 fallback 内容。 - 水合开始:
ReactDOM.hydrateRoot(document.getElementById('root'), <App />)开始执行。它会尝试将客户端的 React 组件树与服务端渲染的 DOM 结构进行匹配和水合。 - 处理
Suspense占位符: 当hydrateRoot遇到服务端渲染的Suspensefallback(如<div id="S:R1">...</div>),它会将其标记为“待定”(pending)状态。 - 接收后续内容块: 当服务器发送包含真实内容的
<template id="T:R1">...</template>和更新指令的<script>标签时,浏览器会执行这个脚本。 - 动态替换:
<script>中的指令会告诉 React 运行时:- 找到 ID 为
R1的Suspense占位符。 - 从
T:R1模板中获取真实的 HTML 内容。 - 将
S:R1元素替换为T:R1中的内容。 - 对新插入的真实内容进行水合,使其变为可交互的 React 组件。
- 找到 ID 为
这个过程是渐进式的,不同的 Suspense 边界可以独立地完成加载和水合。React 18 的并发渲染能力允许它在后台进行这些更新,而不会阻塞主线程,从而确保了页面的响应性。
七、性能与优化考量
Streaming SSR 虽然带来了显著的性能优势,但也有其自身的优化和挑战:
- TTFB, FCP, LCP, TTI: Streaming SSR 显著改善了 TTFB 和 FCP,因为内容可以更早地开始传输。LCP(Largest Contentful Paint)也会受益,因为即使主体内容尚未完全加载,页面的主要视觉元素也可能提前到达。TTI(Time to Interactive)则通过渐进式水合得到改善。
- 网络带宽与延迟: 分块传输可以在高延迟网络中更快地开始渲染,但如果块太小或网络抖动严重,可能会增加 HTTP 请求的开销。
- 服务器资源消耗:
renderToPipeableStream在服务器端维持一个流,并需要等待异步数据,这会占用服务器的 CPU 和内存资源。长时间运行的流可能会导致资源耗尽。 - 错误处理:
onShellError和onError回调至关重要。正确处理错误可以防止向用户显示破碎的页面,并在流中断时提供优雅的降级。 - 缓存策略: 由于内容是动态生成的,传统的 CDN 缓存可能不直接适用。需要更精细的缓存策略,例如基于组件的缓存或部分页面缓存。
- 同构代码 (Isomorphic Code): 确保你的 React 组件既能在服务器端渲染,也能在客户端水合,避免出现客户端/服务器端渲染不一致的问题。
- CSS-in-JS 库: 一些 CSS-in-JS 库可能需要特殊配置才能与 Streaming SSR 兼容,确保样式能在流中正确地被提取和插入。
八、潜在问题与解决方案
- Node.js
WritableStream与 WebWritableStream的兼容性:renderToPipeableStream期望一个 Node.jsWritable流。虽然 Web Streams API 提供了WritableStream,但它们并非直接兼容。在 Node.js 环境下,通常使用stream模块的Writable和Transform。如果你需要将 WebReadableStream转换为 Node.jsReadable,或反之,可能需要使用web-streams-node等库进行适配。 - 错误处理与流的终止: 如果流在传输过程中发生错误(例如数据库连接中断、API 调用失败),如何优雅地关闭流并通知客户端?
abort()方法可以用于中止流。服务器应该在onError中处理这些情况,并可能通过发送一个包含错误信息的 HTML 片段来通知客户端。 - SEO 影响: 现代搜索引擎(如 Google)能够执行 JavaScript 并理解动态内容。因此,Streaming SSR 不会对 SEO 产生负面影响,反而因为更快的 FCP 和 LCP 可能对排名有所帮助。但对于一些老旧的爬虫,可能仍需要确保首次传输的 HTML 包含足够的基础内容。
九、总结与展望
Streaming SSR 结合 React 的 Suspense 和 Web Streams API,是现代前端架构中提升用户体验和页面性能的强大组合。它通过将 HTML 分块流式传输,实现了更快的首屏内容呈现和渐进式水合,使得用户可以更早地看到页面并与之交互。
这种模式代表了服务端渲染的未来方向,它模糊了客户端渲染和服务器端渲染的界限,为构建高性能、高用户感知度的 Web 应用提供了坚实的基础。随着 Server Components 等新技术的进一步发展,我们期待看到更加无缝和高效的同构应用开发体验。