不抖的流式渲染:渲染风暴、增量 Markdown 与滚动锚定
模型每秒吐几十上百个增量,最朴素的写法会让 React 每个增量重渲染一次,还会把用户正在打字的输入框拖卡。这一天依次解决渲染风暴、不完整 Markdown、滚动位置被抢三个问题,把流式聊天做到长会话也不抖。
今日目标
- 能解释渲染风暴的成因,并说清按帧合并与降低更新优先级各自解决了什么
- 能处理流式 Markdown 的不完整语法,说出代码块跨增量时为什么不能直接交给解析器
- 能实现滚动锚定,让用户往上翻看历史时不会被新内容拽回底部
昨天那个页面能跑,但只在「文字很短、网速很好」时好看。今天把它推到真实负载下,一样样修。读完回到页面顶部把三条目标勾掉。
小白版讲解
译员每听一个字就重念整句:渲染风暴长什么样
回到同声传译的隔音间。假设有个译员的工作方式是这样的:每听到一个新词,他就把整句话从头再念一遍。讲者说「这个」,他念「这个」;讲者接着说「方案」,他念「这个方案」;再来一个词,他又从「这个方案」念起。
听众会疯掉。而且越到句子后面越慢——因为每次要重念的东西越来越长。
这就是昨天那个页面的写法。我们在 TEXT_MESSAGE_CONTENT 到达时做的事是:
// D1 的写法:每个增量触发一次状态更新
setMessages((prev) =>
prev.map((m) => (m.id === event.messageId ? { ...m, text: m.text + event.delta } : m))
)模型每秒吐 30 到 100 个增量,这段代码就每秒执行几十上百次。每执行一次,React 都要走一遍完整的渲染流程:重算组件、比对虚拟 DOM、提交变更。而屏幕每秒只刷新 60 次——超出这个数的渲染,画面根本来不及显示,全是白做的功。
更糟的是,长消息的重算成本随长度增长。一条五百字的回复写到末尾时,每个增量都要把这五百字重新处理一遍。
先量后调:真实的数字,以及一个反直觉的发现
不要凭感觉优化。今天的实验页面自带一个渲染计数器,先量再改。
在本课的 lab 里用 burst 模式(174 个增量)实测,两种写法的对比是:
| 写法 | 增量数 | 状态更新次数 | 消息组件渲染次数 |
|---|---|---|---|
| 逐增量(昨天的写法) | 174 | 174 | 176 |
| 按帧合并(今天要做的) | 174 | 55 | 57 |
3.1 倍。这个数字在三次重复测量里都稳定。
但真正值得记住的是准备这个实验时踩到的坑。最初的剧本把增量之间的间隔设成 0,想着「吐得越快越能复现风暴」。结果完全相反:174 个增量只渲染了 6 次。
原因是 React 18 之后的自动批处理:同一个事件循环轮次里的多次状态更新会被自动合并成一次渲染。间隔为 0 时,整条流几乎在一两个数据块里就到齐了,客户端在同一轮里连续调用 setState,React 把它们全并了。
按帧合并:把渲染次数钉在屏幕刷新率上
既然屏幕每秒只刷新 60 次,那渲染超过 60 次就没有意义。思路就很直接:增量先进缓冲区,每一帧只统一更新一次。
requestAnimationFrame 正是浏览器提供的「下一帧要画了」的回调时机。
export function createFrameBuffer<T>(flush: (batch: T[]) => void) {
let buffer: T[] = []
let handle: number | null = null
return {
push(item: T) {
buffer.push(item)
// 关键:一帧之内只排一次。后续 push 只往缓冲区里加,不再排新的帧。
if (handle === null) {
handle = requestAnimationFrame(() => {
handle = null
const batch = buffer
buffer = []
flush(batch)
})
}
},
}
}上面那个表里的 55 次状态更新,除以整条流的约 730 毫秒,正好是每 13 毫秒一次——与 60fps 的 16.7 毫秒同一量级。渲染频率不再跟着模型的吐字速度走,而是被钉在了屏幕刷新率上。
还有一个容易漏的细节:一帧内可能收到属于不同消息的增量。合并时要先按消息标识归并,再对消息数组做一次遍历,否则一百个增量就是一百次数组遍历,白省了。
降低优先级:别让流式渲染卡住用户打字
按帧合并解决了「渲染太多次」,但还有一个独立的问题:React 默认认为所有状态更新同样紧急。
于是流式渲染和用户敲键盘这两件事会互相争抢。用户在输入框里打字时,如果同时有流在跑,输入会明显滞后于按键——因为 React 可能正忙着渲染一大段流式内容,腾不出手响应键盘。
useTransition 就是用来给更新分优先级的:
const [, startTransition] = useTransition()
const buffer = createFrameBuffer<Delta>((batch) => {
// 流式内容是低优先级:用户此刻的输入、点击必须能插队
startTransition(() => appendDeltas(batch))
})被 startTransition 包起来的更新是可中断的:React 渲染到一半如果来了更紧急的事(键盘输入、点击),会先放下手里的活去响应,之后再回来继续。
这个差别没法靠看代码体会,要动手感受。本课的实验页面专门放了一个输入框,你在流式跑起来的时候在里面打字,开关一下「按帧合并」,两种手感的差距非常明显。
不完整的 Markdown:流式渲染真正的难点
模型的回答里常有代码块。流式到一半时,你手里的文本可能是这样:
这是一段代码:
```ts
const a = 1围栏开了,还没关。把这段交给普通的 Markdown 解析器,它只有两种反应:要么认为代码块没结束、把后面所有内容都吞进代码块;要么干脆不认这个块、当普通文本渲染。
两种都会导致同一个后果:流一结束、围栏闭合的那一瞬间,画面突然重排一次。一段文字忽然变成代码块,位置跳动,非常刺眼。
解法是:解析之前先假装它已经闭合,让「正在生成中」的语法以它最终会变成的样子渲染。本课的渲染器给未闭合的代码块标一个 complete: false,界面据此显示一个「还在写」的样式(比如左侧一道高亮边),但结构上已经是代码块了。内容写完时只是去掉那道边,不发生重排。
行内标记也一样,但结论相反:
// 遇到只开了口没闭合的 ** 标记,当普通文本处理
parseInline('这是 **还没写完') // 全部当 text,不加粗
parseInline('这是 **写完了** 的') // 识别为 strong为什么这里反过来了?因为加粗一半的文字在流式过程中会一闪一闪(每来一个字就重新判断要不要加粗),而普通文本转成粗体只跳一次。少跳一次总是更好的那个选择,这是取舍的判断标准,不是规则。
两趟渲染:先出字,再上色
语法高亮很贵。给一段代码上色要做词法分析,在流式过程中每来一个字就重算一次,是纯粹的浪费——何况代码还没写完,高亮结果本来就是错的。
工业界的做法是两趟:第一趟,代码块以纯文本形式立刻显示,保证零感知延迟;第二趟,等围栏闭合、代码块完整之后,再做一次语法高亮。用户看到的是「字先出来,然后颜色补上」,而不是「转圈等着,然后整块蹦出来」。
这也是为什么上一节那个 complete 标记有用:它正好是「该不该上高亮」的判据。
滚动锚定:别把用户拽回底部
新内容到达时要不要自动滚到底部?
最常见的错误写法是无条件滚:
// 错误:每次内容变化都强制滚到底
useEffect(() => {
listRef.current.scrollTop = listRef.current.scrollHeight
}, [messages])后果是用户想往回看一句话,刚翻上去就被拽回底部,完全没法读。这是个体验事故,但很多产品都有。
正确的规则只有一条:跟不跟随取决于用户当前在不在底部,而不是取决于有没有新内容。
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
const pinned = distance <= 48 // 留一点余量,行高与缩放会让判断不精确贴底时跟随,用户上翻后就不动了,同时给一个「回到底部」的按钮让他能回来。还有个细节:跟随时用 scrollTop 直接赋值而不是平滑滚动——流式期间每帧都可能滚一次,平滑滚动会互相打断,看起来反而像卡顿。
虚拟化:先手写一版,再决定要不要上库
会话长到几百条消息时,即使每条都不重渲染,光是 DOM 节点数量本身就会拖慢页面。虚拟化的思路是只渲染视口内的那些。
原理并不复杂:知道每条消息的高度,就能算出当前滚动位置该显示哪几条,其余的用一个撑起总高度的空盒子占位。难点在于聊天消息高度不固定——你得先渲染才知道多高,而虚拟化又要求先知道多高才能决定渲不渲染。工业级的库(比如 @tanstack/react-virtual)用「先估算、渲染后测量、再修正」来解决。
本课的建议是:先手写一版朴素实现把原理跑通,再决定要不要引库。多数聊天场景在两三百条以内,前面几项优化做完就够用了;虚拟化会让滚动锚定、跳转到某条消息、搜索高亮全部变复杂,不要提前付这个成本。
源码导读
动手实验
今天的实验是在昨天的基础上改,三个冻结文件原样复制过来即可。先跑一次基线并记下数字,再动手优化——没有基线的优化是没法验证的。
- 用 burst 模式复现渲染风暴,记录基线的渲染次数
- 实现按帧合并的增量缓冲,重新测量并与基线对比
- 用 useTransition 降低流式更新的优先级,在流式期间打字验证手感
- 手写一个能容忍未闭合围栏与未闭合行内标记的增量 Markdown 渲染器
- 实现滚动锚定与回到底部按钮,覆盖用户上翻的场景
面试题
今天 4 道题在下方题库区,侧重高频更新下的渲染性能、增量解析的边界处理、滚动行为的用户预期。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能解释渲染风暴的成因,并说清按帧合并与降低更新优先级各自解决了什么
- 能处理流式 Markdown 的不完整语法,说出代码块跨增量时为什么不能直接交给解析器
- 能实现滚动锚定,让用户往上翻看历史时不会被新内容拽回底部
- 能说清为什么「本地测不出卡顿」不等于线上不卡
- 实验的 6 条验收标准全部通过
- 4 道面试题不看要点也能答出至少 3 道
明天(D3)我们让界面显示 Agent 正在调用什么工具,并实现人在回路的审批。前两天解决的都是「把模型说的话显示好」,而从明天起处理的是「模型要动手做事」——这是信任问题的核心:用户愿不愿意让一个 Agent 替自己执行操作,取决于他能不能看见它要做什么、并且拦得住。顺带你会发现,审批这件事做对的方式和多数人的第一直觉不一样。
面试题库
模型每秒吐 80 个增量,你的聊天页开始掉帧、输入框也变卡,你按什么顺序排查和优化?A model is emitting 80 deltas per second, your chat page drops frames, and the input box lags. In what order do you diagnose and fix it?
国内高频海外高频深入#react-performance#streaming#profiling分析过程 · 先想清楚再作答
- 这题考的是排查顺序,不是优化手段的清单。上来就背「memo、虚拟化、防抖」的人会被追问「你怎么知道是这个原因」,然后就答不下去了。
- 第一步永远是量,不是改。打开性能面板录一段,看时间花在哪一层:是 React 的渲染提交,还是 Markdown 解析,还是布局重排。不同的瓶颈解法完全不同,猜错了做的全是无用功。
- 确认是渲染次数过多之后,第一刀砍在源头:按帧合并。屏幕每秒只刷新 60 次,超出的渲染画面根本来不及显示,所以把增量攒到 requestAnimationFrame 里每帧统一更新一次,渲染次数的上限就被钉在刷新率上。
- 输入卡顿是**另一个独立问题**,不会被按帧合并解决:React 默认认为所有更新同样紧急,流式渲染会和键盘输入抢主线程。这一刀用 useTransition,把流式更新标成低优先级、可中断,让输入插队。能把这两件事分开说,基本就过了。
- 还有余量再往下做:memo 让已完成的历史消息不跟着重渲染,两趟渲染把语法高亮推迟到代码块闭合之后,最后才轮到虚拟化。虚拟化要放最后,因为它会让滚动锚定、跳转、搜索全部变复杂,是成本最高的一步。
- 可预期的追问是「为什么本地测不出来」——因为本地 mock 数据几乎瞬间到齐,React 的自动批处理会把同一轮事件循环里的多次更新合并掉,问题被藏起来了。真实网络下增量跨事件循环陆续到达,批处理帮不上忙。性能测试必须模拟真实到达节奏。
How to reason about it · think before answering
- This tests your diagnostic order, not your list of optimizations. Reciting 'memo, virtualization, debounce' invites the follow-up 'how do you know that is the cause', and the answer runs dry.
- Step one is always measure, never change. Record a profile and find which layer the time goes to: React's render and commit, Markdown parsing, or layout. Different bottlenecks need entirely different fixes.
- Once you confirm excessive renders, cut at the source with per-frame batching. The screen refreshes 60 times a second, so renders beyond that never reach the user. Buffer deltas and flush once per requestAnimationFrame, and the render ceiling becomes the refresh rate.
- Input lag is a **separate problem** that batching does not fix: React treats all updates as equally urgent, so streaming competes with keystrokes for the main thread. Use useTransition to mark streaming updates low-priority and interruptible. Separating these two concerns is most of the signal in this question.
- With headroom left, keep going: memo so finished history does not re-render, two-pass rendering to defer syntax highlighting until a code block closes, and virtualization last. Virtualization goes last because it complicates scroll anchoring, jump-to-message, and search.
- Expect the follow-up on why it does not reproduce locally: mock data arrives almost instantly, so React's automatic batching collapses updates within one event loop turn and hides the problem. Over a real network deltas arrive across turns and batching cannot help. Performance tests must mimic real arrival pacing.
答题要点
- 先量后改:用性能面板确认瓶颈在渲染、解析还是布局,不同瓶颈解法完全不同。
- 渲染次数过多用按帧合并,把更新频率钉在屏幕刷新率上而不是模型吐字速度上。
- 输入卡顿是独立问题,用 useTransition 把流式更新降为可中断的低优先级。
- 再往下依次是 memo 跳过历史消息、两趟渲染推迟语法高亮,虚拟化放最后做。
- 本地测不出来是因为自动批处理把瞬间到齐的更新合并了,测试要模拟真实到达节奏。
Key points
- Measure first: profile to see whether the cost is rendering, parsing, or layout, since the fixes differ completely.
- For excessive renders, batch per animation frame so update frequency tracks the refresh rate rather than the model's output speed.
- Input lag is a separate issue: use useTransition to make streaming updates low-priority and interruptible.
- Then memo to skip finished history, two-pass rendering to defer highlighting, and virtualization last.
- It does not reproduce locally because automatic batching collapses instantly-arriving updates; tests must mimic real pacing.
流式 Markdown 渲染到一半,代码块的围栏只来了一半,你的渲染器应该怎么处理?Your streaming Markdown renderer receives a code fence that has opened but not yet closed. How should it handle that?
国内高频海外高频进阶#markdown#streaming#rendering分析过程 · 先想清楚再作答
- 这题的区分度在于你有没有真做过流式渲染。没做过的人会说「等它闭合再渲染」,而这恰恰是体验最差的做法。
- 先说清楚朴素做法坏在哪:把未闭合的文本交给普通解析器,它要么把后面所有内容吞进代码块,要么不认这个块当普通文本。无论哪种,**围栏闭合的那一刻画面都会突然重排**——一段文字忽然变成代码块,位置跳动,非常刺眼。
- 正确思路是让「正在生成中」的语法以它**最终会变成的样子**渲染:识别出未闭合的围栏,照样产出一个代码块,只是额外标一个未完成的标记。界面用这个标记显示「还在写」的样式,比如左侧一道高亮边。内容写完时只是去掉那道边,结构不变,所以不重排。
- 这个未完成标记还有第二个用途:它正好是「该不该上语法高亮」的判据。高亮很贵,而且代码没写完时高亮结果本来就是错的,所以工业做法是两趟——先出纯文本保证零延迟,闭合后再上色。
- 有意思的是行内标记的结论**相反**:只开了口的粗体应该当普通文本,不要提前加粗。因为加粗一半的文字会随着每个字到达反复横跳,而普通文本转粗体只跳一次。判据不是规则而是「哪种跳动更少」,能说出这一层说明你是在权衡而不是背结论。
- 可预期的追问是「那表格和列表呢」——同理,按「补全后是什么样」渲染,但表格要注意列数可能还会变,通常等整行到齐再渲染那一行更稳。
How to reason about it · think before answering
- The signal here is whether you have actually built streaming rendering. People who have not say 'wait until it closes', which is the worst option for the user.
- Name what breaks in the naive approach: hand unclosed text to a normal parser and it either swallows everything after into the code block or refuses the block and renders plain text. Either way, **the moment the fence closes the layout jumps** as a paragraph abruptly becomes a code block.
- The right approach is to render in-progress syntax as what it **will eventually become**: detect the unclosed fence, emit a code block anyway, and flag it as incomplete. The UI uses that flag for an in-progress treatment such as a highlighted left border. When content finishes, the border goes away and nothing reflows.
- That incomplete flag has a second use: it is exactly the signal for whether to apply syntax highlighting. Highlighting is expensive, and on unfinished code it is wrong anyway, so the industry approach is two passes — plain text immediately, color once the block closes.
- Interestingly, inline markers go the **other** way: treat an unclosed bold marker as plain text rather than bolding early, because half-bolded text flickers with every arriving character while plain-to-bold jumps once. The criterion is which choice flickers less, not a fixed rule.
- Expect a follow-up about tables and lists: same principle, but tables are safer rendered a full row at a time since column count can still change.
答题要点
- 不能等闭合再渲染,那会让围栏闭合的瞬间发生一次刺眼的重排。
- 识别未闭合围栏并照样产出代码块,额外标一个未完成标记供界面显示「还在写」的样式。
- 结构提前正确,闭合时只是去掉样式,所以不重排。
- 未完成标记同时是「该不该上语法高亮」的判据,两趟渲染:先出字,闭合后上色。
- 行内标记结论相反,未闭合时当普通文本,因为提前加粗会反复横跳;判据是哪种跳动更少。
Key points
- Do not wait for the fence to close; that produces a jarring reflow at the moment it does.
- Detect the unclosed fence, emit a code block anyway, and flag it incomplete so the UI can show an in-progress treatment.
- Getting the structure right early means closing only removes a style, with no reflow.
- That flag also decides whether to highlight: two passes, text first, color after the block closes.
- Inline markers invert the rule: leave unclosed bold as plain text, since early bolding flickers. The criterion is which flickers less.
聊天消息列表什么时候该自动滚到底部,什么时候不该?说出你的判定规则。When should a chat message list auto-scroll to the bottom, and when should it not? State your rule.
国内高频海外高频基础#scroll-behavior#ux#chat-ui分析过程 · 先想清楚再作答
- 这是道送分题,但答错的产品非常多,所以面试官爱问。错误答案是「有新消息就滚到底」,一句话就暴露了没考虑用户正在往回看的情况。
- 判定规则只有一条,而且要能一句话说出来:**跟不跟随取决于用户当前在不在底部,而不是取决于有没有新内容。**
- 落到实现上就是算距底距离:scrollHeight 减 scrollTop 再减 clientHeight。小于一个阈值就算贴底,跟随;否则不动。阈值不要设成 0,行高、缩放、亚像素都会让判断不精确,留个几十像素的余量。
- 不跟随的时候必须给用户一个回去的入口,通常是一个「回到底部」按钮,有新消息时还可以带个未读提示。只停不给回路是另一种体验事故。
- 一个容易漏的实现细节:跟随时用 scrollTop 直接赋值,不要用平滑滚动。流式期间每帧都可能滚一次,多个平滑滚动动画会互相打断,看起来反而像卡顿。平滑滚动只用在用户主动点「回到底部」那一次。
- 可预期的追问是「用户正好停在阈值边界上反复抖动怎么办」——加一点迟滞,比如进入贴底态和离开贴底态用不同的阈值,避免在边界上反复切换。
How to reason about it · think before answering
- An easy question that a surprising number of products get wrong, which is why interviewers like it. The wrong answer is 'scroll down whenever a message arrives', which ignores the user reading back through history.
- There is one rule and you should be able to state it in a sentence: **whether to follow depends on where the user currently is, not on whether new content arrived.**
- In practice, compute distance from the bottom as scrollHeight minus scrollTop minus clientHeight. Under a threshold counts as pinned and follows; otherwise stay put. Do not use zero as the threshold, since line height, zoom, and subpixel rounding make it imprecise. Leave a few dozen pixels.
- When not following you owe the user a way back, normally a jump-to-bottom button, optionally with an unread indicator. Stopping without offering a return path is its own failure.
- An easily missed detail: when following, assign scrollTop directly rather than smooth-scrolling. During streaming you may scroll every frame, and overlapping smooth animations interrupt each other and read as jank. Save smooth scrolling for the explicit jump-to-bottom click.
- Expect a follow-up about a user parked exactly on the threshold: add hysteresis by using different thresholds for entering and leaving the pinned state.
答题要点
- 规则是跟不跟随取决于用户当前在不在底部,不取决于有没有新内容。
- 算距底距离判断是否贴底,阈值留几十像素余量,不要用 0。
- 不跟随时必须提供「回到底部」入口,可以带未读提示。
- 跟随时直接赋值 scrollTop,不要用平滑滚动,否则每帧的动画互相打断会像卡顿。
- 边界抖动用迟滞解决:进入和离开贴底态使用不同阈值。
Key points
- The rule: follow based on where the user is, not on whether new content arrived.
- Compute distance from the bottom with a threshold of a few dozen pixels, never zero.
- When not following, provide a jump-to-bottom affordance, optionally with an unread badge.
- Assign scrollTop directly when following; smooth scrolling every frame interrupts itself and reads as jank.
- Add hysteresis with different enter and leave thresholds to avoid flapping at the boundary.
你的流式聊天在本地怎么测都不卡,一上线用户就抱怨界面卡顿。可能是什么原因?Your streaming chat never janks locally but users report lag in production. What could explain that?
国内高频海外高频深入#react-performance#testing#debugging分析过程 · 先想清楚再作答
- 这题在考你对 React 批处理边界的理解,而且它有一个非常具体的答案。泛泛答「线上机器差、网络慢」不算错但拿不到分,面试官想听的是机制。
- 关键机制是 React 18 之后的**自动批处理**:同一个事件循环轮次里的多次状态更新会被合并成一次渲染。
- 本地连 mock 服务时,整条流往往在一两个数据块里就到齐了,客户端在同一轮事件循环里连续调用状态更新,React 把它们全并成一次——于是「每个增量一次 setState」这个写法在本地根本不产生风暴。我自己写课程实验时实测过:间隔设成 0 时,174 个增量只渲染了 6 次。
- 真实网络下增量是**跨事件循环陆续到达**的,每个增量各自落在不同的轮次里,自动批处理帮不上忙,于是渲染次数就和增量数一比一了。同一份代码,本地 6 次、线上 174 次。
- 所以结论有两层:一是这个写法本来就该改成按帧合并,二是**性能测试必须模拟真实的到达节奏**,mock 服务要在增量之间留真实的微小间隔,否则你的测试在系统性地给你假绿。
- 可预期的追问是「还有哪些问题有同类特征」——凡是依赖时序的问题都有,比如竞态、防抖失效、以及只在慢网络下暴露的加载顺序问题。共同点是本地环境太快,把问题藏起来了。
How to reason about it · think before answering
- This probes your understanding of React's batching boundaries, and it has a very specific answer. 'Production machines are slower' is not wrong but scores nothing; the interviewer wants the mechanism.
- The mechanism is **automatic batching** in React 18 and later: multiple state updates within one event loop turn collapse into a single render.
- Against a local mock, the whole stream often arrives in one or two chunks, so the client issues its updates within a single turn and React merges them all. The naive per-delta setState therefore produces no storm locally. Measured while building this course: with zero spacing, 174 deltas produced only 6 renders.
- Over a real network the deltas arrive **across event loop turns**, each landing in its own, so batching cannot help and renders track deltas one to one. Same code: 6 renders locally, 174 in production.
- Two conclusions follow: the code should batch per frame regardless, and **performance tests must mimic real arrival pacing**. A mock that fires everything at once gives you systematically false green.
- Expect a follow-up on what else behaves this way: anything timing-dependent, such as race conditions, ineffective debouncing, and load-order bugs that only appear on slow networks. The common thread is a local environment fast enough to hide the problem.
答题要点
- React 18 的自动批处理会合并同一个事件循环轮次里的多次状态更新。
- 本地 mock 数据几乎瞬间到齐,更新落在同一轮里被全部合并,风暴不会出现。
- 真实网络下增量跨事件循环陆续到达,批处理失效,渲染次数与增量数一比一。
- 解法是按帧合并,同时让 mock 在增量之间留真实的微小间隔,避免测试给出假绿。
- 同类特征的问题还有竞态、防抖失效、慢网络下的加载顺序,都是被过快的本地环境藏起来的。
Key points
- React 18's automatic batching merges state updates that occur within one event loop turn.
- Local mock data arrives almost instantly, so updates land in the same turn and collapse, hiding the storm.
- Over a real network deltas arrive across turns, batching cannot help, and renders track deltas one to one.
- Fix by batching per frame, and add realistic spacing to the mock so tests stop reporting false green.
- Race conditions, broken debouncing, and slow-network load ordering share this shape: a too-fast local environment hides them.