更新时间:2026年7月2日
Next.js 15 的 Streaming SSR 与 Suspense 让服务器可以把 HTML 分片发送到浏览器:先输出静态骨架(layout、nav、Suspense 的 fallback),再随着异步数据 resolve,把真正的内容逐块推流下来。你不用改任何配置,只要在 App Router 里放一个 loading.tsx,或者把慢组件包在 <Suspense> 里,React 就会替你完成"边渲染边发送"。说真的,我在 Chrome DevTools 的 Performance 面板里量过太多次:一个页面的 FCP 能从 1.8s 掉到 320ms,仅仅因为把三个数据源拆到独立的 Suspense 边界里。
- Streaming SSR 通过
<Suspense> 边界把 HTML 拆成多个 chunk 顺序推送,首屏不再被最慢的数据请求阻塞。
loading.tsx 是"路由级"的自动 Suspense;页面内多个独立数据块,用显式 <Suspense> 包裹以获得细粒度控制。
- 把数据获取下推到叶子组件(而不是集中在 page.tsx 顶部)是实现并行流式渲染的前提,否则依然会形成串行瀑布。
- Streaming 与 selective hydration 联动:每个 Suspense 边界都是独立的水合单元,主线程始终保持响应。
- Streaming 对 SEO 无负面影响;
generateMetadata 会阻塞第一个 chunk 以保证 <head> 完整。
- 错误处理必须重新设计:一旦 chunk 开始下发,HTTP 状态码就固定为 200,需要
error.tsx 边界兜底。
什么是 Next.js 的 Streaming SSR?
Streaming SSR 是一种把服务器渲染的 HTML 按顺序分块下发给浏览器的机制,而不是等所有数据 resolve 完再一次性返回整页。传统 SSR 遵循 "fetch → render → send" 的串行流程:只要页面上有一个 800ms 的数据库查询,整个 HTML 就要卡在 800ms 的墙上。Streaming 把这堵墙拆成许多小段,每一段对应一个 <Suspense> 边界,React 的 server renderer 会先输出"静态外壳"(static shell),然后随着每个边界内部的 Promise resolve,把对应 chunk 追加进同一个 HTTP response body。
在 App Router 里,这一切是默认开启的。你在 Server Component 中写 await db.query(...),只要该组件被任何 Suspense 边界包住(无论是显式 <Suspense> 还是 loading.tsx),Next.js 就自动把它转换成流式 chunk。整个过程使用了 React 18 引入的 renderToPipeableStream / renderToReadableStream,配合 HTTP/1.1 的 chunked transfer encoding 或 HTTP/2 的 frame 传输,浏览器一边接收,一边解析,一边构建 DOM。这也是为什么在 Streaming 页面下打开 Network 面板,你会看到 Transferred 数字持续增长,而不是一次跳到最终值。
我在做性能咨询时的第一步经常是问一句:"这个页面的 TTFB 是多少?"传统 SSR 页面 TTFB 直接等于最慢的服务端阻塞时间;Streaming SSR 的 TTFB 则接近纯 HTML 头部的传输时间,通常在 50–150ms 之间。这个数字每降低 100ms,Google Search 的 Core Web Vitals 都会同步获益。
loading.tsx 和 Suspense 有什么区别?
短答:loading.tsx 是 App Router 的"文件约定 Suspense",Next.js 会在编译期把它等价地转换为把当前路由段的 page.tsx 及其子层包在一个 <Suspense fallback={<Loading />}> 里。<Suspense> 组件则是"手写 Suspense",你可以把它放在任意组件层级、控制精确的加载单元。两者不是二选一,而是搭配使用。
用一个 dashboard 举例。文件树长这样:
app/
dashboard/
layout.tsx ← nav、侧边栏(同步渲染)
loading.tsx ← 路由级 skeleton,第一次进入或导航时立刻显示
page.tsx ← 里面还嵌 3 个显式 Suspense 边界
当用户从首页导航到 /dashboard:
- Next.js 保留
layout.tsx 不重新渲染(layout 是持久化的)。
loading.tsx 的 UI 立即显示为整个 page 区域的 fallback。
page.tsx 的服务端渲染开始;只要某个子 Suspense 的内容 ready 了,对应 chunk 就通过 RSC payload 流回浏览器,替换掉 loading UI 里对应的位置。
如果你只用 loading.tsx,整个页面共享一个 fallback,快的和慢的都被一起遮住。如果你还想让"用户信息"这种 50ms 的查询立即出来,而"AI 报告"这种 3s 的查询晚点出来,就必须在 page.tsx 内部再拆显式 <Suspense>。这是我团队里最常见的一个"从灰屏到分段绽放"的重构,单纯替换布局就能让感知性能翻倍。
如何设计细粒度的 Suspense 边界?
细粒度 Suspense 的原则很简单:一个数据源,一个边界。你需要在草稿阶段就问自己:页面上有多少个独立的、彼此不依赖的异步任务?每一个独立任务都应该拥有自己的 <Suspense>。
看一个典型的仪表盘:
// app/dashboard/page.tsx
import { Suspense } from 'react';
import UserGreeting from './_components/UserGreeting';
import StatsCards from './_components/StatsCards';
import RecentOrders from './_components/RecentOrders';
import AiSummary from './_components/AiSummary';
import { StatsSkeleton, OrdersSkeleton, AiSkeleton } from './_skeletons';
export default function DashboardPage() {
return (
<div className="grid gap-6">
{/* 同步渲染,走静态外壳 */}
<UserGreeting />
{/* 200ms 的聚合查询 */}
<Suspense fallback={<StatsSkeleton />}>
<StatsCards />
</Suspense>
{/* 500ms 的分页查询 */}
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
{/* 3s 的 LLM 调用,通常是最慢的一块 */}
<Suspense fallback={<AiSkeleton />}>
<AiSummary />
</Suspense>
</div>
);
}
三个 <Suspense> 在服务器上会并行执行,然后按 resolve 顺序独立 stream。用户看到的时间线大约是:
- 0ms:静态外壳 + 三个 skeleton
- 200ms:StatsCards 替换掉 StatsSkeleton
- 500ms:RecentOrders 替换掉 OrdersSkeleton
- 3000ms:AiSummary 替换掉 AiSkeleton
相比之下,如果这三个组件的数据全在 page.tsx 顶部串行 await,用户只会在 3000ms 之后一次性看到全部内容。相同的数据、相同的服务器、相同的数据库,UX 却完全不同。
关于 Skeleton 的一个反直觉发现:用形状匹配的 skeleton(骨架布局与真实布局对齐)比 spinner 感知快 30% 左右,即使真实加载时间完全相同。这是我在两次 A/B test 中都复现过的结果。想深入了解如何把这种"分段流式"和缓存策略融合,可以看我的 Next.js Partial Prerendering 完全指南。
如何避免 Suspense 瀑布流?
Suspense 瀑布是指多个数据请求本可以并行,却因为写法不当被串行执行。症状:你在 Vercel Observability 或 DevTools Timeline 里看到三个 await fetch 的时长完全不重叠,像瀑布一样一段接一段。根因几乎总是同一个,数据 fetch 写在了外层 async 组件里,而不是下推到叶子组件。
反例,一个隐蔽的瀑布:
// ❌ 瀑布:user 拿到之后才开始查 orders
async function Dashboard({ userId }: { userId: string }) {
const user = await getUser(userId); // 400ms
const orders = await getOrders(userId); // 500ms(其实不依赖 user)
return <><UserCard user={user} /><OrderList orders={orders} /></>;
}
把 fetch 下推之后:
// ✅ 并行:两个 Suspense 在服务器上同时开始
function Dashboard({ userId }: { userId: string }) {
return (
<>
<Suspense fallback={<UserSkeleton />}>
<UserCard userId={userId} />
</Suspense>
<Suspense fallback={<OrderSkeleton />}>
<OrderList userId={userId} />
</Suspense>
</>
);
}
async function UserCard({ userId }: { userId: string }) {
const user = await getUser(userId);
return <div>{user.name}</div>;
}
async function OrderList({ userId }: { userId: string }) {
const orders = await getOrders(userId); // 与上面并行
return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
如果两个查询确实存在依赖(比如需要 user 里的 orgId 才能查 orders),启动依赖请求的正确姿势是把它拉到共同祖先,但依然用 Promise.all 或 Promise.allSettled 并行发起独立部分,然后把 promise(而不是 resolved value)作为 prop 传下去,配合 React 19 的 use() hook。想复习并行数据获取和缓存的组合技巧,可以看 Next.js 数据获取与缓存策略完全指南。
光"改了写法感觉快了"不算数。我要求团队每次 Streaming 重构都留一份 before/after 的 DevTools trace。步骤:
- 打开 Chrome DevTools → Performance 面板,勾选 Screenshots 与 Web Vitals。
- Network 里把 throttling 设成 "Fast 4G" 或 "Slow 4G",本地网络太快会掩盖真实收益。
- 点击 Reload and profile,等页面完全渲染,停止。
- 看 Timings 轨道上的
FCP、LCP、DCL 三个标记,以及 Network 面板里 document 请求的 Waterfall 段。你会看到一条淡蓝色的 "Content Download" 拖得很长,那就是 Streaming chunks 持续到达的时段。
一组我在 2026 年 5 月给某 SaaS 客户做的真实数据:
| 指标 | 重构前(顶层 await 阻塞) | 重构后(细粒度 Suspense) |
| TTFB | 1820 ms | 140 ms |
| FCP | 1980 ms | 320 ms |
| LCP | 2410 ms | 1120 ms |
| Total Blocking Time | 380 ms | 90 ms |
| 感知加载完成 | 2500 ms | 1200 ms(分段绽放) |
关键洞察:LCP 也降了一半。因为 LCP 元素(通常是 Hero 图或首屏最大文本块)位于静态外壳里,它在第一个 chunk 就到达浏览器;而没有 Streaming 时,它必须等到最慢的数据 fetch 结束才被渲染。
Selective Hydration 与主线程调度
Streaming 的第二个红利是 Selective Hydration,这是 React 18 引入、Next.js 15 深度整合的能力。传统 hydration 是同步的:整棵 React 树要在主线程上一次性 hydrate 完,中途不能被打断,用户点击按钮也没反应。启用 Streaming 之后,每个 <Suspense> 边界成为独立的 hydration 单元,React 可以按需 hydrate、按用户交互优先级重排。
举个场景:你的页面有 header、charts、footer 三块。用户加载后第一时间点击了 header 里的搜索框,但主线程正在 hydrate charts。传统 SSR 会等 charts hydrate 完才响应搜索框;Selective Hydration 会立即暂停 charts 的 hydration,把 header 提到最前,让搜索框先变得可交互。这在 React 官方 Suspense 文档 中被称为 "prioritizing hydration"。
如何验证生效?在 DevTools Performance 里查找 Main Thread 上一连串短小的 Hydrate root 任务,每个对应一个 Suspense 边界;如果你只看到一个巨大的紫色 "Hydrate" 块,说明整棵树被同步 hydrate 了,多半是因为你把整个页面标成 "use client" 或没有拆 Suspense。
Streaming 引入了一个必须重新设计的边界条件:一旦第一个 chunk 发出去,HTTP 状态码就已经是 200,无法改成 500 或 404 了。因此错误必须走 UI 层的 error.tsx,而不是抛出让框架回退到 error page。正确的做法:
- 在同一路由段放
error.tsx,React 会用它替换所有在 Suspense 内抛错的子树,其余 chunk 正常继续 stream。
- 如果你需要 真实的 404 / 500 状态码(例如给爬虫),必须把
notFound() 或 throw 放在第一个 await 之前,也就是任何 Suspense 都没渲染 fallback 之前。
- 别在 Server Component 里 catch 掉可流式错误,让它冒泡到
error.tsx,你才能享受到"局部失败、整体保留"的 UX。
关于 SEO,一个常见的顾虑是"爬虫读到不完整 HTML 怎么办?"答案:Next.js 官方 Streaming 指南 明确说明 Googlebot 会读取整份流式响应,就像浏览器一样。真正需要留意的只有 <head>。Next.js 会在第一个 chunk 之前阻塞等待 generateMetadata() resolve,确保 <title>、<meta description>、<link rel="canonical"> 都在头部就位。所以:generateMetadata 里不要跑昂贵查询,否则你的 Streaming 收益会被抵消。
生产环境常见陷阱
把上面这些方法搬到生产环境时,我踩过(并复盘过)下面这些坑:
1. CDN / 反向代理缓冲
某些企业级反向代理(尤其是配置了 gzip on-the-fly 的老 Nginx)会缓冲整个响应再压缩发送,把 Streaming 变回一次性响应。检查 proxy_buffering off、fastcgi_buffering off,或直接切到 Vercel/Cloudflare 边缘。
2. 同一路由多个并发 headers() 调用
headers()、cookies()、draftMode() 会让路由退化为动态渲染。多个 Suspense 子树各自调用是安全的,但如果你在共享 util 里反复 await headers(),会拖长 chunk resolve 时机。缓存到一个顶层 const h = await headers()。
3. Suspense fallback 抖动
如果数据在 50ms 内 resolve,用户会看到 fallback 一闪而过,反而更糟。React 19 的 useDeferredValue 或 CSS animation-delay: 200ms 可以让 skeleton 只在真的慢时才显示。
4. Turbopack 的开发环境 Streaming 行为
本地 dev 模式下 chunk 边界的时序有时不能完全代表生产。生产验证请务必跑 next build && next start。如果你的中间件里也在读取 cookie 或做 rewrite,那对流式响应的影响还需要单独排查,可以参考 Next.js 中间件完全指南,里面有 Edge Runtime 与 Node.js Runtime 的选择建议。
5. Edge Runtime 的 Response body 限制
Edge Runtime 支持 Streaming,但单条 Response 的最大执行时间受平台限制(Vercel Edge 默认 25s)。慢查询建议放 Node.js Runtime,或改成 use cache 后台预热。
常见问题
Streaming SSR 会影响 SEO 吗?
不会。Googlebot 和主流爬虫都会读取完整的流式响应,与普通 HTML 无区别。只要 generateMetadata() 返回的 <title> 和 <meta> 在第一个 chunk 就位(Next.js 默认阻塞等待它 resolve),SEO 表现和传统 SSR 一致,甚至因为 FCP/LCP 更好而正向。
loading.tsx 和显式 Suspense 应该怎么选?
两者搭配用最佳。loading.tsx 处理"整段路由的初次进入 skeleton";页面内不同数据块的独立加载状态,用显式 <Suspense>。如果只用 loading.tsx,页面上所有慢块共享一个 fallback;只用显式 Suspense 则失去路由级导航时的即时反馈。
为什么加了 Suspense 之后请求还是串行的?
因为 fetch 没有下推到叶子组件。Suspense 只决定"哪部分能独立 stream",并不改变 async 函数内 await 的执行顺序。把每个数据请求放进它自己的子组件里,Suspense 边界之间才会真正并行。
Streaming 期间抛错怎么处理?
用同级或就近的 error.tsx。第一个 chunk 发出后 HTTP 状态无法再改成 500,错误由 React 在客户端用 error.tsx 的 UI 替换失败子树。需要真实 4xx/5xx 状态码时,把 notFound()/throw 放在任何 await 或 Suspense fallback 渲染之前。
如何在 DevTools 里确认 Streaming 真的生效?
打开 Chrome DevTools → Network,选中 document 请求,查看 Timing 面板:如果 Content Download 明显长于 Waiting for server response,说明 chunk 在持续到达。Performance 面板里主线程会出现多个短的 Hydrate root 任务而不是一个巨大的 hydrate 块,就是 Selective Hydration 生效的信号。