流式渲染 · 别等全部,边算边发

传统 SSR 必须等整页渲染完才能发出第一个字节——一个慢查询拖垮整页。流式渲染让服务器先发出外壳,再把慢的区块「边算边发」流式补上,TTFB 大降、首屏更快。这一章讲清它的原理与 Suspense。

作者 David更新于 第 5 篇(共 17 篇)

流式渲染 · 别等全部,边算边发

传统 SSR 有个致命节奏问题:必须等整个页面渲染完,才能发出第一个字节。

想象一个页面:顶部导航、中间是要查数据库的商品列表、底部是要调第三方 API 的推荐位。传统 SSR 会傻等——等最慢的那个(比如卡了 800ms 的推荐 API)也返回、整页 HTML 全部拼好,才把第一个字节发给浏览器。于是一个慢区块,拖垮了整页的 TTFB。导航明明瞬间就能给,却要陪着推荐位一起等。

流式渲染(streaming SSR) 解决的正是这个:先发能发的,慢的随后补。

边算边发

流式的思路是把 HTML 拆成一块块,谁先就绪就先发谁:

图表加载中…

浏览器一拿到外壳就能立刻画出导航和骨架(TTFB 极短),用户马上看到「页面在动」;那些慢的区块先显示 loading 占位,等服务端数据就绪,再通过同一条 HTTP 响应流把真实内容推过去,自动替换掉占位。一个慢查询,不再拖累其余部分。

Suspense:声明「这块可以晚点来」

在 React 里,流式的开关是 <Suspense>。你用它把「可能会慢」的区块包起来,并给个 fallback(占位):

<Layout>
  <Nav />                          {/* 立刻就绪,先发 */}
  <Suspense fallback={<Skeleton />}>
    <SlowProductList />            {/* 慢,先发 Skeleton,就绪后流式补上 */}
  </Suspense>
</Layout>

服务端用 renderToPipeableStream(而非老的 renderToString)渲染:遇到 <Suspense>,它不阻塞——先把 fallback 连同外壳发出去,等 <SlowProductList> 的数据好了,再把真实 HTML 作为后续 chunk 流式发送,并附一小段脚本把占位替换掉。<Suspense> 就是你对框架说的那句「这块可以晚点来,别等它」。

各框架都有自己的「Suspense 式」出口:Next.js App Router 用 <Suspense> 和 loading.js(整个路由段的流式占位);Remix 用 defer() + <Await>;SvelteKit 的 load 可以返回 promise 让页面先渲染。名字不同,做的是同一件事:声明哪些区块允许「先占位、后补上」。

流式还顺带改善了水合

流式不只让首屏更快,还让水合更聪明——这叫选择性水合(selective hydration):

  • 流式发来的区块,可以各自独立地水合,不必等整页 HTML 全到齐。
  • React 会优先水合用户正在交互的区块——你点了哪儿,它就先「激活」哪儿,而不是死板地从上到下。

于是上一章那个「看得见点不动」的空档,被大大压缩了:内容一块块地到达、一块块地变得可交互,而不是「整页一起卡着等水合」。

代价与注意点

流式不是纯赚,有几处要留心:

① 状态码和响应头必须先发。 HTTP 响应的头部(状态码、Content-Type、以及后面要讲的缓存头)在流开始时就得定下来——因为第一个字节已经发出去了,你没法「发到一半改成 500」。这对错误处理和缓存策略有实际影响。 ② 一旦开始流,就难以整体缓存。 一个流式响应是「边生成边发」的,想把它当成一个完整对象缓存起来,比缓存一份定死的 HTML 要复杂——这是流式和 CDN 缓存 之间的一处张力,后面会再提。 ③ SEO 要确认爬虫等得到。 流式补上的内容是后到的,得确认目标爬虫会等完整个流(主流搜索引擎会,但一些简单爬虫可能只取先到的外壳)。

小结与下一章

流式渲染把「等整页渲染完才发」改成「先发外壳、慢区块随后流式补上」,TTFB 大降、首屏更快,还借选择性水合缓解了「看得见点不动」。代价是响应头要提前定、整体缓存变复杂。它解决的是「什么时候发」。

但还有个更根本的开销没动:水合要把所有组件在客户端再跑一遍、把所有组件 JS 都发给浏览器。有没有办法让一部分组件根本不发到客户端、根本不水合?这就是服务端组件与岛屿架构的思路。👉 服务端组件:只在服务端跑的那部分。

参考文献

本页目录