陌上人如玉
公子世无双

9.6 流式渲染与 Suspense

9.6 流式渲染与 Suspense

一句话:传统 SSR 必须「等所有数据就绪才返回 HTML」,流式渲染(Streaming SSR)改成「先把外壳(shell)发给浏览器,慢的部分用 Suspense 占位、数据就绪后再流式补上」——从而改善 TTFB 与首屏可见时间。

一、传统 SSR 的瓶颈

传统 SSR(renderToString)的流程:

① 服务端收到请求
② 渲染【整棵】React 树 —— 必须等所有数据都就绪
   · 如果页面里有一个「慢接口」(如 3 秒的推荐数据)
   · 那么整个 HTML 都要等这 3 秒才能生成
③ 一次性返回完整 HTML
④ 浏览器显示 → 下载 JS → 水合

问题:
· TTFB 差(服务端要等最慢的那个数据)
· 首屏可见时间差(用户在这 3 秒里看的是「白屏」)
· 「快的数据」被「慢的数据」拖住了
⭐ 关键洞察:
   页面上不同部分的「数据就绪时间」本来就不同。
   传统 SSR 把它们「串」成了一条最长的链,
   而流式渲染让它们「各自独立地到达」。

二、流式渲染的原理

流式 SSR 的流程(React 18+):

① 服务端开始渲染整棵树
② 渲染到「不挂起的部分」(shell)→ 【立即发送】这些 HTML
   → 浏览器立刻能显示「外壳」(导航、标题、骨架)
③ 渲染到 <Suspense> 边界里的「挂起组件」时:
   · 先发送它的「fallback HTML」(如骨架屏)
   · 【记住位置】,稍后要用真实内容替换它
④ 挂起的数据就绪后:
   · 把「真实内容的 HTML」+ 一段「替换脚本」流式发送
   · 客户端(或浏览器解析时)用真实内容替换掉 fallback
⑤ 同时:客户端 JS 下载完成后开始【水合】
   · shell 先水合(可以交互)
   · 后到的部分到货后再水合(渐进式水合)

⭐ 收益:TTFB 大幅改善、首屏可见更早、慢的部分不阻塞整体
⚠️ 一个常见误解:
   「流式渲染 = 更快的服务端渲染」
   → 不对。服务端「渲染完所有内容」的总时间没变,
     变的是「用户多早看到有用的内容」(并发地做「渲染」与「传输」)。

三、renderToPipeableStream

// 服务端(Node)—— 用 renderToPipeableStream 替代 renderToString
import { renderToPipeableStream } from 'react-dom/server';
import { Transform } from 'node:stream';

app.get('*', (req, res) => {
  const { pipe, abort } = renderToPipeableStream(
    <App location={req.url} />,
    {
      // ① shell(外壳)渲染完成 → 立即开始发送
      onShellReady() {
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/html; charset=utf-8');
        res.setHeader('Transfer-Encoding', 'chunked');       // ⭐ 分块传输
        pipe(res);
      },

      // ② shell 渲染就失败 → 整个页面都无法流式,返回错误页
      onShellError(error) {
        res.statusCode = 500;
        res.setHeader('Content-Type', 'text/html; charset=utf-8');
        res.send('<!doctype html><h1>出错了</h1>');
      },

      // ③ 某个 Suspense 边界渲染失败(shell 已发出)
      onError(error) {
        console.error(error);              // ⭐ 必须记录(否则错误被静默)
      },

      // ④ 全部内容(含所有挂起部分)就绪 → 适合爬虫/静态生成
      onAllReady() {
        // 爬虫请求时可以「等全部就绪」再返回(避免爬虫拿到不完整内容)
      },
    }
  );

  // ⑤ 超时保护:10 秒后强制放弃未完成的部分
  setTimeout(() => abort(), 10_000);
});
// 同一个应用(React 组件)—— 用 Suspense 划分「可后到的部分」
function App() {
  return (
    <html lang="zh-CN">
      <body>
        <Header />                                {/* 快 → 进 shell */}

        <Suspense fallback={<CommentsSkeleton />}>
          <Comments />                            {/* ⭐ 慢 → 服务端先给骨架,后补内容 */}
        </Suspense>

        <Suspense fallback={<RecommendSkeleton />}>
          <Recommendations />                     {/* ⭐ 另一个独立慢块 */}
        </Suspense>

        <Footer />
      </body>
    </html>
  );
}
// ✅ 区分「爬虫」与「浏览器」(爬虫看不到流式替换,最好等全部就绪)
function shouldStream(req: Request) {
  const ua = req.headers.get('user-agent') ?? '';
  return !/bot|crawler|spider|crawling/i.test(ua);
}
// Next.js 等框架内部已经处理了这类策略
API 用途
renderToPipeableStream(Node) 流式 SSR(Node 流)
renderToReadableStream(Web Streams) 流式 SSR(Edge/Deno)
renderToString 传统一次性 SSR(不支持流式)
onShellReady / onShellError / onAllReady / onError 流式渲染的四个回调

