起因
传统 SSR(getServerSideProps):
- 服务端拉所有数据
- render 整个 HTML
- 发给浏览器
慢的部分(如调 5 个 API)阻塞整个 response。用户看白屏几秒。
Streaming SSR 让服务端边 render 边 flush:先把 layout / shell 推
出去,慢部分用 <Suspense fallback> 标记 → 浏览器先显示 fallback →
慢部分 ready 后追加 HTML 替换 fallback。
React 18 引入,19 完善。Next.js App Router 默认就是 streaming。
解决方案
Next.js 14+ App Router
// app/posts/[id]/page.tsx
import { Suspense } from 'react'
async function fetchPost(id) {
await new Promise(r => setTimeout(r, 200)) // 200ms
return { title: '...', body: '...' }
}
async function fetchRelated(id) {
await new Promise(r => setTimeout(r, 2000)) // 2s(慢)
return [{ ... }]
}
async function fetchComments(id) {
await new Promise(r => setTimeout(r, 1500)) // 1.5s
return [{ ... }]
}
export default async function Page({ params }: ...) {
const post = await fetchPost(params.id) // 必须先等这个
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<Suspense fallback={<div>loading related...</div>}>
<Related id={params.id} />
</Suspense>
<Suspense fallback={<div>loading comments...</div>}>
<Comments id={params.id} />
</Suspense>
</article>
)
}
async function Related({ id }) {
const related = await fetchRelated(id)
return <RelatedList items={related} />
}
async function Comments({ id }) {
const comments = await fetchComments(id)
return <CommentList items={comments} />
}
效果:
- 200ms 后浏览器看到 article 标题 + body + 两个 "loading..." fallback
- 1.5s 后 comments 替换 fallback
- 2s 后 related 替换 fallback
vs 传统 SSR 必须等 2s 才有任何内容。首屏感知速度大幅提升。
React 19 标准 API
不用 Next.js 也能 streaming SSR:
import { renderToReadableStream } from 'react-dom/server'
import { Suspense } from 'react'
async function handler(req) {
const stream = await renderToReadableStream(
<html>
<body>
<App />
</body>
</html>,
{
bootstrapScripts: ['/main.js'],
}
)
return new Response(stream, {
headers: { 'Content-Type': 'text/html' },
})
}
App 内 <Suspense> 包慢组件 → 自动 stream。
use() Hook(React 19)
import { use } from 'react'
function Comments({ id }: { id: string }) {
const comments = use(fetchComments(id)) // 直接调 promise!
return <CommentList items={comments} />
}
function Page({ params }) {
return (
<Suspense fallback={<div>loading...</div>}>
<Comments id={params.id} />
</Suspense>
)
}
use(promise) 在 Suspense boundary 内 throw promise → React 等
resolve 后重渲。比 async 函数组件更简洁。
也能 use(context):
function Foo() {
const theme = use(ThemeContext) // 替代 useContext
return ...
}
Partial pre-rendering(Next.js 14+ 实验性)
把"完全静态" + "完全动态" 之外加第三种:"骨架静态 + slot 动态":
export default function Page() {
return (
<>
<StaticHeader /> {/* build 时渲染,CDN cache */}
<Suspense fallback={<Skeleton />}>
<DynamicCart /> {/* 每请求 render */}
</Suspense>
</>
)
}
CDN 立刻返回 static shell + skeleton,CDN 后端 stream 动态部分。
最优 LCP。
与 client-side fetching 对比
// 客户端 fetch(旧 SPA)
function Comments({ id }) {
const { data, isPending } = useQuery({
queryKey: ['comments', id],
queryFn: () => fetchComments(id),
})
if (isPending) return <Skeleton />
return <CommentList items={data} />
}
vs streaming SSR:
| client fetch | streaming SSR | |
|---|---|---|
| TTFB | 极快(CDN) | 略慢(服务端等数据) |
| FCP | 慢(要 JS hydrate + fetch) | 快(HTML 直接来) |
| SEO | 差(爬虫不跑 JS) | 好 |
| 客户端 bundle | 大 | 小(server component 不进 bundle) |
| 复杂度 | 中 | 低(async function 直接写) |
SEO / 首屏感知重 → streaming SSR。
交互重 / 后台 app → client fetch 仍合适。
多服务串行 → 并行
// ❌ 串行
async function Page() {
const a = await fetchA()
const b = await fetchB()
const c = await fetchC()
return <Component data={{ a, b, c }} />
}
// 三个 fetch 加起来时间
// ✅ 并行
async function Page() {
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()])
return <Component data={{ a, b, c }} />
}
或者每个独立 Suspense 各自 stream:
function Page() {
return (
<>
<Suspense fallback={<...>}><A /></Suspense>
<Suspense fallback={<...>}><B /></Suspense>
<Suspense fallback={<...>}><C /></Suspense>
</>
)
}
各自独立"快的先到"。
error boundary 跟 Suspense 配套
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<SlowComponent />
</Suspense>
</ErrorBoundary>
Suspense 处理 loading;ErrorBoundary 处理 throw。两者分工。
Next.js 14:每个文件夹放 error.tsx(自动 boundary)+ loading.tsx
(自动 Suspense)。
性能数据
我们一个商品详情页:
| TTFB | FCP | LCP | |
|---|---|---|---|
| 传统 SSR | 2.4s | 2.4s | 2.4s |
| Streaming + 3 Suspense | 200ms | 350ms | 1.8s |
| + Partial pre-render | 50ms | 100ms | 1.6s |
用户感知差距巨大。Web Vitals 全绿。
注意 / 限制
1. async component 仅 server side
async function ServerComp() { // ✅ 只能在 server component 用
const data = await fetch(...)
return <div>{data}</div>
}
client component 要异步必须用 use():
'use client'
function ClientComp({ promise }) {
const data = use(promise) // ✅
return <div>{data}</div>
}
2. 静态 export 与 streaming 不兼容
next export 模式只有静态 HTML,没 server runtime,streaming
无意义。
3. CDN cache
streaming response 是 chunked transfer。Cloudflare 等 CDN 默认
不缓存 chunked response。要 cache 必须改回 fixed-length。
特性 trade-off。
4. browser 历史问题
Safari 14 之前对 streaming HTML 表现不一致,第一屏可能闪。
现代浏览器都没事。
调试
Network → response headers 看 transfer-encoding: chunked 确认
streaming 模式。
Chrome DevTools Performance → record load → 看 HTML chunks 到达
时间。
与 Astro / Qwik 对比
Astro 默认是 server-render-with-islands(client 只 hydrate 交互
组件),Qwik 是 resumable(client 0 JS 启动),两者都侧重首屏速度。
React 19 streaming + RSC 是同方向但仍有 React runtime 开销。
极致 perf 试试 Qwik;React 生态成熟度 + RSC 仍是主流。
踩过的坑
-
顶层 await 阻塞 stream:page.tsx 顶部
await fetch(...)阻塞
所有 stream。把它移到子组件 + Suspense 才能 stream。 -
Suspense 套了但没 fallback:fallback 不会显示。永远写
fallback={<X />}而非{...}。 -
client component import server lib:bundle 暴大或运行时 error。
严格分 "use client" / server-only 边界。 -
error boundary 在 Suspense 内 vs 外:内只接 stream 那段错;
外接 Suspense 自己 + 内部错。看意图分。 -
dev 跟 prod 行为不同:dev 没 cache + 较多 console,prod 体感
差异大。永远以 prod build 量真实体验。
登录后参与评论。