KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

04 · 流式渲染:一个响应,两批内容 — keel 龙骨

这一章回答:一次渲染的结果,凭什么能分几次送出去?为什么 fallback 后面的内容不用陪着一起等?

这一章回答:一次渲染的结果,凭什么能分几次送出去?为什么 fallback 后面的内容不用陪着一起等?

第 02 章确定了"这页要不要每次重渲染",第 03 章确定了"取回来的数据存不存"。这两件事都假设了一个前提:渲染产出的是一份完整 HTML,一次发完。

慢接口会让这个前提崩掉。

服务端要等最慢的那一段才能发出第一个字节 —— 那用户就对着白屏等 3 秒。

现场

一个列表页,数据从三个地方取:用户信息(缓存里有,快)、推荐的商品(自家库,20ms)、和库存状态(第三方接口,平均 3 秒,偶尔 5 秒以上)。

页面的骨架只需要用户信息和推荐商品,库存状态只影响每一行右侧那个小标。但上线后同事反馈:整页白屏 3 秒,然后一次性全部出现。

把响应打出来看,确实如此:第一个字节 3 秒后才到。

问题不在于"接口慢"——接口就是这么慢,改不了。问题在于页面把快的那 2 个和慢的那 1 个绑成了一捆。整页要等所有人到齐才发车。

先猜一下:如果把这个慢接口单独用 <Suspense> 包起来,用户会先看到什么?再看后面实测的到达时刻,看你的猜测对不对。

一、一个响应,两批内容

实验页的结构就三段:壳 → <Suspense fallback={加载中}> 里包一个 await 3000ms 的慢组件 → 尾巴。服务端按 Date.now() 打点,记录每一个 HTTP chunk 的到达时刻:

=== 头部 ===  {"at":11ms,"status":200,"te":"chunked","cl":null,
               "cc":"private, no-cache, no-store, max-age=0, must-revalidate"}

=== chunk 到达时刻(ms)===
  #0  t=    12ms  len=  1849
  #1  t=    12ms  len=  5777
  #2  t=  3025ms  len=   192
  #3  t=  3025ms  len=   963
  #4  t=  3025ms  len=    14

=== 标记出现的时机 ===
  shell     → chunk#0  t=12ms
  fallback  → chunk#0  t=12ms
  slow      → chunk#3  t=3025ms
  tail      → chunk#0  t=12ms

=== 总耗时 === 3026 ms;  总字节 8655