四、Suspense 边界的划分

// ❌ 边界太大:一个慢块拖住整块内容
<Suspense fallback={<PageSkeleton />}>
  <PageContent />          {/* 里面既有快数据也有慢数据 → 都在等 */}
</Suspense>
// ✅ 边界按「独立且较慢的数据块」划分
function PageContent() {
  return (
    <>
      <Title />                                    {/* 快(同步或已就绪)*/}
      <Suspense fallback={<AuthorSkeleton />}>
        <Author />                                 {/* 中等 */}
      </Suspense>
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />                               {/* 慢 */}
      </Suspense>
    </>
  );
}
四条划分原则:
① 【按「慢」划分】——只把「明显较慢的、可独立就绪的部分」放进 Suspense
   (快的内容不该被骨架替代)
② 【同一时间就绪的内容放一起】——否则会「先后闪多次骨架」
③ 【fallback 要有「确定高度」】——避免内容出现时的布局跳动(CLS)
④ 【边界不要太多】——太多会导致「骨架海」(视觉噪音)

⭐ 一个经验判断:
   「如果没有 Suspense,用户会等这个数据多久?」
   · < 100ms → 不需要(直接等)
   · 100ms ~ 1s → 可以考虑(提升感知)
   · > 1s → 应该用(否则用户等到怀疑人生)

五、loading.tsx 与 <Suspense> 的关系

// app/posts/loading.tsx —— Next.js 会自动用它包住 page.tsx
export default function Loading() {
  return <PostsSkeleton />;
}
⭐ 两者其实是「同一机制的不同粒度」:

`loading.tsx`:
  · 文件约定,Next.js 自动用 <Suspense fallback={<Loading />}> 包住整个路由段
  · 粒度:【整页/整段】
  · 零代码(写上文件就有)

`<Suspense>`:
  · 手动包裹,粒度精确到「某个数据块」
  · 粒度:【组件级】
  · 更细腻(可以让「快的先显示」)

✅ 实践组合:
   · 用 loading.tsx 提供「进入页面时」的基础加载态
   · 在页面内部用 <Suspense> 划分「多个独立数据块」→ 更快的感知
// ✅ 组合示例
// app/posts/loading.tsx —— 整页加载态(路由切换时)
export default function Loading() {
  return <PageSkeleton />;
}

// app/posts/page.tsx —— 页面内部再细分
export default function PostsPage() {
  return (
    <>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />                    {/* 统计(较快) */}
      </Suspense>

      <Suspense fallback={<ListSkeleton />}>
        <PostList />                 {/* 列表(较慢) */}
      </Suspense>
    </>
  );
}

六、选择性水合(Selective Hydration)

React 18 的能力:水合也可以「按优先级进行」

① 页面上的多个 Suspense 边界「各自就绪、各自水合」
② 如果用户「点击了某个还没水合的边界」,React 会【优先水合那个边界】
   → 用户感觉「点了就有反应」(而不是等所有 JS 都水合完)
③ 这依赖「事件重放」:React 会先在根容器捕获这次点击,
   水合完成后「重放」它

⚠️ 这是框架/React 内部行为,业务代码通常不用手动处理;
   但它解释了「为什么用 Suspense 划分边界值得一提」(不只为视觉,也影响水合优先级)。

七、与 RSC 的配合

