renderToPipeableStream

在 Node.js 环境中将 React 树流式渲染为 Node.js 可读流

核心概述

renderToPipeableStream 是 React 18 引入的服务端渲染 API, 专门用于 Node.js 环境。它允许你将 React 树渲染为 Node.js 的 stream.Readable 流, 实现流式服务端渲染 (Streaming SSR)

与传统的 renderToString 不同,流式渲染可以渐进式地将 HTML 发送到客户端, 而不是等待整个应用渲染完成。这意味着用户可以更早看到页面的部分内容, 大大改善了首屏加载体验和 Largest Contentful Paint (LCP) 指标。

适用场景:

  • Next.js、Remix 等全栈框架的服务端渲染
  • 需要流式传输 HTML 的 Node.js 服务器
  • 使用 Suspense 的数据获取场景
  • 需要优化 Time to First Byte (TTFB) 的应用

💡 流式渲染的优势

  • 更快的首屏显示:部分 HTML 可以立即发送,无需等待整个应用
  • 更好的 Suspense 支持:Suspense 边界外的内容先发送,边界内的内容加载完成后流式传输
  • 更低的 TTFB:首字节时间更短,因为不需要等待所有数据
  • 渐进式增强:关键内容优先,次要内容后加载

技术规格

导入方式

TypeScript
import { renderToPipeableStream } from 'react-dom/server';

函数签名

TypeScript
function renderToPipeableStream(
  model: ReactNode,
  options?: {
    bootstrapModules?: string[];
    bootstrapScriptContent?: string;
    onAllReady?: () => void;
    onShellReady?: () => void;
    onShellError?: (error: Error) => void;
    onError?: (error: Error) => string;
    identifierPrefix?: string;
    nonce?: string;
  }
): PipeableStream

参数说明

参数类型说明
modelReactNode要渲染的 React 元素树
optionsobject可选的配置选项
options.bootstrapModulesstring[]客户端 hydration 脚本的 URL 数组
options.onShellReady() => void当 shell (Suspense 边界外的内容) 渲染完成时调用的回调
options.onAllReady() => void当整个应用渲染完成时调用的回调
options.onError(error) => string错误处理回调,返回的错误信息会插入到 HTML 中

返回值

返回一个 PipeableStream 对象,它是一个 Node.js 可读流, 具有 pipe 方法,可以连接到 Node.js 的 res 对象或其他流。

实战演练

1. 基础用法:Node.js Express 服务器

使用 Express 实现流式 SSR:

TypeScript
import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';

const app = express();

app.get('/', (req, res) => {
  // 渲染应用为流
  const stream = renderToPipeableStream(<App />, {
    // 当 shell 渲染完成时开始流式传输
    onShellReady() {
      res.statusCode = 200;
      res.setHeader('Content-type', 'text/html');
      res.write('<!DOCTYPE html><html><head><title>My App</title></head><body>');
      res.write('<div id="root">');

      // 将流连接到响应
      stream.pipe(res);
    },

    // 错误处理
    onError(error) {
      console.error(error);
    },

    // Bootstrap 脚本
    bootstrapModules: ['/client.js'],
  });

  // 当流结束时关闭 HTML 标签
  stream.on('end', () => {
    res.end('</div><script src="/client.js"></script></body></html>');
  });
});

app.listen(3000);

2. 使用 Suspense 实现渐进式渲染

结合 Suspense 实现数据获取时的流式传输:

TypeScript
import { Suspense } from 'react';

// 模拟异步数据获取
async function fetchData() {
  await new Promise(resolve => setTimeout(resolve, 2000));
  return { name: 'John', email: 'john@example.com' };
}

function UserProfile() {
  const data = fetchData(); // 如果是 Promise,Suspense 会等待
  return <div>{data.name} - {data.email}</div>;
}

function App() {
  return (
    <html>
      <body>
        <div id="root">
          <h1>用户中心</h1>
          {/* 这部分会立即渲染 */}
          <nav>导航菜单</nav>

          {/* Suspense 边界:这部分会在数据加载完成后流式传输 */}
          <Suspense fallback={<div>加载中...</div>}>
            <UserProfile />
          </Suspense>

          {/* 这部分也会立即渲染 */}
          <footer>页脚</footer>
        </div>
      </body>
    </html>
  );
}

// 服务端渲染
app.get('/', (req, res) => {
  const stream = renderToPipeableStream(<App />, {
    onShellReady() {
      // 此时 nav 和 footer 已经渲染完成
      // UserProfile 的 fallback ("加载中...") 也已发送
      res.statusCode = 200;
      res.setHeader('Content-type', 'text/html');
      stream.pipe(res);
    },

    onError(error) {
      console.error(error);
      res.statusCode = 500;
      res.send('Server Error');
    },
  });
});

3. 生产级案例:完整的服务端渲染实现

包含错误处理、超时和完整 HTML 结构的 SSR 实现:

