模块四 · 性能、调度与架构
Fiber 与并发:让出主线程的权利
Fiber 把渲染拆成可中断的工作单元,Lane 决定谁先执行。这一节讲清 Fiber 结构、双缓冲、优先级,以及 transition 与 Suspense 的适用边界。
读完这一节,你能做到
- 说清 Fiber 是什么,以及它解决了什么问题。
- 用双缓冲解释"可中断渲染"为什么不会撕裂界面。
- 说出 Lane 优先级模型的作用。
- 判断什么时候该用
useTransition与useDeferredValue。
一、Fiber 是什么
Fiber 是 React 的"工作单元",把一次大的渲染拆成许多小任务:
Fiber 节点 = 组件实例 + 工作状态 + 链表指针(child/sibling/return)
它带来三个能力:
- 可中断:渲染到一半可以让出主线程,先处理用户输入。
- 可恢复:下次从上次的节点继续。
- 可丢弃:优先级更高的更新到来时,丢弃低优先级的工作。
在没有 Fiber 之前,递归渲染一旦开始就无法中断,长任务会阻塞输入。
二、双缓冲与一致视图
React 同时维护 current 与 workInProgress 两棵树:
current(屏幕上的) ←→ workInProgress(构建中的)
render 在 workInProgress 上进行,commit 时整体切换。这样即使中途被打断或丢弃,屏幕上的树仍然完整,不会出现"改了一半"的界面。
三、Lane 优先级
React 用 Lane(二进制位)表示更新优先级:
| 优先级 | 典型来源 |
|---|---|
| 同步/紧急 | 点击、输入 |
| 默认 | 普通 setState |
| Transition | startTransition |
| 空闲 | 预渲染、离屏内容 |
高优先级更新可以打断低优先级的 render;低优先级任务会在空闲时重做。这就是"输入不卡"的来源:用户打字产生的更新优先于大列表重算。
四、并发 API 的用途
| API | 作用 | 适用 |
|---|---|---|
useTransition | 把更新标记为非紧急 | 搜索过滤、切换大列表 |
useDeferredValue | 延迟某个值的更新 | 输入框联动重组件 |
Suspense | 声明加载边界 | 代码分割、数据加载 |
示例:
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState("");
function onChange(e) {
setQuery(e.target.value);
startTransition(() => setFiltered(filter(e.target.value)));
}
输入框保持响应,列表更新可以稍后完成。isPending 用来显示"正在更新"的提示。
五、并发不是"多线程"
React 的并发仍然是单线程,只是把任务切成片,在浏览器空闲时执行。它不会让计算变快,只会让高优先级任务先执行。
所以:
- 重计算放进
useMemo或 Worker,而不是指望 transition。 - transition 适合"更新频率高、可以稍后"的场景。
- 不要用它包裹请求,它只管渲染优先级。
六、常见坑
- 以为并发渲染会开新线程。
- 用 transition 包裹受控输入,导致输入框反而变慢。
- 在 Suspense 边界外发请求,加载态无法被捕获。
- 忘记处理
isPending,用户以为点击没反应。 - 把长任务放进 render,transition 也救不了。
七、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 写一个 5,000 行列表的搜索,分别用普通更新与
useTransition对比输入延迟。 - 用
useDeferredValue延迟重组件渲染,观察输入流畅度。 - 用 Suspense 包裹一个懒加载组件,处理加载与错误态。
② 让 AI 写同一题
把第 1 题交给 AI,补一句 输入框必须保持即时响应。
③ 对比要盯的点
- 它把输入框自己的
setState也包进startTransition了吗?包了输入就会卡。 - 耗时的过滤计算它是放在 transition 内还是外?放在外面,transition 白做。
- 它有没有说“并发渲染更快”?并发只改执行顺序,不改总时长。
④ 一句话取舍
它给的 useTransition,在你机器上真让输入变顺了吗?用 Performance 面板看一眼,不要只看它说“应该会好很多”。
八、自测
- Fiber 解决了什么问题?
- 双缓冲如何保证视图一致?
- Lane 的作用是什么?
- transition 与 defer 的区别是什么?
- 并发渲染会让计算变快吗?
参考答案要点:可中断渲染;workInProgress 构建后切换;优先级调度;一个标记更新、一个延迟值;不会,只改变执行顺序。
附:Fiber 的遍历顺序
beginWork(向下)→ completeWork(向上)
从根开始,先处理 child,没有 child 就处理 sibling,都没有就 return 到父节点。这个顺序让 React 可以在任意节点暂停与恢复。
commit 阶段则按“先子后父”的顺序执行副作用,保证子组件的 DOM 先于父组件被处理。
附:Lane 的位运算
Lane 用一个整数的不同二进制位表示不同优先级:
0b0001 SyncLane
0b0010 InputContinuousLane
0b0100 DefaultLane
0b1000 TransitionLane
判断“是否包含某个优先级”用按位与,合并用按位或。好处是一次运算就能处理多个优先级,比数组遍历快。
附:并发下的副作用
并发渲染可能让 render 执行多次、被丢弃,所以:
- 不要在 render 里写 ref、发请求、改全局。
- effect 在 commit 后执行,不会被“丢弃的 render”触发。
useLayoutEffect仍然是同步的,不要在里做重计算。
并发不是“让代码跑得更快”,而是“让重要的更新先跑”。理解这一点,就不会把 transition 当成性能银弹。
附:时间切片怎么切
React 在 render 阶段每处理完一个 Fiber,就检查是否用完了当前时间片(默认 5ms 左右)。如果用完,就把控制权交还浏览器,让绘制与输入先执行,之后再从暂停的节点继续。
切片单位是 Fiber,不是组件:一个大组件内部有很多节点,也能被切开。这也是为什么 render 必须是纯的——它可能被切成很多段执行。
附:Suspense 的边界
Suspense 捕获的是“子组件抛出的 Promise”,不是所有异步操作。数据请求要配合支持 Suspense 的请求库或框架 loader,普通 useEffect 请求不会被捕获。
边界要放在“加载态有意义”的位置:整页骨架、区块骨架,而不是每个按钮。边界太细,加载态会闪烁;边界太粗,一个慢请求会拖住整页。
附:错误边界与 Suspense 的分工
错误边界捕获渲染错误,Suspense 处理加载态;两者都不能捕获事件回调与异步代码里的错误。事件里的错误要自己 try/catch,异步错误要交给请求库或全局监听。
附:Fiber 与栈调和的差异
栈调和是递归:一旦开始就无法暂停,长任务会阻塞输入。Fiber 改成链表遍历:每个节点是一个工作单元,可以暂停、恢复、丢弃。代价是更多的指针操作与内存占用。Fiber 是调度单位,双缓冲保证一致,Lane 决定先后;并发不是并行,切片让输入优先。理解这三件事,就理解了并发——它改变顺序,不改变总量。先修长任务,再谈优先级;render 可丢,commit 不可丢。切片是手段,一致是底线;优先级服务于体验。理解调度,才理解 React;并发是体验工程,不是性能魔法。先量后调,别把并发当银弹。调度服务于体验,一致先于速度。先一致,再快。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解