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。稳定复现。)
三件事:
- 第一批在 11 毫秒就出去了(头部),而不是 3 秒后。总耗时确实还是 3026ms(那 3 秒省不掉),但用户的屏幕不再是白的。
slow在 3025ms 才到,晚了一批 —— 它在等那个 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
这是流式的必要条件,也是它的代价:
- 没有
content-length,是因为开始发送时根本不知道最后会有多少字节(慢数据还没渲染出来)。所以必须用chunked编码:每个 chunk 自带长度,收到 0 长度块才算结束。 - 反过来,一旦第一批字节发出去,头部就定死了。响应状态码是 200 就已经是 200 了,后面再发生什么都不能改成 500;
cache-control也已经在头里,不能再改成"这个结果可以缓存"。
这条约束的工程含义比它看起来重:"能不能缓存"必须在发出第一个字节之前决定完。 你没机会说"我先发个壳,等下看数据全不全,齐全的话标成可缓存"。所以缓存策略(第 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 页面。 它只能以页面内的一段内容出现(那个"占位替换"的脚本里可以带错误态),或者干脆什么都不替换、让占位一直留在那里。
这也是为什么实践里流式页面的错误处理要分两处做:
- 头部发出之前的错误 —— 还能是干净的 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: 慢的那块被填上
本章脉络
- 一个响应可以分多批:头部 11ms 就出去,慢数据 3025ms 才到,中间是一个已经可以看到内容的页面。
<Suspense>的作用面是它包住的那一段,不是它之后的所有内容 —— 实测tail和shell、fallback同在第一个 chunk(12ms)。- 第二批不是一整页,只有 192+963+14 = 1169 字节:一段 HTML 片段 + 换占位的脚本。
chunked+ 无content-length是流式的必要条件;代价是头部先发出、状态码与缓存策略此后不能再改。- 边界位置比数量重要:
await写在边界外 = 白屏;多个边界并行不串行。
生产边界
- 没量"流开始之后出错"的具体形态。 本课只验证了头部在 11ms 发出、状态码是 200,据此可以确定状态码此后不能改;但没有注入一个"第二批渲染时抛错"的故障,所以"错误会以什么形态出现在页面上"这件事本课没取证。
- 没量中间层缓冲。 本课是
next start直连。反向代理、CDN、负载均衡器如果有缓冲行为,流式会被压回"整页等到最后"——这是流式上线后最常见的"代码没改但效果没了",但需要真实的多层环境才能验,本课没验。 - 没量慢数据的并发上限。 多个 3 秒慢块并行时,服务端的连接与内存开销会怎么增长,本课没测。
- 没量"占位替换"对前端交互的影响:用户在第二批到达之前点了页面上的东西会发生什么,本课没测。
prefers-reduced-motion、骨架屏的可访问性这类问题不在本课范围。- 本课用的 3 秒延迟是
setTimeout造出来的固定延迟。真实接口的延迟是抖动的,抖动会让"第一批多快出去"变得不稳定 —— 本课量到的 11ms / 3025ms 不能当成线上预期值。
动手:可观察结果
- 不要用浏览器看流式。 浏览器会把内容渲染出来,但你看不到"分了几批"。
用node直接读 chunk 到达时刻:http.get(...),在res.on("data", ...)里记Date.now() - T0。 - 给响应打三个
data-probe标记:shell(壳)、fallback(占位)、slow(慢数据)、tail(Suspense 之后)。
写完第一个 chunk,去每个标记落在哪个 chunk 里。 - 把
await从被包住的子组件挪到父组件里,重建、再量一次。 - 看头部:
transfer-encoding是不是chunked、有没有content-length。 - 再加一个同样慢的块,也各自包在自己的边界里,重建、再量一次。重点看总耗时有没有变成 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 状态码是什么、页面上剩下什么。把结果记下来 —— 这正是本课「生产边界」里空着的那一格。
自测题
- 实测里
tail和fallback同在第一个 chunk。如果<Suspense>之后还有第二个同样慢的、同样被包住的组件,第一批里还会不会有第二个占位?为什么? - 为什么流式响应必然没有
content-length?如果强行要一个content-length,会失去什么? - 一个页面的第一批字节发出后,
cache-control还能不能改?这和第 03 章的"两层缓存"在时间上是什么关系? - 你把一个 3 秒的
await写在父组件里、然后把子组件用<Suspense>包起来。首字节会变快吗? - 两个各 3 秒的慢块分别用 Suspense 包住,总耗时是 3 秒还是 6 秒?为什么?
现在能解释什么
- 现场那个"白屏 3 秒然后全部出现"的页面,问题在于快慢两种数据被绑成了一捆,整页要等所有人到齐。
- 把慢接口用
<Suspense>单独包起来之后,用户先看到的是完整骨架 + "加载中"占位,而且尾巴也在里面 —— 因为tail和shell同在第一个 chunk(12ms 实测)。 - 3 秒省不掉。流式改的不是总耗时,是"第一眼什么时候来"。
- 头部一旦发出,状态码与缓存策略就定死了 —— 所以"能不能缓存"必须在渲染完成之前决定,也因为如此,流式的错误处理必须分"头部之前"和"头部之后"两处做。