TypeScript
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';

interface SSRConfig {
  res: express.Response;
  requestUrl: string;
}

export function renderSSR({ res, requestUrl }: SSRConfig) {
  let didError = false;
  const stream = renderToPipeableStream(<App url={requestUrl} />, {
    // Shell 准备就绪 - 开始流式传输
    onShellReady() {
      // 如果发生错误,设置错误状态码
      res.statusCode = didError ? 500 : 200;
      res.setHeader('Content-type', 'text/html');

      // 发送 HTML 头部
      res.write('<!DOCTYPE html>');
      res.write('<html lang="zh-CN">');
      res.write('<head>');
      res.write('<meta charset="UTF-8">');
      res.write('<meta name="viewport" content="width=device-width, initial-scale=1.0">');
      res.write('<title>My App</title>');
      res.write('</head>');
      res.write('<body>');
      res.write('<div id="root">');

      // 将 React 流连接到响应
      stream.pipe(res);
    },

    // 整个应用渲染完成
    onAllReady() {
      // 用于爬虫或不需要流式传输的场景
      if (!res.headersSent) {
        res.statusCode = didError ? 500 : 200;
        res.setHeader('Content-type', 'text/html');
        stream.pipe(res);
      }
    },

    // Shell 渲染错误
    onShellError(error) {
      console.error('Shell rendering error:', error);

      // 发送错误页面
      res.statusCode = 500;
      res.setHeader('Content-type', 'text/html');
      res.send(`
        <!DOCTYPE html>
        <html>
          <head><title>Server Error</title></head>
          <body>
            <h1>Something went wrong</h1>
            <pre>${error.message}</pre>
          </body>
        </html>
      `);
    },

    // 错误处理 - 返回堆栈信息用于调试
    onError(error) {
      didError = true;
      console.error('Rendering error:', error);
      return error.stack; // 将堆栈信息插入到 HTML 中
    },

    // 客户端 hydration 脚本
    bootstrapModules: ['/static/js/client.js'],
  });

  // 流结束后关闭 HTML 标签
  stream.on('end', () => {
    if (!res.writableEnded) {
      res.end('</div><script src="/static/js/client.js"></script></body></html>');
    }
  });

  // 超时处理
  const timeout = setTimeout(() => {
    console.error('Rendering timeout');
    stream.abort();
    if (!res.headersSent) {
      res.statusCode = 504;
      res.send('Gateway Timeout');
    }
  }, 30000); // 30 秒超时

  stream.on('error', () => clearTimeout(timeout));
  stream.on('end', () => clearTimeout(timeout));
}

// Express 路由
app.get('*', (req, res) => {
  renderSSR({ res, requestUrl: req.url });
});

避坑指南

陷阱 1: 混淆 onShellReady 和 onAllReady

问题:不理解这两个回调的区别,导致流式渲染失效。

TypeScript
// ❌ 错误:使用 onAllReady 失去了流式传输的优势
const stream = renderToPipeableStream(<App />, {
  onAllReady() {
    // 这会等待整个应用渲染完成后才发送,包括 Suspense 内的内容
    // 等同于 renderToString 的行为
    stream.pipe(res);
  },
});

// ✅ 正确:使用 onShellReady 实现流式传输
const stream = renderToPipeableStream(<App />, {
  onShellReady() {
    // Suspense 边界外的内容会立即发送
    // Suspense 内的内容会在数据加载完成后流式传输
    stream.pipe(res);
  },
});

核心原则:onShellReady 用于流式传输 (推荐),onAllReady 用于爬虫或不需要流式传输的场景。

陷阱 2: 忘记处理流错误

问题:流可能在传输过程中因网络问题或渲染错误而中断。

TypeScript
// ❌ 错误:没有错误处理
stream.pipe(res);

// ✅ 正确:监听错误事件
stream.on('error', (error) => {
  console.error('Stream error:', error);
  if (!res.headersSent) {
    res.statusCode = 500;
    res.send('Internal Server Error');
  } else if (!res.writableEnded) {
    res.end();
  }
});

陷阱 3: 在客户端数据获取组件中使用

问题:renderToPipeableStream 只用于服务端,不能在客户端使用。

TypeScript
// ❌ 错误:在浏览器中导入
import { renderToPipeableStream } from 'react-dom/server'; // 运行时错误

// ✅ 正确:只在服务端使用
// server.js
import { renderToPipeableStream } from 'react-dom/server';

// client.js
import { hydrateRoot } from 'react-dom/client';

API 对比

API环境流式传输使用场景
renderToPipeableStreamNode.jsNode.js 服务器,流式 SSR
renderToReadableStreamWeb 标准Edge Runtime,Cloudflare Workers
renderToStringNode.js传统 SSR,不推荐
renderToStaticMarkupNode.js静态 HTML (非交互)

延伸阅读

这篇文章有帮助吗?