React 19 Streaming SSR + Suspense:渐进式首屏渲染

起因

传统 SSR(getServerSideProps):

  1. 服务端拉所有数据
  2. render 整个 HTML
  3. 发给浏览器

慢的部分(如调 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 仍是主流。

踩过的坑

  1. 顶层 await 阻塞 stream:page.tsx 顶部 await fetch(...) 阻塞
    所有 stream。把它移到子组件 + Suspense 才能 stream。

  2. Suspense 套了但没 fallback:fallback 不会显示。永远写
    fallback={<X />} 而非 {...}

  3. client component import server lib:bundle 暴大或运行时 error。
    严格分 "use client" / server-only 边界。

  4. error boundary 在 Suspense 内 vs 外:内只接 stream 那段错;
    外接 Suspense 自己 + 内部错。看意图分。

  5. dev 跟 prod 行为不同:dev 没 cache + 较多 console,prod 体感
    差异大。永远以 prod build 量真实体验。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。