
一句话:传统 SSR 必须「等所有数据就绪才返回 HTML」,流式渲染(Streaming SSR)改成「先把外壳(shell)发给浏览器,慢的部分用 Suspense 占位、数据就绪后再流式补上」——从而改善 TTFB 与首屏可见时间。
一、传统 SSR 的瓶颈
传统 SSR(renderToString)的流程:
① 服务端收到请求
② 渲染【整棵】React 树 —— 必须等所有数据都就绪
· 如果页面里有一个「慢接口」(如 3 秒的推荐数据)
· 那么整个 HTML 都要等这 3 秒才能生成
③ 一次性返回完整 HTML
④ 浏览器显示 → 下载 JS → 水合
问题:
· TTFB 差(服务端要等最慢的那个数据)
· 首屏可见时间差(用户在这 3 秒里看的是「白屏」)
· 「快的数据」被「慢的数据」拖住了
⭐ 关键洞察:
页面上不同部分的「数据就绪时间」本来就不同。
传统 SSR 把它们「串」成了一条最长的链,
而流式渲染让它们「各自独立地到达」。
二、流式渲染的原理
流式 SSR 的流程(React 18+):
① 服务端开始渲染整棵树
② 渲染到「不挂起的部分」(shell)→ 【立即发送】这些 HTML
→ 浏览器立刻能显示「外壳」(导航、标题、骨架)
③ 渲染到 <Suspense> 边界里的「挂起组件」时:
· 先发送它的「fallback HTML」(如骨架屏)
· 【记住位置】,稍后要用真实内容替换它
④ 挂起的数据就绪后:
· 把「真实内容的 HTML」+ 一段「替换脚本」流式发送
· 客户端(或浏览器解析时)用真实内容替换掉 fallback
⑤ 同时:客户端 JS 下载完成后开始【水合】
· shell 先水合(可以交互)
· 后到的部分到货后再水合(渐进式水合)
⭐ 收益:TTFB 大幅改善、首屏可见更早、慢的部分不阻塞整体
⚠️ 一个常见误解:
「流式渲染 = 更快的服务端渲染」
→ 不对。服务端「渲染完所有内容」的总时间没变,
变的是「用户多早看到有用的内容」(并发地做「渲染」与「传输」)。
三、renderToPipeableStream
// 服务端(Node)—— 用 renderToPipeableStream 替代 renderToString
import { renderToPipeableStream } from 'react-dom/server';
import { Transform } from 'node:stream';
app.get('*', (req, res) => {
const { pipe, abort } = renderToPipeableStream(
<App location={req.url} />,
{
// ① shell(外壳)渲染完成 → 立即开始发送
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked'); // ⭐ 分块传输
pipe(res);
},
// ② shell 渲染就失败 → 整个页面都无法流式,返回错误页
onShellError(error) {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send('<!doctype html><h1>出错了</h1>');
},
// ③ 某个 Suspense 边界渲染失败(shell 已发出)
onError(error) {
console.error(error); // ⭐ 必须记录(否则错误被静默)
},
// ④ 全部内容(含所有挂起部分)就绪 → 适合爬虫/静态生成
onAllReady() {
// 爬虫请求时可以「等全部就绪」再返回(避免爬虫拿到不完整内容)
},
}
);
// ⑤ 超时保护:10 秒后强制放弃未完成的部分
setTimeout(() => abort(), 10_000);
});
// 同一个应用(React 组件)—— 用 Suspense 划分「可后到的部分」
function App() {
return (
<html lang="zh-CN">
<body>
<Header /> {/* 快 → 进 shell */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* ⭐ 慢 → 服务端先给骨架,后补内容 */}
</Suspense>
<Suspense fallback={<RecommendSkeleton />}>
<Recommendations /> {/* ⭐ 另一个独立慢块 */}
</Suspense>
<Footer />
</body>
</html>
);
}
// ✅ 区分「爬虫」与「浏览器」(爬虫看不到流式替换,最好等全部就绪)
function shouldStream(req: Request) {
const ua = req.headers.get('user-agent') ?? '';
return !/bot|crawler|spider|crawling/i.test(ua);
}
// Next.js 等框架内部已经处理了这类策略
| API | 用途 |
|---|---|
renderToPipeableStream(Node) |
流式 SSR(Node 流) |
renderToReadableStream(Web Streams) |
流式 SSR(Edge/Deno) |
renderToString |
传统一次性 SSR(不支持流式) |
onShellReady / onShellError / onAllReady / onError |
流式渲染的四个回调 |
四、Suspense 边界的划分
// ❌ 边界太大:一个慢块拖住整块内容
<Suspense fallback={<PageSkeleton />}>
<PageContent /> {/* 里面既有快数据也有慢数据 → 都在等 */}
</Suspense>
// ✅ 边界按「独立且较慢的数据块」划分
function PageContent() {
return (
<>
<Title /> {/* 快(同步或已就绪)*/}
<Suspense fallback={<AuthorSkeleton />}>
<Author /> {/* 中等 */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* 慢 */}
</Suspense>
</>
);
}
四条划分原则:
① 【按「慢」划分】——只把「明显较慢的、可独立就绪的部分」放进 Suspense
(快的内容不该被骨架替代)
② 【同一时间就绪的内容放一起】——否则会「先后闪多次骨架」
③ 【fallback 要有「确定高度」】——避免内容出现时的布局跳动(CLS)
④ 【边界不要太多】——太多会导致「骨架海」(视觉噪音)
⭐ 一个经验判断:
「如果没有 Suspense,用户会等这个数据多久?」
· < 100ms → 不需要(直接等)
· 100ms ~ 1s → 可以考虑(提升感知)
· > 1s → 应该用(否则用户等到怀疑人生)
五、loading.tsx 与 <Suspense> 的关系
// app/posts/loading.tsx —— Next.js 会自动用它包住 page.tsx
export default function Loading() {
return <PostsSkeleton />;
}
⭐ 两者其实是「同一机制的不同粒度」:
`loading.tsx`:
· 文件约定,Next.js 自动用 <Suspense fallback={<Loading />}> 包住整个路由段
· 粒度:【整页/整段】
· 零代码(写上文件就有)
`<Suspense>`:
· 手动包裹,粒度精确到「某个数据块」
· 粒度:【组件级】
· 更细腻(可以让「快的先显示」)
✅ 实践组合:
· 用 loading.tsx 提供「进入页面时」的基础加载态
· 在页面内部用 <Suspense> 划分「多个独立数据块」→ 更快的感知
// ✅ 组合示例
// app/posts/loading.tsx —— 整页加载态(路由切换时)
export default function Loading() {
return <PageSkeleton />;
}
// app/posts/page.tsx —— 页面内部再细分
export default function PostsPage() {
return (
<>
<Suspense fallback={<StatsSkeleton />}>
<Stats /> {/* 统计(较快) */}
</Suspense>
<Suspense fallback={<ListSkeleton />}>
<PostList /> {/* 列表(较慢) */}
</Suspense>
</>
);
}
六、选择性水合(Selective Hydration)
React 18 的能力:水合也可以「按优先级进行」
① 页面上的多个 Suspense 边界「各自就绪、各自水合」
② 如果用户「点击了某个还没水合的边界」,React 会【优先水合那个边界】
→ 用户感觉「点了就有反应」(而不是等所有 JS 都水合完)
③ 这依赖「事件重放」:React 会先在根容器捕获这次点击,
水合完成后「重放」它
⚠️ 这是框架/React 内部行为,业务代码通常不用手动处理;
但它解释了「为什么用 Suspense 划分边界值得一提」(不只为视觉,也影响水合优先级)。
七、与 RSC 的配合
// ✅ RSC + 流式渲染:服务端组件的「慢数据」用 Suspense 流式补上
export default function Page() {
return (
<div>
<h1>商品详情</h1>
<Suspense fallback={<PriceSkeleton />}>
<Price /> {/* async Server Component(慢) */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews /> {/* 另一个 async Server Component */}
</Suspense>
</div>
);
}
async function Price() {
const price = await getPrice(); // 服务端直接查(慢)
return <span>¥{price}</span>;
}
⭐ RSC + 流式渲染的组合价值:
① 服务端组件的代码「不进客户端包」
② 慢的服务端数据用 Suspense「流式后到」
③ 客户端 JS 更少 → 水合更快 → 更快可交互
→ 这是 Next.js App Router 默认推荐的组织方式
八、常见坑
// ① 用 renderToString(不支持流式,白等所有数据)
renderToString(<App />); // ⚠️ 对流式无用
// ② 只用了流式 API 但没在应用里加 <Suspense>(没有可流式的边界 → 等价于传统 SSR)
// ③ 边界划分太粗(一个慢块拖住整块)
<Suspense fallback={<PageSkeleton />}><WholePage /></Suspense>; // ⚠️
// ④ fallback「没有高度」导致布局跳动(CLS)
<Suspense fallback={<div />}> // ⚠️
// ⑤ 所有内容都包 Suspense → 「骨架海」(视觉噪音 + 水合碎片化)
// ⑥ 把「必须同步显示的关键内容」(如标题、价格)放进 Suspense
// ⑦ 忘记 onError 记录错误(流式下的错误被静默)
onError(error) { /* 什么都没做 */ } // ⚠️
// ⑧ 没有超时保护(一个卡住的请求会让连接一直挂着)
setTimeout(() => abort(), 10_000); // ✅
// ⑨ 给爬虫返回「带骨架的流式 HTML」(应该等全部就绪再返回)
// ⑩ 认为「流式渲染能让服务端渲染更快」
// → 总渲染时间不变,变的是「用户多早看到内容」
// ⑪ 在「已经发出的 shell」里依赖「后面才就绪的数据」(不可能,shell 已经发出)
// ⑫ 用「客户端 fetch」代替「服务端挂起」(不会流式,只是骨架 + 客户端请求)
// ⑬ 在 Suspense 边界里做「有副作用的渲染」(可能重复执行,见 4.3)
// ⑭ 中间代理/CDN「缓冲」响应(导致流式失效,看起来和传统 SSR 一样)
// ✅ 关闭代理缓冲(如 Nginx 的 proxy_buffering off)
// ⑮ 忽略「水合时的 mismatch」(流式内容替换后再水合,仍要保证一致,见 9.4)
// ⑭ 的 Nginx 配置示例
// proxy_buffering off;
// proxy_cache off;
// chunked_transfer_encoding on;
// 或者对「流式响应」设置响应头:
res.setHeader('X-Accel-Buffering', 'no');
九、面试延伸
- 「什么是流式渲染(Streaming SSR)?」
流式渲染指「服务端边渲染边把 HTML 分块发给浏览器」:先把「不挂起的部分(shell)」渲染完立即发送(TTFB 显著改善、首屏更快可见),遇到<Suspense>边界里的慢内容时先发送 fallback 的 HTML,等数据就绪后再把真实内容 + 替换脚本流式发送,由浏览器/客户端无缝替换。它解决的是「一个慢请求阻塞整个页面」的问题(传统 SSR 必须等所有数据就绪才能返回)。 -
「流式渲染会让服务端渲染更快吗?」
不会。服务端「渲染完所有内容」的总时间基本不变——流式改变的是「用户多早看到有用的内容」(把「渲染」与「传输」并发起来:一边渲染后面的部分,一边把已就绪的部分发出去)。所以它的收益体现在 TTFB、FCP/LCP 这些「可见性指标」上,而不是「服务端 CPU 时间」。这也是「为什么它常与 Suspense 一起提」的原因——有 Suspense 边界才有可流式的切分点。 -
「Suspense 边界应该怎么划分?」
四条原则:① 按「慢」划分——只把「明显较慢且可独立就绪」的内容放进 Suspense(快的内容不该被骨架替代);② 同一时间就绪的内容放一起——否则会「先后闪多次骨架」;③ fallback 要有「确定高度」——避免内容出现时的布局跳动(CLS);④ 边界不要过多——太多会造成「骨架海」与「水合碎片化」。经验阈值是「没有 Suspense 时用户要等多于 ~1 秒」的数据才值得单独包。 -
「
loading.tsx和<Suspense>有什么区别?」
它们是同一机制的不同粒度:①loading.tsx是「文件约定」——Next.js 会自动用<Suspense fallback={<Loading />}>包住整个路由段的页面(粒度是「整页/整段」,零代码);②<Suspense>是「手动包裹」——粒度精确到「某个数据块」,可以让「快的部分先显示、慢的部分后补」。实践组合是用loading.tsx提供「进入页面的基础加载态」,在页面内部再用<Suspense>细分(更好的感知)。 -
「什么是选择性水合(Selective Hydration)?」
指「水合可以按优先级进行」——React 18 里,页面上的多个 Suspense 边界「各自就绪、各自水合」;如果用户点击了某个还没水合的边界,React 会优先水合那块(通过「在根容器先捕获事件、水合完成后重放该事件」实现)——用户感觉「点了就有反应」,而不是「等所有 JS 水合完」。这是框架/React 内部的行为,业务代码通常不用手动处理,但它解释了「为什么用 Suspense 划分边界值得一提」(不只为视觉,也影响水合优先级)。 -
「流式渲染有什么代价与限制?」
四点:① 中间设备可能缓冲——Nginx/CDN 默认会缓冲响应,导致「看起来还是等全部」→ 需要关闭缓冲(proxy_buffering off);② 错误处理变复杂——shell 已发出后,边界内出错只能「在已发出的 HTML 里插入错误提示」(onError必须记录,否则错误被静默);③ 爬虫可能拿到不完整内容——通常的做法是「检测爬虫 → 等onAllReady再返回」;④ 水合仍要一致——流式替换后再水合,仍需保证「客户端首次渲染与服务端一致」(否则仍有 mismatch)。 -
「为什么要给流式渲染加超时?」
因为「一个卡住的请求可能让连接一直挂着」——shell 已经发出去了,但某个 Suspense 边界的数据永远不返回,那么这个 HTTP 连接会一直保持(占用服务端资源、用户看到的是永久骨架)。renderToPipeableStream返回的abort()可以用来「强制放弃未完成的部分并结束响应」。实践上会设置一个「全局超时」(如 10 秒),超时后 abort,让页面「以已就绪的部分结束」(通常是显示「加载失败/稍后重试」的兜底 UI)。 -
「怎么判断页面适合流式渲染?」
三个特征:① 页面有多块「就绪时间差异明显」的数据(一个快的列表 + 一个慢的推荐/评论);② 用户对「首屏可见」敏感(内容型页面、电商详情);③ 服务端获取数据会较慢(数据库复杂查询、聚合多个内部服务)。不适合的情况是:① 页面几乎全是「同步/很快」的内容(流式没有可切分点);② 纯客户端应用(没有 SSR);③ 爬虫为主要访问者(通常选择「等全部就绪」)。核心判断标准是「是否存在『慢的部分拖累快的内容』」。
一句话速记
流式渲染 = 「shell 先发、慢的部分用 Suspense 占位、就绪后流式补上」(
renderToPipeableStream+onShellReady/onShellError/onAllReady/onError+abort()超时保护);它不会让服务端渲染更快,而是改善 TTFB / FCP(把「渲染」与「传输」并发);用Suspense划分「独立且较慢的数据块」(快的不要包、同期的放一起、fallback 要有确定高度、边界别太多);loading.tsx是「整段级」的文件约定,<Suspense>是「组件级」,两者组合使用;必须关闭代理缓冲(否则流式失效)、必须记录onError、给爬虫等onAllReady;React 18 还支持选择性水合(用户点击哪块就先水合哪块)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!