// ✅ RSC + 流式渲染:服务端组件的「慢数据」用 Suspense 流式补上
export default function Page() {
  return (
    <div>
      <h1>商品详情</h1>

      <Suspense fallback={<PriceSkeleton />}>
        <Price />                        {/* async Server Component(慢) */}
      </Suspense>

      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews />                      {/* 另一个 async Server Component */}
      </Suspense>
    </div>
  );
}

async function Price() {
  const price = await getPrice();        // 服务端直接查(慢)
  return <span>¥{price}</span>;
}
⭐ RSC + 流式渲染的组合价值:
① 服务端组件的代码「不进客户端包」
② 慢的服务端数据用 Suspense「流式后到」
③ 客户端 JS 更少 → 水合更快 → 更快可交互
→ 这是 Next.js App Router 默认推荐的组织方式

八、常见坑

// ① 用 renderToString(不支持流式,白等所有数据)
renderToString(<App />);                      // ⚠️ 对流式无用
// ② 只用了流式 API 但没在应用里加 <Suspense>(没有可流式的边界 → 等价于传统 SSR)
// ③ 边界划分太粗(一个慢块拖住整块)
<Suspense fallback={<PageSkeleton />}><WholePage /></Suspense>;   // ⚠️
// ④ fallback「没有高度」导致布局跳动(CLS)
<Suspense fallback={<div />}>                  // ⚠️
// ⑤ 所有内容都包 Suspense → 「骨架海」(视觉噪音 + 水合碎片化)
// ⑥ 把「必须同步显示的关键内容」(如标题、价格)放进 Suspense
// ⑦ 忘记 onError 记录错误(流式下的错误被静默)
onError(error) { /* 什么都没做 */ }             // ⚠️
// ⑧ 没有超时保护(一个卡住的请求会让连接一直挂着)
setTimeout(() => abort(), 10_000);             // ✅
// ⑨ 给爬虫返回「带骨架的流式 HTML」(应该等全部就绪再返回)
// ⑩ 认为「流式渲染能让服务端渲染更快」
//    → 总渲染时间不变,变的是「用户多早看到内容」
// ⑪ 在「已经发出的 shell」里依赖「后面才就绪的数据」(不可能,shell 已经发出)
// ⑫ 用「客户端 fetch」代替「服务端挂起」(不会流式,只是骨架 + 客户端请求)
// ⑬ 在 Suspense 边界里做「有副作用的渲染」(可能重复执行,见 4.3)
// ⑭ 中间代理/CDN「缓冲」响应(导致流式失效,看起来和传统 SSR 一样)
//    ✅ 关闭代理缓冲(如 Nginx 的 proxy_buffering off)
// ⑮ 忽略「水合时的 mismatch」(流式内容替换后再水合,仍要保证一致,见 9.4)
// ⑭ 的 Nginx 配置示例
// proxy_buffering off;
// proxy_cache off;
// chunked_transfer_encoding on;
// 或者对「流式响应」设置响应头:
res.setHeader('X-Accel-Buffering', 'no');