(第二次独立运行:头部 9ms、slow 落在 chunk#3 的 3014ms、总 3015ms。稳定复现。)

三件事:

  1. 第一批在 11 毫秒就出去了(头部),而不是 3 秒后。总耗时确实还是 3026ms(那 3 秒省不掉),但用户的屏幕不再是白的。
  2. slow 在 3025ms 才到,晚了一批 —— 它在等那个 3 秒。
  3. 🔴 tail(Suspense 之后的内容)在 chunk#0,12 毫秒就到了。

第 3 点是这一章最反直觉的地方,也是最多人猜错的地方。

二、为什么 tail 也在第一批里

自然的猜测是:<Suspense> 像一个闸门,闸门之后的内容都得等闸门开。所以 tail 应该跟 slow 一起在 3025ms 到。

实测不是。原因是服务端在 fallback 的位置留了一个占位,而不是留下一个"待会儿再从这里继续"的断点:

第一批(12ms)
  ...壳的 HTML...
  <p data-probe="fallback">加载中(fallback,先到)</p>   ← 占位,先把位置占住
  <p data-probe="tail">尾巴(在 Suspense 之后)</p>       ← 后面的内容照常往下写
  ...一段脚本,记录"这个占位等下要被换掉"...

第二批(3025ms)
  ...一小段 HTML 片段 + 一段脚本:把上面的占位替换成真内容...

所以 <Suspense> 的作用面是它包住的那一段,不是"它之后的所有内容"。它把"要等的东西"隔离成一小块可以后补的区域,其余部分照常先发。

这也解释了为什么第二批只有 192 + 963 + 14 = 1169 字节 —— 它带的不是一整页,只是一段用来填坑的片段和换坑的脚本。整页 8795 字节里,第一批就走了 7626 字节(86.7%)。

(顺带一个容易看错的地方:上面探针输出里的 总字节 8655 是解码后的字符数,而每个 chunk 后面的 len 是字节数。两者不等是因为这份 HTML 里中文很多,一个汉字在 UTF-8 下占三字节。看流式的量,认 len 那一列。)

可复用的推论:如果你发现"加了 <Suspense> 还是整页白屏",先确认慢的东西是不是真的在 Suspense 边界里面。把一个 3 秒的 await 写在 Suspense 外面的父组件里,占位根本没机会发出,效果和没包一样。这是下一节要处理的事。

三、头部先发出去了,所以有些事就不能再改了

注意头部那一行的两个字段:

"te":"chunked"        ← transfer-encoding: chunked
"cl":null             ← 没有 content-length

这是流式的必要条件,也是它的代价:

这条约束的工程含义比它看起来重:"能不能缓存"必须在发出第一个字节之前决定完。 你没机会说"我先发个壳,等下看数据全不全,齐全的话标成可缓存"。所以缓存策略(第 03 章那两层的决定)在时间上早于渲染完成。

顺带把第 02 章的结论接上:/stream 这条路由的判定是 ƒ,响应头是 private, no-cache, no-store, max-age=0, must-revalidate —— 流式页天然就是"每请求重渲染",因为它必须每次重新走一遍渲染流程才能决定第一批里放什么。

四、Suspense 该划在哪:边界的位置比数量重要

有了机制,划边界的判据就很直接:把"需要等的东西"尽可能圈小,让不依赖它的部分能先走。

划法 第一批里有什么 用户先看到
不包 什么都没有 白屏,直到最慢的那段回来
包住慢组件 壳 + 占位 + 尾巴 完整骨架 + "加载中",慢那块后补
包住整页 只有占位 一个"加载中",等于换了个样子的白屏
await 写在父组件里再传给子组件 什么都没有 白屏 —— 边界画错了,慢数据在边界之外

最后一行是实践里最常见的失败形态:边界看着加了,但 await 在边界外。 React 只能在它渲染到 <Suspense> 那一刻才知道"这段要等",如果父组件自己先 await 完了才把结果传下去,父组件早就卡住了,边界没有机会生效。

还有一个顺序上的细节,实测过的:多个 Suspense 边界是并行的,不是串行的。 两个各 3000ms 的慢块分别包住,总耗时还是 3028ms,不是 6 秒:

### /stream-parallel(两个各 3 秒的慢块,各自包在自己的 Suspense 里)
=== chunk 到达时刻(ms)===
  #0  t=    16ms  len=  1845
  #1  t=    16ms  len=  5837
  #2  t=  3026ms  len=   351
  #3  t=  3027ms  len=  1113
  #4  t=  3027ms  len=    14
=== 标记出现的时机 ===
  shell     → chunk#0  t=16ms
  fb-a      → chunk#0  t=16ms
  fb-b      → chunk#0  t=16ms
  slow-a    → chunk#3  t=3027ms
  slow-b    → chunk#3  t=3027ms
  tail      → chunk#0  t=16ms
=== 总耗时 === 3028 ms;  总字节 9160

这里还有一个容易漏掉的细节:两个慢块的结果落在同一个 chunk(#3)里,不是一个坑来一批。第二批是"此刻所有已就绪的坑一起填"。所以你不能靠数 chunk 个数来数"有几个边界"——chunk 的切法取决于内部的 flush 时机,不是边界个数。

这条推论和第 03 章 fetch 缓存的道理是一条:能并行的事别排队,因为等待会叠加。 三个慢接口串在一条 await 链上就是 9 秒;各自包进边界就是 3 秒。

五、两批的分工:第一批给"能看的",第二批给"能算的"

把两批的字节数和内容对齐,分工就很清楚了:

到达时刻 字节 里面装什么 什么时候能产生
第一批 12ms 1849 + 5777 = 7626 <!DOCTYPE>、<head>、壳的 HTML、fallback 占位、尾巴、以及一段用来"待会儿换占位"的脚本 只依赖立刻能算出来的东西
第二批 3025ms 192 + 963 + 14 = 1169 慢数据那一段的 HTML 片段 + 把它替换进占位的脚本 必须等那 3 秒

比例悬殊得有点反直觉:整页 86.7% 的内容在 12 毫秒就走了,2.8 秒里等的只是 13.3%。 所以流式真正改变的是"用户第一次看到东西的时刻",不是总耗时。把你的页面按这个表拆一遍,往往能发现"等的那 3 秒"其实只影响页面上很小一块 —— 但因为它被画在了同一个边界外,整页都得陪着等。

这也给了一个判断"值不值得做流式"的量化口径:慢的东西占页面多大比例、它是不是在关键路径上(首屏可见区域里)。 慢,但只影响页面底部一个"最近更新"小组件,收益就很大;慢,且它是首屏的主内容,流式也只能让你先看到一个骨架 —— 收益是"骨架 vs 白屏",仍然有,但别指望它把体验变成"秒开"。

六、流式的另一面:错误也不再是一次性的

第一批发出去之后状态码就冻结了,这条约束有一个直接后果:在流开始之后才发生的错误,不可能再表现为一个 500 页面。 它只能以页面内的一段内容出现(那个"占位替换"的脚本里可以带错误态),或者干脆什么都不替换、让占位一直留在那里。

这也是为什么实践里流式页面的错误处理要分两处做:

再加上一条部署层的现实:流式依赖中间层不缓冲。本课是在 next start 直连下量的,中间隔一层会做缓冲的反向代理或 CDN 会是什么结果,本课没有验证,见「生产边界」。

sequenceDiagram
  participant B as 浏览器
  participant N as Next 服务端
  participant S as 慢数据源(3s)
  B->>N: GET /stream
  Note over N: 渲染到 Suspense 边界
  N->>S: 发起慢数据请求(不 await 到底)
  N-->>B: 第一批 12ms:头部 + 壳 + fallback 占位 + 尾巴
  Note over B: 屏幕已经有内容了
  S-->>N: 3s 后返回
  N-->>B: 第二批 3025ms:一段替换占位的片段
  Note over B: 慢的那块被填上

本章脉络

生产边界

动手:可观察结果

  1. 不要用浏览器看流式。 浏览器会把内容渲染出来,但你看不到"分了几批"。
    用 node 直接读 chunk 到达时刻:http.get(...),在 res.on("data", ...) 里记 Date.now() - T0。
  2. 给响应打三个 data-probe 标记:shell(壳)、fallback(占位)、slow(慢数据)、tail(Suspense 之后)。
    写完第一个 chunk,去每个标记落在哪个 chunk 里。
  3. 把 await 从被包住的子组件挪到父组件里,重建、再量一次。
  4. 看头部:transfer-encoding 是不是 chunked、有没有 content-length。
  5. 再加一个同样慢的块,也各自包在自己的边界里,重建、再量一次。重点看总耗时有没有变成 6 秒、以及两个慢块的结果是落在同一批还是两批。

可观察结果:正确划法下 shell / fallback / tail 同在 chunk#0(12ms),slow 在 3025ms 的 chunk#3;把 await 挪到父组件后,所有标记都跑到 3025ms 那一批,第一批里只剩一个几乎空的壳。加第二个慢块之后总耗时仍是 3028ms(不是 6 秒),且两个结果落在同一个 chunk。头部固定是 te: chunked、cl: null。

故障注入

注入一:把慢 await 提到 Suspense 外面。

预期:页面判定和响应头看起来完全没变(还是 ƒ、还是 chunked、还是 no-store),但第一批字节的到达时刻从 12ms 掉到约 3025ms。这是最隐蔽的一种退化 —— 所有指标都正常,只有用户在等。

注入二:把 Suspense 包到整页外面。

预期:第一批确实很快就到,但里面只有一个"加载中"。字节数掉了、首字节变快了,而用户体验没变好。 记录这个对照:指标好看不等于体验变好。

注入三:给慢组件加一个会抛错的开关。

预期(本课未取证,请自己记录):如果它在第二批渲染时抛错,观察 HTTP 状态码是什么、页面上剩下什么。把结果记下来 —— 这正是本课「生产边界」里空着的那一格。

自测题

  1. 实测里 tail 和 fallback 同在第一个 chunk。如果 <Suspense> 之后还有第二个同样慢的、同样被包住的组件,第一批里还会不会有第二个占位?为什么?
  2. 为什么流式响应必然没有 content-length?如果强行要一个 content-length,会失去什么?
  3. 一个页面的第一批字节发出后,cache-control 还能不能改?这和第 03 章的"两层缓存"在时间上是什么关系?
  4. 你把一个 3 秒的 await 写在父组件里、然后把子组件用 <Suspense> 包起来。首字节会变快吗?
  5. 两个各 3 秒的慢块分别用 Suspense 包住,总耗时是 3 秒还是 6 秒?为什么?

现在能解释什么

进入 keel 阅读