Skip to main content

第 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,它干两件事:

  1. beginWork,进入当前 fiber,往下找子节点;
  2. 没有子节点了,就调 completeWork,往上回溯。
const next = beginWork(current, unitOfWork, entangledRenderLanes);
if (next === null) {
completeUnitOfWork(unitOfWork); // 没孩子,往上收尾
} else {
workInProgress = next; // 有孩子,钻进去
}

beginWorkReactFiberBeginWork.js:4091,第 6 章专讲;completeWorkReactFiberCompleteWork.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 阶段:动手,不可中断

commitRootReactFiberWorkLoop.js:3416)开始提交,三个子阶段挨个执行:

子阶段干什么源码
before mutation动手前:读旧 DOM、触发 getSnapshotBeforeUpdate 之类commitWork.js:340
mutation真正改 DOM:增删节点、更新属性、执行卸载副作用commitWork.js:1944
layout布局后:执行 layoutEffect、调 refcommitWork.js:2876

这三段调用在 commitRootImpl 里依次出现(369938573951)。

为什么 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 消费②。

动手实验

  1. 断点跟全程:在 beginWork(ReactFiberBeginWork.js:4091)和 commitMutationEffects(ReactFiberCommitWork.js:1944)各打一个断点,点一下按钮。观察:beginWork 会进出很多次(一棵树从上到下),而 commitMutationEffects 只在最后出现一次。这就是 render 与 commit 的量级差。
  2. 验证可中断:在 workLoopConcurrentyieldAfter 那行打断点,把一个大的组件树渲染一下,看它是不是中途真的让出了。
  3. 看 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 内部。