九、面试延伸

  1. 「什么是流式渲染(Streaming SSR)?」
    流式渲染指「服务端边渲染边把 HTML 分块发给浏览器」:先把「不挂起的部分(shell)」渲染完立即发送(TTFB 显著改善、首屏更快可见),遇到 <Suspense> 边界里的慢内容时先发送 fallback 的 HTML,等数据就绪后再把真实内容 + 替换脚本流式发送,由浏览器/客户端无缝替换。它解决的是「一个慢请求阻塞整个页面」的问题(传统 SSR 必须等所有数据就绪才能返回)。

  2. 「流式渲染会让服务端渲染更快吗?」
    不会。服务端「渲染完所有内容」的总时间基本不变——流式改变的是「用户多早看到有用的内容」(把「渲染」与「传输」并发起来:一边渲染后面的部分,一边把已就绪的部分发出去)。所以它的收益体现在 TTFB、FCP/LCP 这些「可见性指标」上,而不是「服务端 CPU 时间」。这也是「为什么它常与 Suspense 一起提」的原因——有 Suspense 边界才有可流式的切分点。

  3. 「Suspense 边界应该怎么划分?」
    四条原则:① 按「慢」划分——只把「明显较慢且可独立就绪」的内容放进 Suspense(快的内容不该被骨架替代);② 同一时间就绪的内容放一起——否则会「先后闪多次骨架」;③ fallback 要有「确定高度」——避免内容出现时的布局跳动(CLS);④ 边界不要过多——太多会造成「骨架海」与「水合碎片化」。经验阈值是「没有 Suspense 时用户要等多于 ~1 秒」的数据才值得单独包。

  4. 「loading.tsx 和 <Suspense> 有什么区别?」
    它们是同一机制的不同粒度:① loading.tsx 是「文件约定」——Next.js 会自动用 <Suspense fallback={<Loading />}> 包住整个路由段的页面(粒度是「整页/整段」,零代码);② <Suspense> 是「手动包裹」——粒度精确到「某个数据块」,可以让「快的部分先显示、慢的部分后补」。实践组合是用 loading.tsx 提供「进入页面的基础加载态」,在页面内部再用 <Suspense> 细分(更好的感知)。

  5. 「什么是选择性水合(Selective Hydration)?」
    指「水合可以按优先级进行」——React 18 里,页面上的多个 Suspense 边界「各自就绪、各自水合」;如果用户点击了某个还没水合的边界,React 会优先水合那块(通过「在根容器先捕获事件、水合完成后重放该事件」实现)——用户感觉「点了就有反应」,而不是「等所有 JS 水合完」。这是框架/React 内部的行为,业务代码通常不用手动处理,但它解释了「为什么用 Suspense 划分边界值得一提」(不只为视觉,也影响水合优先级)。

  6. 「流式渲染有什么代价与限制?」
    四点:① 中间设备可能缓冲——Nginx/CDN 默认会缓冲响应,导致「看起来还是等全部」→ 需要关闭缓冲(proxy_buffering off);② 错误处理变复杂——shell 已发出后,边界内出错只能「在已发出的 HTML 里插入错误提示」(onError 必须记录,否则错误被静默);③ 爬虫可能拿到不完整内容——通常的做法是「检测爬虫 → 等 onAllReady 再返回」;④ 水合仍要一致——流式替换后再水合,仍需保证「客户端首次渲染与服务端一致」(否则仍有 mismatch)。

  7. 「为什么要给流式渲染加超时?」
    因为「一个卡住的请求可能让连接一直挂着」——shell 已经发出去了,但某个 Suspense 边界的数据永远不返回,那么这个 HTTP 连接会一直保持(占用服务端资源、用户看到的是永久骨架)。renderToPipeableStream 返回的 abort() 可以用来「强制放弃未完成的部分并结束响应」。实践上会设置一个「全局超时」(如 10 秒),超时后 abort,让页面「以已就绪的部分结束」(通常是显示「加载失败/稍后重试」的兜底 UI)。

  8. 「怎么判断页面适合流式渲染?」
    三个特征:① 页面有多块「就绪时间差异明显」的数据(一个快的列表 + 一个慢的推荐/评论);② 用户对「首屏可见」敏感(内容型页面、电商详情);③ 服务端获取数据会较慢(数据库复杂查询、聚合多个内部服务)。不适合的情况是:① 页面几乎全是「同步/很快」的内容(流式没有可切分点);② 纯客户端应用(没有 SSR);③ 爬虫为主要访问者(通常选择「等全部就绪」)。核心判断标准是「是否存在『慢的部分拖累快的内容』」。

一句话速记

流式渲染 = 「shell 先发、慢的部分用 Suspense 占位、就绪后流式补上」(renderToPipeableStream + onShellReady/onShellError/onAllReady/onError + abort() 超时保护);它不会让服务端渲染更快,而是改善 TTFB / FCP(把「渲染」与「传输」并发);用 Suspense 划分「独立且较慢的数据块」(快的不要包、同期的放一起、fallback 要有确定高度、边界别太多);loading.tsx 是「整段级」的文件约定,<Suspense> 是「组件级」,两者组合使用;必须关闭代理缓冲(否则流式失效)、必须记录 onError、给爬虫等 onAllReady;React 18 还支持选择性水合(用户点击哪块就先水合哪块)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 9.6 流式渲染与 Suspense

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