深入 ‘Streaming SSR’:如何利用 Web Streams API 在 HTML 传输过程中动态插入 `Suspense` 的 fallback?

各位技术同仁,下午好!

今天,我们将深入探讨一个令人兴奋且极具潜力的前端性能优化技术——“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)的形式逐步发送给客户端。

这意味着:

  1. 更快的 TTFB 和 FCP: 服务器可以先发送页面“外壳”(shell)或骨架,包含页面的基本布局和不依赖异步数据的部分。用户可以更快地看到页面的基本结构。
  2. 渐进式水合 (Progressive Hydration): 结合 React 18+ 的并发特性和 Suspense,客户端可以对到达的 HTML 块进行局部水合,而不是等待整个页面。这允许用户在某些部分可交互时,另一些部分仍在加载或水合。
  3. 更好的用户体验: 用户在等待所有内容加载完成之前,就能看到页面内容并与部分元素进行交互,极大地提升了感知性能。

而 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 是连接 ReadableStreamWritableStream 的桥梁。它有一个可写端(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 流程概览

  1. 客户端发起请求: 浏览器向服务器请求页面。
  2. 服务器处理请求:
    • 调用 renderToPipeableStream(<App />, { ... })
    • onShellReady 触发时,服务器设置 Content-Type: text/htmlres.statusCode = 200,然后调用 pipe(res) 将 React 生成的 HTML 流连接到 HTTP 响应流。
    • React 会首先发送页面的“外壳”HTML。
    • 当遇到 <Suspense> 组件时,如果其内部组件的数据尚未准备好,React 会立即发送 fallback 内容以及一个 <script> 标签,用于客户端水合和替换。
    • Suspense 内部组件的数据准备好时,React 会发送其真实的 HTML 内容,以及另一个 <script> 标签,指示客户端用真实内容替换之前发送的 fallback。
  3. 客户端接收并渲染:
    • 浏览器接收到第一个 HTML 块(外壳),并开始解析和渲染。
    • 当接收到带有 Suspense fallback 的 HTML 块时,浏览器显示 fallback。
    • 客户端的 React 水合器会逐步处理这些 HTML 块。当接收到后续的真实内容块时,React 会在客户端用真实内容替换掉 DOM 中对应的 fallback。

这个过程是渐进式的,用户可以更快地看到内容并与部分页面进行交互。

四、深入 Suspense 与 Streaming SSR 的协同

Suspense 是 React 并发模式的核心特性之一,它允许组件“暂停”渲染,直到其所需数据准备就绪。在客户端,这意味着在数据加载期间显示一个 fallback UI。在 Streaming SSR 中,Suspense 的作用被极大地扩展。

4.1 Suspense 在客户端和服务器端的行为对比

特性 客户端行为 服务器端行为 (Streaming SSR)
数据加载 异步数据加载,如 fetchPromise 等。 同样进行异步数据加载。
渲染中断 当子组件抛出 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> 边界时,它会进行以下处理:

  1. 检查内部组件是否准备就绪: React 尝试渲染 Suspense 内部的子组件。
  2. 如果子组件未就绪(例如,抛出了一个 Promise):
    • React 会立即渲染 Suspensefallback 内容,并将其作为第一个 HTML 块发送出去。
    • 同时,React 会生成一个特殊的 <script> 标签,其中包含一些元数据(如 id),指示客户端这是一个 Suspense 占位符。
    • 服务器继续渲染页面的其他部分,特别是那些不依赖于此 Suspense 边界的同步内容。
  3. 当子组件的数据最终准备就绪时:
    • React 会再次渲染 Suspense 内部的子组件,这次它能成功渲染出真实的 HTML 内容。
    • 这些真实的 HTML 内容会被包裹在一个 <template> 标签中,并紧跟一个 <script> 标签。这个 <script> 标签会告诉客户端 React 运行时:“嘿,那个 ID 为 X 的 Suspense 占位符,现在可以被这个 <template> 里的真实内容替换了!”
    • 这些 HTML 块会作为后续数据流的一部分发送给客户端。

示例 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 实现动态内容插入。这里主要有两种策略:

  1. 利用 React 内置的 SuspenserenderToPipeableStream 机制(推荐且主流)。
  2. 自定义 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}`);
});

构建步骤:

  1. 创建一个新的 React 项目 (npx create-react-app streaming-ssr --template cra-template-pwa --use-npm)
  2. 安装 Express (npm install express)
  3. src/App.jssrc/index.js 替换为上述代码。
  4. 在项目根目录创建 server.js 文件。
  5. package.json 中添加 type: "module" 以支持 ES Modules,或者使用 Babel/Webpack 配置 Node.js 服务器。为了简单起见,我们直接使用 ESM。
  6. 运行 npm run build 打包客户端应用。
  7. 运行 node server.js 启动服务器。

运行效果:

当你访问 http://localhost:3000 时,你会立即看到页面的头部、同步内容和两个 Suspense 的 fallback (加载用户数据中...加载评论中...)。大约 1.5 秒后,加载评论中... 会被评论列表替换。再过 1.5 秒(总共 3 秒),加载用户数据中... 会被用户详情替换。整个过程是渐进的,用户可以实时看到内容的更新。

Web Streams API 在哪里发挥作用?

虽然我们没有直接实例化 ReadableStreamTransformStream,但 res 对象(Express 的响应对象)本身就是一个 Node.js 的 WritableStreamrenderToPipeableStreampipe 方法正是将其内部的 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 的时机问题。更健壮的模式是:

  1. 将整个 HTML 模板(包含 <div id="root"></div> 占位符)视为一个字符串。
  2. <div id="root"></div> 处分割模板。
  3. 将第一部分(头部 + 我们的 banner)直接写入 res
  4. 将 React 流 pipe 到 res
  5. 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) 会发挥关键作用。

  1. 初始解析: 浏览器接收到第一个 HTML 块,解析并渲染页面的外壳和 Suspense 的 fallback 内容。
  2. 水合开始: ReactDOM.hydrateRoot(document.getElementById('root'), <App />) 开始执行。它会尝试将客户端的 React 组件树与服务端渲染的 DOM 结构进行匹配和水合。
  3. 处理 Suspense 占位符:hydrateRoot 遇到服务端渲染的 Suspense fallback(如 <div id="S:R1">...</div>),它会将其标记为“待定”(pending)状态。
  4. 接收后续内容块: 当服务器发送包含真实内容的 <template id="T:R1">...</template> 和更新指令的 <script> 标签时,浏览器会执行这个脚本。
  5. 动态替换: <script> 中的指令会告诉 React 运行时:
    • 找到 ID 为 R1Suspense 占位符。
    • T:R1 模板中获取真实的 HTML 内容。
    • S:R1 元素替换为 T:R1 中的内容。
    • 对新插入的真实内容进行水合,使其变为可交互的 React 组件。

这个过程是渐进式的,不同的 Suspense 边界可以独立地完成加载和水合。React 18 的并发渲染能力允许它在后台进行这些更新,而不会阻塞主线程,从而确保了页面的响应性。

七、性能与优化考量

Streaming SSR 虽然带来了显著的性能优势,但也有其自身的优化和挑战:

  • TTFB, FCP, LCP, TTI: Streaming SSR 显著改善了 TTFB 和 FCP,因为内容可以更早地开始传输。LCP(Largest Contentful Paint)也会受益,因为即使主体内容尚未完全加载,页面的主要视觉元素也可能提前到达。TTI(Time to Interactive)则通过渐进式水合得到改善。
  • 网络带宽与延迟: 分块传输可以在高延迟网络中更快地开始渲染,但如果块太小或网络抖动严重,可能会增加 HTTP 请求的开销。
  • 服务器资源消耗: renderToPipeableStream 在服务器端维持一个流,并需要等待异步数据,这会占用服务器的 CPU 和内存资源。长时间运行的流可能会导致资源耗尽。
  • 错误处理: onShellErroronError 回调至关重要。正确处理错误可以防止向用户显示破碎的页面,并在流中断时提供优雅的降级。
  • 缓存策略: 由于内容是动态生成的,传统的 CDN 缓存可能不直接适用。需要更精细的缓存策略,例如基于组件的缓存或部分页面缓存。
  • 同构代码 (Isomorphic Code): 确保你的 React 组件既能在服务器端渲染,也能在客户端水合,避免出现客户端/服务器端渲染不一致的问题。
  • CSS-in-JS 库: 一些 CSS-in-JS 库可能需要特殊配置才能与 Streaming SSR 兼容,确保样式能在流中正确地被提取和插入。

八、潜在问题与解决方案

  • Node.js WritableStream 与 Web WritableStream 的兼容性: renderToPipeableStream 期望一个 Node.js Writable 流。虽然 Web Streams API 提供了 WritableStream,但它们并非直接兼容。在 Node.js 环境下,通常使用 stream 模块的 WritableTransform。如果你需要将 Web ReadableStream 转换为 Node.js Readable,或反之,可能需要使用 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 等新技术的进一步发展,我们期待看到更加无缝和高效的同构应用开发体验。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注