React 18 对服务端渲染(SSR)进行了实质性的升级,引入了流式 SSR 和选择性注水。这两个特性解决了一直以来传统 SSR 的两大痛点:整体渲染阻塞和全量注水开销。
传统 SSR 的瓶颈
在 React 18 之前,一个典型的 SSR 流程是这样的:
- 服务端完整渲染整个 React 应用为 HTML 字符串。
- 将 HTML 一次性发送给浏览器(首屏白屏时间取决于最慢的数据请求)。
- 浏览器接收完 HTML,加载所有 JavaScript。
- 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>
);
}
执行流程:
- 服务端立刻渲染
Header和Footer以及Suspense的 fallback<Spinner />,作为“外壳”HTML 先发送到浏览器。 - 浏览器接收外壳,立刻展示 Header、Footer 和一个 loading 动画,首屏内容出现极快。
SlowComments的数据请求在服务端继续执行。一旦数据获取完毕,React 在服务端渲染出SlowComments的真实 HTML,并以<script>标签的形式流式注入到浏览器中。- 浏览器在流中接收这部分增量 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 的交互。
实际项目中的落地注意事项
- 确定 Suspense 边界
不是所有地方都需要 Suspense。一般按照“关键路径”和“非关键内容”划分:顶部导航、核心产品信息适合作为外壳;评论区、推荐列表、页脚等可以作为 Suspense 包裹的延迟内容。
- 数据获取需要支持
流式 SSR 要求数据获取方案能配合 Suspense。React 18 本身未提供数据请求库,你需要使用支持 Suspense 的请求库(如 react-query 的 suspense: true 模式,或 Next.js 的 fetch + App Router)。服务端渲染时,组件必须能够“抛出”数据加载的 promise,让 Suspense 捕获并等待。
- CSS 与样式处理
流式注入的组件可能有独立的 CSS(CSS-in-JS 或 CSS Modules 等),需要确保服务端能在流式 chunk 中也包含相应的样式,否则可能出现无样式的闪动。许多现代 CSS 方案(如 styled-components)已适配 React 18 的流式 SSR。
- 不要滥用
过多的 Suspense 会导致过多的小 chunk,反而可能增加网络开销。建议根据实际页面性能瓶颈进行针对性的放置,通常一两个关键的延迟边界就能获得显著的体验提升。
- 错误处理
如果某个被 Suspense 包裹的组件在服务端渲染时出错,React 18 的流式 SSR 允许只抛弃那个组件(并展示 fallback),而页面的其他部分可以继续正常工作。这提升了健壮性,避免因为一个小组件的数据异常导致整个页面崩溃。
总结:流式 SSR + 选择性注水带来的质变
| 特性 | 传统 SSR | React 18 SSR |
|------|----------|---------------|
| HTML 返回 | 等待所有数据,一次性返回 | 外壳优先,延迟内容流式追赶 |
| 首屏速度 | 受最慢接口拖累 | 几乎无阻塞,TTFB 极大降低 |
| hydration 方式 | 全量一次性,页面长时间无法交互 | 选择性、可中断,优先激活关键交互 |
| 容错性 | 一个组件出错可能影响整个页面 | 错误隔离,单个组件回退不影响其余部分 |
流式 SSR 与选择性注水让 React 的全栈渲染真正具备了“高可交互性”和“渐进式加载”的现代 Web 特征。如果你的项目对 SEO 和首屏体验要求较高(如电商、内容站),迁移到支持这组特性的 Next.js 或 Remix 版本,通常能带来非常明显的性能指标提升。