人人都会AI编程

11.5 流式 SSR 与选择性注水

更新时间:2026-07-11

React 18 对服务端渲染(SSR)进行了实质性的升级,引入了流式 SSR选择性注水。这两个特性解决了一直以来传统 SSR 的两大痛点:整体渲染阻塞全量注水开销

传统 SSR 的瓶颈

在 React 18 之前,一个典型的 SSR 流程是这样的:

  1. 服务端完整渲染整个 React 应用为 HTML 字符串。
  2. 将 HTML 一次性发送给浏览器(首屏白屏时间取决于最慢的数据请求)。
  3. 浏览器接收完 HTML,加载所有 JavaScript。
  4. React 在客户端执行 hydration(注水),将静态 HTML 重新“激活”为可交互的 React 应用。hydration 必须是整体进行的——整个应用要一次性完成注水,任一组件未准备好就会阻塞整体交互。

这就导致两个问题:

  • 数据依赖阻塞:如果页面中某个组件需要较慢的 API 调用,整个页面的 HTML 都要等这个请求完成才能返回。用户看到白屏的时间高度依赖最慢的那个接口。
  • 交互阻塞:即使 HTML 已经展示,但 JavaScript bundle 很大或主线程繁忙时,整个页面无法交互(点击按钮无反应),直到全局 hydration 完成。

流式 SSR:边服务端渲染边传输

React 18 利用 Suspense 组件,结合 Node.js 的 pipeToNodeWritable 等新 API,可以把 HTML 分块流式传输到浏览器。

核心思想:把不需要等待异步数据的部分先渲染成 HTML 发送,需要等待的部分用 Suspense 包裹,并提供一个 fallback 占位内容,等数据就绪后,React 会把该组件的 HTML 以流的形式继续推送出去。

服务端代码示例(伪代码)

// 服务端渲染入口
import { renderToPipeableStream } from 'react-dom/server';

app.get('/', (req, res) => {
  const { pipe } = renderToPipeableStream(<App />, {
    bootstrapScripts: ['/main.js'],
    onShellReady() {
      // 应用外壳(不依赖数据的部分)已就绪,立即开始流式传输
      res.setHeader('content-type', 'text/html');
      pipe(res);
    },
    onAllReady() {
      // 所有内容(包括被 Suspense 包裹的部分)都已就绪
    },
    onError(error) {
      console.error(error);
    }
  });
});
// App 组件
function App() {
  return (
    <html>
      <body>
        <Header />
        <Suspense fallback={<Spinner />}>
          <SlowComments />   {/* 这个组件依赖慢接口 */}
        </Suspense>
        <Footer />
      </body>
    </html>
  );
}

执行流程

  1. 服务端立刻渲染 HeaderFooter 以及 Suspense 的 fallback <Spinner />,作为“外壳”HTML 先发送到浏览器。
  2. 浏览器接收外壳,立刻展示 Header、Footer 和一个 loading 动画,首屏内容出现极快。
  3. SlowComments 的数据请求在服务端继续执行。一旦数据获取完毕,React 在服务端渲染出 SlowComments 的真实 HTML,并以 <script> 标签的形式流式注入到浏览器中。
  4. 浏览器在流中接收这部分增量 HTML,自动替换掉掉 Spinner 占位,展示评论区。

最终效果:页面的 TTFB(首字节时间) 大幅降低,用户感受到更快的首屏渲染,无需等待最慢的组件。

选择性注水:按需激活交互

传统 SSR 的 hydration 是“全量”的:只要有一个 React 组件渲染在 HTML 中,整个应用的所有组件都得在同一个批次里完成 hydration。这意味着如果页面中某个部分(比如侧边栏)的 JavaScript 代码很大,它会拖慢其他组件的交互时间。

