第 4 章:全局地图——一次更新的完整旅程
前三章给了舞台(FiberRoot)、图纸(元素)、施工结构(Fiber)。现在把它们串起来:一次点击,React 内部到底走完哪些路。
就用全书出现次数最多的例子:
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
你每点一次按钮,React 就走完一遍下面的地图。
先记住这一句:一次更新分两段。render 阶段算新 Fiber 树,可中断;commit 阶段把树落成 DOM,不可中断。前者动脑子,后者动手。
一图流:全局地图
这张图是全书的地图。后面每一章,都是某个路口停下来放大的特写。先把每个编号对应的源码记下来。
① 入队:setState 制造 update
setCount 的内部叫 dispatchSetState,它最终做的还是第 1 章那三件事的翻版:createUpdate 造一个 update,enqueueUpdate 挂到 fiber 的 updateQueue 上,scheduleUpdateOnFiber 通知"有事要做"。具体细节第 21 章用一整个章节讲。
② 调度:交给 Scheduler
scheduleUpdateOnFiber 不会立刻开算。它把任务交给调度器 Scheduler(第 5 章),Scheduler 决定"这个优先级该什么时候算"。时机到了,回调一个函数进入渲染,这个函数在 React 19 里叫 performWorkOnRoot(老版本叫 performConcurrentWorkOnRoot,19 改的名)。
③ render 阶段:动脑子,可中断
renderRoot 先调 prepareFreshStack:拿 current 树当底子,通过 alternate 复制出一棵 workInProgress 树(第 3 章的双缓冲,在这里真正用上)。然后工作循环开跑。
循环体是 performUnitOfWork,它干两件事:
- 调 beginWork,进入当前 fiber,往下找子节点;
- 没有子节点了,就调 completeWork,往上回溯。
const next = beginWork(current, unitOfWork, entangledRenderLanes);
if (next === null) {
completeUnitOfWork(unitOfWork); // 没孩子,往上收尾
} else {
workInProgress = next; // 有孩子,钻进去
}
beginWork 在 ReactFiberBeginWork.js:4091,第 6 章专讲;completeWork 在 ReactFiberCompleteWork.js:1049,第 8 章专讲。记住方向就行:beginWork 从上往下,completeWork 从下往上。
为什么说可中断:并发渲染时跑的是 workLoopConcurrent,它设了个时间片:
const yieldAfter = now() + (nonIdle ? 25 : 5);
do {
performUnitOfWork(workInProgress);
} while (workInProgress !== null && now() < yieldAfter);
25 毫秒一到,就算没算完也停下来,把控制权交还给浏览器。高优先级任务可以插队,之后从中断点接着算。这套机制的第 5、23 章会展开。
④ finishedWork:新树就绪
整个 render 阶段结束,得到一棵全新的 finishedWork 树。注意它还是纯内存的,页面上啥都没变。这一步唯一的产出,是"算清了界面应该长什么样"。
⑤ commit 阶段:动手,不可中断
commitRoot(ReactFiberWorkLoop.js:3416)开始提交,三个子阶段挨个执行:
| 子阶段 | 干什么 | 源码 |
|---|---|---|
| before mutation | 动手前:读旧 DOM、触发 getSnapshotBeforeUpdate 之类 | commitWork.js:340 |
| mutation | 真正改 DOM:增删节点、更新属性、执行卸载副作用 | commitWork.js:1944 |
| layout | 布局后:执行 layoutEffect、调 ref | commitWork.js:2876 |
这三段调用在 commitRootImpl 里依次出现(3699、3857、3951)。
为什么 commit 不可中断:DOM 改到一半停下来,页面就处于"半新半旧"的坏状态。所以 commit 全程同步跑完,一口气改完,浏览器才看到完整的新画面。
mutation 之后还有一个尾巴:useEffect 是异步的(commitPassiveMountEffects,第 14 章),不在 commit 的同步路径里。所以地图上"页面更新完成"之后,passive effect 才慢慢跑。
⑥ 收尾:等下一次更新
commit 结束,current 指针换成新树,旧树进回收池(第 3 章双缓冲)。页面安静下来,直到下一次 setState 再次入队,从 ① 重新走。
附:两种冒泡,别搞混
源码里"冒泡"出现两次,时机完全不同。
① childLanes 冒泡:预约登记,发生在 setState 被调用的瞬间(渲染之前)。 scheduleUpdateOnFiber 沿 return 链从下往上,给每个祖先的 childLanes 置位(第 22 章)。目的:让祖先在 beginWork 的 bailout 判断(第 6 章)时,不用重新渲染就知道"我子树里有活"。
② subtreeFlags 冒泡:成果汇总,发生在 render 的 complete 阶段(从下往上的那一遍)。 completeWork 处理完一个 fiber 后,bubbleProperties 把子树的 subtreeFlags / childLanes 汇总到当前 fiber(第 8 章)。目的:让 commit 阶段知道这次要动哪些地方。
完整时间线:setState(①预约)→ beginWork 向下钻(读①判断钻不钻)→ complete 向上汇(②)→ commit 消费②。
动手实验
- 断点跟全程:在
beginWork(ReactFiberBeginWork.js:4091)和commitMutationEffects(ReactFiberCommitWork.js:1944)各打一个断点,点一下按钮。观察:beginWork会进出很多次(一棵树从上到下),而commitMutationEffects只在最后出现一次。这就是 render 与 commit 的量级差。 - 验证可中断:在
workLoopConcurrent的yieldAfter那行打断点,把一个大的组件树渲染一下,看它是不是中途真的让出了。 - 看 render 不改 DOM:render 阶段全程给 DOM 打"突变监听"(
MutationObserver或直接把document.body.innerHTML打出来),会发现 render 结束前 DOM 纹丝不动,commit 一瞬间全变。
小结
- 一次更新 = render(可中断,算新树)→ commit(不可中断,落 DOM)。
- render 阶段:
prepareFreshStack复制 workInProgress 树 →performUnitOfWork循环 beginWork 向下 + completeWork 向上 → 产出 finishedWork。 - commit 阶段:before mutation → mutation → layout 三连,
useEffect异步收尾。 - 更新完,current 换树,等下一次 setState。
- 这张地图是全书索引。第 5 章看 ② 的调度器,第 6~8 章看 ③ 的 render 内部,第 17 章看 ⑤ 的 commit 内部。