一、Fiber 到底改变了什么
先说结论:Fiber 没有让单次渲染更快,它改变的是"渲染这件事能不能被调度"。
在 React 15 及更早的版本里,一次 setState 会触发递归遍历整棵组件树,从根节点 diff 到叶子节点,中间根本停不下来。JS 是单线程的,这段递归一旦开始,就会霸占主线程直到结束。于是你经常会看到这样的场景:列表里有几千条数据,搜索框里打字字母一个一个往外蹦,滚动掉帧,按钮点了没反应。
这不是你代码写得烂,是架构层面的硬伤。
Fiber 做的,就是把这种"一口气跑完"的同步渲染,改造成可以暂停、恢复、丢弃的任务调度系统,把主线程控制权还给浏览器。它提升的是响应性,不是总吞吐量。极端情况下总耗时甚至可能更长,但用户感知到的卡顿消失了。
1.1 React 15 的 Stack Reconciler 错在哪
核心问题就一句:递归调用栈的执行状态存在引擎栈帧里,你没法保存一半,下次接着跑。
5000 项列表更新时,浏览器抽不出空去处理输入事件、滚动事件、重绘请求,所以页面直接假死。
1.2 Fiber 的解法:链表替代递归栈
Fiber 本质上就是一个普通 JS 对象,代表一个工作单元,对应一个组件实例或 DOM 节点:
js
const fiber = {
type, // 组件类型 / HostComponent
stateNode, // 真实 DOM 或组件实例
return, // 父
child, // 第一个子
sibling, // 兄弟
alternate, // 指向另一棵树的对应节点(双缓存)
memoizedProps, pendingProps,
memoizedState, // Hook 链表头
updateQueue, // 环形链表,存待处理更新
lanes, // 优先级(31 位位掩码)
flags, // 副作用标记:Placement/Update/Deletion
};
链表的妙处在于:你不需要保存整个调用栈,只要记住"当前走到哪个节点",下次从 next 继续就行。
于是递归被拆成了一个可暂停的 while 循环:
js
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
shouldYield() 由 Scheduler 包提供,初始时间切片大约是 5ms,并按实际 fps 动态调整。
这里有个常见误解要纠正:React 并没有用 requestIdleCallback,因为它的时机不稳定、可能被饿死。React 实际用的是 MessageChannel,不支持时回退到 setTimeout,来模拟可控的空闲回调。
1.3 配套机制:双缓存、两阶段、优先级
| 机制 | 说明 |
|---|---|
| 双缓存树 | current(屏幕上正在展示的)+ workInProgress(内存中正在构建的),通过 alternate 互指。Commit 后交换指针 root.current = finishedWork,旧树作为对象池复用 |
| Render 阶段 | 构建 WIP 树、diff、打 flags。可中断、可丢弃、完全在内存中,不动真实 DOM。因此必须是纯函数 |
| Commit 阶段 | 把 effect list 一次性应用到 DOM,执行生命周期 / useLayoutEffect。不可中断,否则用户会看到半新半旧的 UI |
| Lanes 优先级 | 31 位整数位掩码区分紧急程度:Immediate(输入)> UserBlocking(交互)> Normal(网络数据)> Low > Idle |
1.4 Fiber 是 Concurrent Mode 的地基
没有 Fiber,下面这些 API 根本玩不转:
<Suspense>------ 异步边界挂起,显示 fallbackstartTransition/useTransition------ 主动标记"过渡更新"为低优先级useDeferredValue------ 被动拿到一个延迟版本的状态值useSyncExternalStore------ 外部状态与并发渲染的一致性- 自动批处理
1.5 两个必须澄清的误区
误区一:Fiber ≠ 虚拟 DOM。
ReactElement 是 UI 描述快照,无状态、创建后不变;Fiber 是执行单位,有状态、记录进度和优先级、可复用可修改。前者是图纸,后者是施工队。
误区二:useMemo 的收益被高估了。
真正决定性能的是稳定 key 让跳过成为可能,以及减少每次要做的工作量。至于"谁来做跳过判断",反而不是主要矛盾。
二、AI 流式长文本的场景特征
LLM 流式输出(SSE)有几个"反前端"的特性:
- 超高频增量写入:每个 token / chunk 到达就是一次状态变更,每秒可能十几到几十次。
- 内容单调增长但解析代价非线性 :每次把全量文本丢给
marked.parse()重跑一遍 Lexer,复杂度逼近 O(n²),几千字后明显变慢。 - DOM 只增不减:几千字对话产生成百上千个 DOM 节点。
- 交互不能停:用户要在输出时滚动回看、复制、点"停止生成"、切会话、继续提问。
这四个特征叠加,就是典型的 Fiber 该出场的场景,但也恰好暴露了 Fiber 的边界。
三、Fiber 在该场景下的真实优势
优势 1:增量渲染让"打字感"和"输出感"互不干扰
每个 token 触发一次低优先级更新,切成若干 ~5ms 的片,穿插在浏览器重排/重绘间隙执行。AI 疯狂吐字的同时,滚动、选中文本、切 tab 依然跟手。
换成 React 15 的同步模型,长回复后半段会把主线程吃满,页面直接假死。
优势 2:优先级调度天然适配"输出 vs 交互"冲突
startTransition 包裹的追加操作被标记为 Normal/Low 优先级;此时用户点"停止生成"(Immediate/UserBlocking),调度器会中断当前过渡渲染、丢弃半成品 WIP,先响应停止。
这是"停止按钮必须立刻生效"的产品刚需。
优势 3:新 chunk 到来时能丢弃旧工作,不会任务堆积
高频 setState 最怕更新积压。低优先级长文本渲染还在 Render 阶段时,新的更高优先级更新到了,React 直接扔掉建了一半的 WIP 树,从最新的 current 重新算。
用户看到的永远是最新状态,不会有"排队回放"的延迟感。
优势 4:批处理 + 双缓存,避免视觉上的"半成品"
React 18 默认批处理:同一事件循环里的多次 setState 合并成一次渲染提交。配合双缓存,DOM 只在 Commit 阶段一次性切换,用户永远不会看到"标题渲染了、段落还有一半旧内容"的中间态。
优势 5:块级 diff + 稳定 key,让 memo 真正生效
AI 场景最常见的性能坑其实是 key 不稳定:
js
// ❌ 用索引当 key:AI 在头部插入内容时,后面所有块的 key 全部错位
blocks.map((_, i) => <Block key={`block-${i}`} block={blocks[i]} />)
改成内容哈希作为 key:
js
function hashContent(s: string) {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h) + s.charCodeAt(i);
return Math.abs(h).toString(36);
}
具体性能数据高度依赖环境,但方向是一致的:Fiber 给了你可中断的能力,真正的收益来自"减少每次要做的工作量"。
优势 6:为更长远的优化留了接口
Suspense + RSC 做流式 SSR;<Suspense> 兜住未加载完的代码高亮/公式渲染模块;React 19 的 use API 支持渲染期 Promise 挂起。都是 Fiber 可中断性的直接红利。
四、Fiber 的三条边界
边界 1:Fiber 管不到 Markdown 解析。
marked.parse(fullText) 是一段运行在 React 之外的同步 JS。它阻塞主线程时 Fiber 根本插不上手------Render 阶段还没开始呢。
正确做法:
- Worker 里解析,主线程零阻塞
- 只解析新增块(增量),已解析结果进 LRU 缓存
- 至少节流,100ms 解析一次
边界 2:Commit 阶段不可中断。
如果一次更新就产出上万个 DOM 节点,Commit 本身就会掉帧,Fiber 无能为力。虚拟滚动/窗口化是必须的。
边界 3:低优先级可丢弃 ≠ 免费。
每次 setState 都会往队列里塞 update、触发一轮协调。chunk 频率过高时,即使都被标记为 transition,调度开销本身也会累积。必须在源头节流,而不是指望 Fiber 替你消化。
另外两个小坑:
- Strict Mode 下组件函数可能被调用两次,若解析逻辑带副作用会出现诡异重复计算。
useEffect里做滚动到底部时,要配合isPending判断,避免低优先级更新反复触发自动滚动。
五、为什么 Render 必须是纯函数
Fiber 可中断 → Render 阶段的结果可能被丢弃 → 同一个更新,组件函数可能被调用 N 次 → 所以 Render 必须是"同样的输入 → 同样的输出",且不产生任何可观测的外部变化。
这就是投机执行:React 会"先试着算一下",这个计算不保证会提交。副作用一旦发出就收不回来:
| 副作用类型 | 丢弃后无法撤回的后果 |
|---|---|
fetch() / 发请求 |
请求已发出,重算 → 重复请求 |
ref.current++ / 改外部变量 |
计数器已 +1,重算又 +1 |
| 直接操作真实 DOM | 真实 DOM 被改但 Fiber 树没提交 → 后续 diff 全错 |
| 渲染期 setState | 触发新一轮渲染 → 死循环 |
埋点 analytics.track() |
一次行为记多次 |
Math.random() / Date.now() |
每次算出来不一样 → 双缓存比对失效 |
补充硬约束:Render 阶段还要支持服务端渲染。服务器上的 render 没有"下一次机会",如果发了请求或改了全局状态,后果比客户端更严重。
5.1 Strict Mode 双重调用:故障注入,不是 bug
Strict Mode 在开发环境下主动把组件函数调用两次,并对 Effect 做 mount → unmount → 再 mount。
为什么两次就能发现问题?纯函数调用两次等于没调用,不纯的函数调用两次一定会露馅。
三个容易误判的点:
- 只在开发环境、只在 StrictMode 子树内生效,生产环境不会双调。
- 生产环境也会多次渲染。并发模式开启后,Suspense resolve、优先级抢占都可能让组件在一次更新里跑好几遍。
- 双调的是"函数",不是钩子语义。
useMemo计算器、useRef初始值函数都会被双调。
5.2 副作用该放哪儿
| 想做的事 | 正确位置 | 说明 |
|---|---|---|
| 派生 UI(格式化、过滤、筛选) | Render / useMemo | 纯计算,随便放 |
| 网络请求、订阅、定时器 | useEffect | Commit 之后才执行,可被 cleanup 收回 |
| 读布局后立即同步写 DOM | useLayoutEffect | 浏览器绘制前,但仍属 Commit 之后 |
| 事件响应(点击停止生成) | 事件处理器 | 天然在渲染之外 |
| 一次性初始化(SDK、Worker) | 模块顶层 / useEffect + guard | 绝不放组件体 |
| 渲染期 setState | 禁止 | 会触发循环 |
| 读写 ref | 尽量放 Effect 或事件里 | Render 里只读不写 |
判据一句:这件事如果做了又被丢掉,会不会在外面留下痕迹?会 → 不能放 Render。
5.3 AI 流式场景的三个高频踩坑
坑 1:在组件体里做 marked.parse(fullText)。
不只是性能问题,更是纯度问题。若把解析结果写进外部缓存,Render 阶段就产生了副作用:Strict Mode 双调写两份,并发模式下被丢弃的 WIP 同样污染缓存。
正确做法:缓存 keyed by 内容哈希且幂等写入,或移到 Worker。
坑 2:渲染期统计"已接收 token 数"。
js
tokenCountRef.current += 1;
中断重试后计数可能大于实际 token。改成由数据源驱动:
js
const count = useMemo(() => text.split(' ').length, [text]);
坑 3:SSE 连接放在组件体里。
组件双调/重渲染 → 开第二个流。必须放进 useEffect,cleanup 里 controller.abort() / es.close()。
六、"低优先级可丢弃"会不会丢掉核心功能?
6.1 结论:不会丢"结果",但会丢"过程"
被丢弃的是那半棵还没建完的 WIP 树(渲染工作),不是你的状态更新。update 在入队那一刻就已经写进了队列,所以状态最终一定会落到 DOM 上。
React 保证收敛正确性,不保证每一帧中间态都被画出来。
6.2 分清三个层次
| 层次 | 会不会被丢弃 | 说明 |
|---|---|---|
| ① update 入队(setState) | ❌ 不会 | startTransition(fn) 里的 fn 同步立即执行,update 立刻进 queue |
| ② Render 阶段 | ✅ 会 | 唯一可丢弃的部分 |
| ③ Commit + Effect | ❌ 不会 | Commit 不可中断,但中间态 effect 会被合并 |
低优先级 = "可以晚点画、可以重画",≠ "可以不生效"。
6.3 真的会被丢 / 被合并的部分
- effect 不是"每次渲染都跑一次":并发模式下可能只提交最后一个,effect 只跑一次。
- 自动滚动会"丢帧"甚至抖动 :不要指望每 chunk 一次 effect,用
isPending分流。 - ref 计数与 effect 里的副作用会"对不上":SSE 场景直接表现为两个流同时往一个气泡追加文字。
- useDeferredValue 本身就是"故意给旧的":需要实时准确的东西别绑上去。
- 动画与过渡会"跳" :逐帧进度条不该走 transition,应走
requestAnimationFrame+ ref 直写 DOM。
6.4 会不会"饿死"?诚实说:没有硬保证
React 没有公开的"最大饥饿时间"承诺。startTransition 和 useDeferredValue 曾经的 timeoutMs 选项后来都被移除了。
AI 流式如果每个 chunk 都触发 default 优先级的 setState,transition 确实可能被长期挤压,表现是"打字很快但屏幕半天不动,然后突然一大段蹦出来"。这说明源头节流才是解药:rAF 或 50--100ms 节流后每秒只有 ~10 次渲染。
6.5 判断清单
对每一段逻辑问三个问题:
- 这东西丢了会不会导致数据不一致? 会 → 放 state(可放心用 transition)。
- 它必须精确执行 N 次吗? 是 → 绝不能放 render/effect,放事件处理器或带 AbortController 的 effect。
- 用户能感知到它晚 100ms 吗? 能 → 别标 transition;不能 → 放心标 transition。
6.6 万一真有"绝对不能被丢"的渲染
flushSync 强制本次更新同步完成、立即落 DOM。但它是一把钝刀:会打断并发调度、造成掉帧,官方明确建议尽量少用。
更推荐的结构解法是状态分层。把"必须实时的"和"可以延后的"拆成两个 state:
js
startTransition(() => setBody(b => b + chunk)); // 高频、可延后
setMeta(m => ({ ...m, tokens: n, status: 'streaming' })); // 低频、必须实时
七、React 19 + React Compiler 下还需要手写 useMemo 吗?
7.1 关键:先把一行代码拆成两件事
js
const blocks = useMemo(() => parseIntoBlocks(text), [text]);
这行代码同时在干两件事:
- A. 保持 blocks 数组引用稳定,让子组件跳过逻辑生效 → React Compiler 能替代,而且做得更好。
- B. 避免每次 token 到来都重跑一遍整篇解析 → Compiler 不能替代,但手写的 useMemo 也从来没做到过,因为
text每次都变,缓存必然失效。
准确说法:A 可以删掉不写;B 本来就不是 useMemo 该管的。
7.2 React Compiler 到底做了什么
它做的是编译期自动推断依赖 + 插入 memoCache,大致等价于把一大片手动 memo 替你写了:
js
// 你写的
const blocks = parseIntoBlocks(text);
// 编译后(示意)
const $ = _c(2);
let blocks;
if ($[0] !== text) {
blocks = parseIntoBlocks(text);
$[0] = text; $[1] = blocks;
} else {
blocks = $[1];
}
三个关键点:
- 它 memo 的是"表达式结果",粒度比 useMemo 更细。
- 缓存存在当前 fiber 的 memo cache 里,生命周期和 useMemo 一致。
- 它会 memoize 组件的返回值本身,props/state 没变时子树根本不进入 render 阶段。
7.3 为什么 B 项目标谁也救不了
缓存命中率取决于"输入有没有变",而流式场景下输入每一次都在变。于是手写 useMemo 和编译器自动 memo 都是每次 miss、每次全量重算,复杂度都是 O(n²)。
Compiler 解决的是"同样的输入别算第二遍",不是"输入变了只算变化的部分"。后者是算法问题,只能靠增量解析 + 外部缓存。
正确做法是把"可复用的部分"提到组件实例之外,key 用内容而不是位置:
js
const blockCache = new Map<string, Block[]>();
function parseIncremental(text: string): Block[] {
const h = djb2(text);
if (blockCache.has(h)) return blockCache.get(h)!;
const out = parseIntoBlocks(text);
blockCache.set(h, out);
return out;
}
配合 chunk 切分思路:按段落/代码块边界切块,每块独立解析、独立缓存、独立 key。第 100 个 token 到达时只有最后一块需要重算,前 99 块缓存全部命中。这一步的收益量级远超任何 memo 手段。
7.4 编译器会失效的场景
- 文件/函数被标注
"use no memo",或语法不被支持导致编译失败 → 整个函数退回手动模式。 - 值流入可能产生副作用的地方 → 该值的 memoization 被主动失效。
- 依赖是闭包里的可变变量而非 state/prop → 推断不到依赖,可能一直命中旧值。
- 需要"跨实例 / 跨卸载"的缓存 → Compiler 缓存跟着 fiber 走,卸载即丢。
7.5 React 19 + Compiler 下的推荐写法与取舍
js
// ① 模块级增量缓存:唯一真正解决 O(n²) 的东西
const blockCache = new Map<string, Block[]>();
function StreamingMessage({ stream }: { stream: AsyncIterable<string> }) {
const bufRef = useRef('');
const [text, setText] = useState('');
const [, startTransition] = useTransition();
const rafRef = useRef(0);
useEffect(() => {
(async () => {
for await (const chunk of stream) {
bufRef.current += chunk;
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
if (!bufRef.current) return;
startTransition(() => setText(autoCloseCodeFences(bufRef.current)));
});
}
}
})();
}, [stream]);
// ② 不再写 useMemo ------ 编译器自动缓存;同时这里已经是增量解析
const blocks = useMemolessParse(text);
return (
<VirtualList items={blocks} estimateHeight={24}>
{(b) => <Block key={`${b.id}-${djb2(b.content)}`} block={b} />}
</VirtualList>
);
}
取舍清单:
- 删掉:只为引用稳定而写的 useMemo / useCallback / React.memo。
- 保留并加强:模块级增量缓存 + 内容哈希 key。
- 保留:节流(rAF / 50--100ms)、虚拟滚动、Worker 卸载。
- 保留:Strict Mode 下的纯度纪律、Effect 的 cleanup 幂等。
八、全局总结对照表
| 维度 | React 15 Stack Reconciler | React 16+ Fiber |
|---|---|---|
| 数据结构 | 树 + 递归调用栈 | 链表 + 循环 |
| 执行方式 | 同步,一气呵成 | 异步可中断,时间切片 ~5ms |
| 主线程 | 长任务阻塞,掉帧 | 让出控制权,交互优先 |
| 优先级 | 无(FIFO) | Lanes 位掩码,可抢占、可丢弃 |
| 副作用 | render 里可写(危险) | render 必须纯,Commit 才落 DOM |
| 上层能力 | 无 | Suspense / Transition / 流式 SSR |
| 对 AI 流式的价值 | 长回复后半段必然卡顿 | 输出与交互并行,但仍需节流+增量解析+虚拟滚动 |
九、四句核心结论
-
Fiber 给你的是"不被卡死"的底线保障,不是"很快"的上限保证。 AI 长文本渲染的性能天花板,取决于你有没有把"每次要干多少活"降下来------分块、增量、缓存、虚拟化,这四样做到位,Fiber 的并发能力才会真正变成用户体验上的顺滑。
-
可中断 → 可丢弃 → 可重试 → 必须幂等 → 必须纯。 这不是代码规范层面的洁癖,是 Fiber 架构能成立的必要条件。Strict Mode 双重调用是故意的故障注入:纯函数双调无害,不纯函数双调必现。
-
Fiber 丢的是"这份渲染工作",不是"这次更新"。 业务状态最终一定正确,但它不保证每一帧中间态都被画出来、不保证 effect 每次都跑、也不提供饥饿时间的公开上限。把"必须精确发生"的逻辑移出 render/effect,把"必须实时"的东西排除在 transition 之外。
-
React Compiler 让你不必再为"引用稳定性"手写 useMemo,但它改变不了"输入变了就必须重算"。 AI 流式恰恰是输入每一次都在变的场景,真正的答案是把缓存从"组件内、跟随输入整体失效"搬到"模块级、按内容块独立失效",剩下的稳定性问题交给编译器。
十、落地优先级(最终版)
scss
节流(rAF/50--100ms) ≈ 增量解析 + 外部缓存 > 稳定 key > 虚拟滚动 > startTransition / 状态分层 > Worker 卸载 > 手动 useMemo(可删除)
Fiber 主要承接调度层,并为其他各项提供"不卡顿"的运行环境;前四项才是性能收益的大头。