React 18 的选择性注水通过 Suspense 进一步优化了 hydration:

  • Suspense 包裹的组件在流式 HTML 到达后,React 不会立即对其进行 hydration
  • React 会优先 hydration 那些已经可见、且不属于任何 Suspense 边界的内容(即外壳部分)。
  • 对于包裹在 Suspense 中的内容,React 会异步地、可被中断地对它进行 hydration。
  • 如果用户在 hydration 过程中尝试与某个尚未 hydrating 的组件交互(比如点击评论区),React 会暂时暂停当前的 hydration,优先处理用户交互的那个组件,然后继续。

这种机制的核心价值是:页面可以逐步变得可交互,而不必等待所有组件的 JS 代码都加载执行完毕。大型页面尤其受益——首屏的按钮和导航可以立刻响应,而下方的一长串非关键内容可以稍后再激活。

客户端中的 Suspense 也参与选择性注水

在客户端,你同样可以使用 Suspense 与 React.lazy 做代码分割,配合 SSR 时的流式传输,形成无缝衔接:

import { lazy, Suspense } from 'react';

const HeavyWidget = lazy(() => import('./HeavyWidget'));

function App() {
  return (
    <div>
      <Nav />  {/* 立即交互 */}
      <Suspense fallback={<div>加载中...</div>}>
        <HeavyWidget />
      </Suspense>
    </div>
  );
}

当使用 SSR 流式渲染时,HeavyWidget 的代码块可以在服务端需要时才加载,并且它的 hydration 会在浏览器空闲时进行,不影响 Nav 的交互。

实际项目中的落地注意事项

  1. 确定 Suspense 边界

不是所有地方都需要 Suspense。一般按照“关键路径”和“非关键内容”划分:顶部导航、核心产品信息适合作为外壳;评论区、推荐列表、页脚等可以作为 Suspense 包裹的延迟内容。

  1. 数据获取需要支持

流式 SSR 要求数据获取方案能配合 Suspense。React 18 本身未提供数据请求库,你需要使用支持 Suspense 的请求库(如 react-query 的 suspense: true 模式,或 Next.js 的 fetch + App Router)。服务端渲染时,组件必须能够“抛出”数据加载的 promise,让 Suspense 捕获并等待。

  1. CSS 与样式处理

流式注入的组件可能有独立的 CSS(CSS-in-JS 或 CSS Modules 等),需要确保服务端能在流式 chunk 中也包含相应的样式,否则可能出现无样式的闪动。许多现代 CSS 方案(如 styled-components)已适配 React 18 的流式 SSR。

  1. 不要滥用

过多的 Suspense 会导致过多的小 chunk,反而可能增加网络开销。建议根据实际页面性能瓶颈进行针对性的放置,通常一两个关键的延迟边界就能获得显著的体验提升。

  1. 错误处理

如果某个被 Suspense 包裹的组件在服务端渲染时出错,React 18 的流式 SSR 允许只抛弃那个组件(并展示 fallback),而页面的其他部分可以继续正常工作。这提升了健壮性,避免因为一个小组件的数据异常导致整个页面崩溃。

总结:流式 SSR + 选择性注水带来的质变

| 特性 | 传统 SSR | React 18 SSR |
|------|----------|---------------|
| HTML 返回 | 等待所有数据,一次性返回 | 外壳优先,延迟内容流式追赶 |
| 首屏速度 | 受最慢接口拖累 | 几乎无阻塞,TTFB 极大降低 |
| hydration 方式 | 全量一次性,页面长时间无法交互 | 选择性、可中断,优先激活关键交互 |
| 容错性 | 一个组件出错可能影响整个页面 | 错误隔离,单个组件回退不影响其余部分 |

流式 SSR 与选择性注水让 React 的全栈渲染真正具备了“高可交互性”和“渐进式加载”的现代 Web 特征。如果你的项目对 SEO 和首屏体验要求较高(如电商、内容站),迁移到支持这组特性的 Next.js 或 Remix 版本,通常能带来非常明显的性能指标提升。