RSC 流式别堵死外壳
前端约 5 分钟阅读

RSC 流式别堵死外壳

顶层 await 会让流式渲染名存实亡。本文拆开 RSC 在生产里最常见的壳阻塞、服务端瀑布流和 use client 上爬,并给出可排查顺序。

上周复查一个 App Router 项目的首屏瀑布图,发现一个尴尬事实:接口其实不算慢,慢的是页面“迟迟不露面”。Chrome 里空白好久,然后内容一口气砸下来。团队都以为是 RSC 没开好。其实 RSC 开了,只是把流式渲染的水龙头拧死了。

React Server Components 在 2026 年已经不是实验品。Next.js、生产环境都在用。但很多团队把它当成“默认更快的开关”。它不是开关,是边界:数据在哪边取、JS 往哪边送、哪些 HTML 可以先流出去。边界画错了,性能账会从浏览器挪到服务器,数字更好看,用户照样骂。

顶层 await 会堵死外壳

最常见的坑几乎一眼能认出来:

tsx
// app/dashboard/page.tsx
export default async function Page() {
  const user = await getUser()          // 慢
  const metrics = await getMetrics()    // 更慢
  return (
    <main>
      <Header user={user} />
      <Metrics data={metrics} />
    </main>
  )
}

在 page 顶部串行 await,服务器要等全部数据齐了才开始吐第一段 HTML。流式渲染在协议层还在,但你没有可先发送的壳。用户看到的是白屏,不是“骨架先到、数字后到”。

LogRocket 等工程文把这类问题归纳得很直:壳被慢请求堵住时,Suspense 再漂亮也轮不到上场。你需要的是“先返回能画的东西”。

改法不复杂:先返回静态壳,把慢数据丢进带 Suspense 的子树。

tsx
import { Suspense } from 'react'

export default function Page() {
  return (
    <main>
      <HeaderShell />
      <Suspense fallback={<MetricsSkeleton />}>
        <MetricsPanel />
      </Suspense>
    </main>
  )
}

async function MetricsPanel() {
  const metrics = await getMetrics()
  return <Metrics data={metrics} />
}

HeaderShell 不依赖慢接口时,首字节可以立刻走。指标区自己转圈。体感差通常就出在“整页等齐”上。

再补一条:layout 里也不要随便顶层 await 用户会话或远程配置。layout 卡住,下面所有子路由一起陪葬。能下沉到具体 segment 的数据,就别抬到根布局。

服务端瀑布流还是瀑布流

把请求挪到服务端,不等于并行。父组件 await 完再渲染子组件,子组件再 await,链路照样串行。只是瀑布从图里的浏览器 Network 挪到了 Node 进程。监控面板上“客户端请求变少了”,你可能只是把瀑布藏起来了。

更稳的写法是:同层互不依赖的数据,在同一层一起发起;真有依赖再串。别为了“组件干净”把请求拆成三层嵌套 await。

tsx
async function DashboardBody() {
  const [orders, inventory] = await Promise.all([
    getOrders(),
    getInventory(),
  ])
  return (
    <>
      <Orders data={orders} />
      <Inventory data={inventory} />
    </>
  )
}

有依赖时再显式串,并在 UI 上分开边界:先出订单列表,库存卡片单独 Suspense。不要为了一次 Promise.all 把无关模块绑死在同一个最慢源上。

图:流式外壳与慢数据分区

"use client" 往上爬

另一个慢性病:为了给一个按钮加 onClick,把半棵树标成 Client Component。"use client" 是入口,不是装饰。入口一抬高,下面整棵子树的服务器优势就没了,包体积和 hydration 成本一起回来。

习惯是:Client 尽量贴叶子。列表、布局、数据拼接留在 Server;交互控件单独拆成小组件。需要共享状态时,用细的 Client 边界包住状态,而不是把整页变成客户端岛。

我见过最夸张的一次,是把整个 app/(dashboard)/layout.tsx 标成 client,只因为侧边栏要折叠。折叠状态用一个 30 行的 Client 组件就够了,犯不着把数据层拽回浏览器。

不是所有数据都该阻塞首屏

RSC + Streaming 的收益,一半来自“分清关键路径”。用户身份条、首屏列表可以优先;次要统计、推荐位、审计旁路可以后到。把所有 fetch 都当成 LCP 必需品,等于自己关掉 partial streaming。

Next.js 里还可以配合 Partial Prerendering:静态壳预渲染,动态洞按请求流。前提仍是:你得先在组件树里画出哪些是壳、哪些是洞。洞太多且彼此耦合,PPR 也救不了设计问题。

缓存策略也要跟着边界走。能静态的壳别因读一次 cookie 整页动态;能按 tag 失效的数据,别动不动 revalidatePath 扫荡整站。RSC 让数据获取更分散,缓存键若仍按“整页”思考,你会得到一堆难以解释的新鲜度问题。

我怎么排查

  1. 看 TTFB 和 First Contentful Paint 的间距。TTFB 还行但 FCP 很晚,多半是服务器在等数据才吐壳。
  2. 搜 page.tsx / layout.tsx 里的顶层 await,问一句:这段不返回,用户连导航栏都看不到吗?
  3. 数 "use client" 的位置。出现在 layout 或大段业务容器上,就要警惕。
  4. 用 React / Next 的调试信息看 Suspense 边界是否真的拆开了慢源。
  5. 在预发制造一个 2 秒的假延迟接口,只挂在次要模块上——若首屏跟着慢 2 秒,边界就画错了。

小结

RSC 不会自动让你的站变快。它把性能问题从“客户端会不会 hydrate 爆炸”换成了“服务端会不会把壳堵死、会不会在 Node 里重演瀑布流”。

先给用户一个能看的外壳,再让慢数据自己入座。这个顺序对了,流式才有意义。其余的优化——缓存、并行请求、叶子级 Client——都排在这之后。

2026 年还在纠结要不要上 RSC 的团队,不如先回答更土的问题:你的首屏,到底在等谁。

相关文章