读 React 源码的时候,很多人第一次产生"我好像懂了但又没完全懂"的感觉,就是在ensureRootIsScheduled到commitRoot这一段。这段代码横跨调度器和渲染器,把任务优先级、时间切片、Fiber 树遍历、副作用提交串成一条完整的链路。你可以把WorkLoop想象成一个工厂的生产线:ensureRootIsScheduled是接单系统,调度器是排产系统,renderRoot是加工车间,commitRoot是质检和发货。任何一个环节掉链子,整个订单都交付不了。今天这篇文章,我想把这个全景拆开揉碎讲清楚,特别是那些读源码时容易被忽略的边界条件和设计取舍。适合所有想深入理解 React 运行机制、或者正在准备源码级别面试的人。
1. 先分清两条循环:Scheduler 的 WorkLoop 和 Renderer 的 WorkLoop
1.1 为什么"工作循环"会有两个
很多初学者看源码时会被绕晕,就是因为没分清 React 里其实有两条独立的循环。
第一条是调度器Scheduler内部的任务循环。它维护一个任务队列,每次从队列里取出最紧急的任务执行。这个循环跑在浏览器宏任务层面,靠MessageChannel驱动。每条任务都有自己的超时时间,到了时间就必须执行,执行完再回到队列取下一条。
第二条是渲染器Renderer的协调循环。它的核心是workLoopSync和workLoopConcurrent,负责消费 Fiber 节点,逐个调用beginWork和completeWork,直到整棵 Fiber 树遍历完成。注意,渲染器只负责加工 Fiber,不负责决定"什么时候开始干活"以及"干到哪一步该停"。这两个决策权在调度器手里。
两条循环是怎么协作的呢?调度器从任务队列里取出一个回调,这个回调往往就是performConcurrentWorkOnRoot或performSyncWorkOnRoot。渲染器在这个回调里启动自己的workLoop。如果当前是并发渲染,渲染器每处理一个 Fiber 节点都会调用shouldYield()判断时间切片是否用完了。用完了就暂停,把控制权还给浏览器,等下一次宏任务再继续。
1.2 两条循环的边界:谁生产任务,谁消费任务
为了把边界画清楚,我们得先给任务下个定义。调度器里的"任务"不是 Fiber,而是一个个回调函数。这些回调大多是performConcurrentWorkOnRoot、flushSyncWork、flushPassiveEffects这样的函数。它们被scheduleCallback或scheduleSyncCallback放进调度队列。
渲染器里的"任务"则是 Fiber 节点。workLoop 不断从 workInProgress 指针往下走,每走一步处理一个节点。它不管这个节点是谁排队排进来的,只负责把当前节点加工完,然后跟随return、sibling、child指针走向下一个。
所以我更愿意这样理解:调度器是"生产任务"的一方,它根据更新优先级生成一个回调并排入队列;渲染器是"消费任务"的一方,它把回调里的 Fiber 树从根到叶子完整地过一遍。生产循环关心的是"任务急不急、何时执行",消费循环关心的是"树到哪了、是否该让路"。
2. ensureRootIsScheduled:入口处的优先级角力
2.1 一个更新进来后发生的第一件事
用户触发了一次setState,或者某处调用了dispatchReducerAction,最终都会汇合到scheduleUpdateOnFiber。这个函数会沿着 Fiber 的 return 指针一路向上找到FiberRootNode,然后调用markRootUpdated标记该根上有新的更新,最后走到本次全景的起点:ensureRootIsScheduled。
ensureRootIsScheduled的第一件事是检查根上是否已有正在执行的任务。如果存在,并且这个任务还没开始渲染,那就直接复用,不需要重新排队。这个检查对应宏任务里的时机问题:同一个事件循环内多次触发更新,不必每次都向调度器注册一个新回调。
接着它会计算当前根的新任务优先级。React 内部用 lane 模型表达优先级,从最高的SyncLane到最低的IdleLane,共 30 多条 lane。getNextLanes会把 root 上 pendingLanes 里最高优先级的 lane 取出来,作为本次要渲染的 lanes。这步很关键:lane 不是单一数字,换算后可能是一组 lane 的集合,所以后续渲染时经常要处理"批量"而不是单条更新。
// 基于 React 18 源码结构简化的入口逻辑 function ensureRootIsScheduled(root) { const existingCallbackNode = root.callbackNode; // 先计算当前还有哪些需要处理的更新 const nextLanes = getNextLanes(root, root.pendingLanes); if (nextLanes === NoLanes) { // 没有需要渲染的更新,清空 existingCallback return; } let newCallbackNode; if (includesSyncLane(nextLanes)) { // 同步优先级:走同步队列,不经过时间切片 newCallbackNode = scheduleSyncCallback( performSyncWorkOnRoot.bind(null, root) ); } else { // 并发优先级:交给 Scheduler 按超时时间调度 const schedulerPriority = lanesToSchedulerPriority(nextLanes); newCallbackNode = scheduleCallback( schedulerPriority, performConcurrentWorkOnRoot.bind(null, root) ); } root.callbackNode = newCallbackNode; }2.2 lane 模型如何决定"谁先走"
lane 模型本质上是一套二进制位掩码。每条 lane 占用一个比特位,值越小优先级越高。想要判断两条 lane 谁更优先,比较的是二进制中最高位 1 的位置。想要知道一组 lane 是否包含同步优先级,只要检查lane & SyncLane是否非零。
为什么要用这种设计?因为更新可以来自不同场景,优先级需要灵活合并。比如一次点击事件里同时触发了输入更新和用户交互更新,它们可以各自占据不同的 lane,但同时被同一帧渲染。如果是传统的数字优先级,合并后很难表达"一组任务整体提前"这个语义。
举一个具体例子。用户在一个输入框里打字,第一键触发了InputDiscreteLane,紧接着一个startTransition包裹的状态更新触发了TransitionLane。当前一共有两条 lane 同时 pending。getNextLanes只会返回InputDiscreteLane,因为输入事件优先级更高。调度器会先安排performConcurrentWorkOnRoot处理输入更新。过渡更新只能在下一轮调度中等待。
2.3 同一条根上多次更新的合并逻辑
还有一个容易被忽略的场景:同一帧内用户疯狂点击按钮,连续触发了十几次更新。ensureRootIsScheduled每次执行时都会发现existingCallbackNode存在,且任务尚未开始,于是直接跳过重新排队。这样十二个更新只会产生一个回调任务。
但如果第一个回调已经开始渲染了,此时又来了新更新,会发生什么?React 会标记这个回调对应的任务需要重新渲染,并在本轮渲染完成后再次进入ensureRootIsScheduled。这种情况下,之前的渲染工作可能会被部分丢弃,重新从根开始。这就是 React 并发渲染的一个重要特征:允许重复渲染,以保证最新状态被包含进来。
所以可以总结成一句话:入口处做的是"合并、去重、提权"三件事。合并让多次更新聚合成一个任务,去重让已有任务不被重复排队,提权让高优先级 lane 能打断低优先级渲染。
3. Scheduler 调度循环:时间切片与任务堆积的博弈
3.1 调度器如何驱动回调
如果ensureRootIsScheduled决定走并发调度,那它会调用scheduleCallback,这个函数属于独立的scheduler包。调度器内部维护两个队列:timerQueue 和 taskQueue。taskQueue 里的任务已经到期,按过期时间排序;timerQueue 里的任务还没到期,按开始时间排序。
调度器本身也有一个 workLoop,但和渲染器的 workLoop 不一样。它循环处理 taskQueue 里到期的任务,调用任务各自的callback函数。这个 workLoop 不会光靠requestAnimationFrame或setTimeout驱动,而是使用MessageChannel。
每次调度器想"醒来"处理任务时,就向MessageChannel的 port 发送一个消息,浏览器会在当前宏任务结束后、下一个宏任务开始时触发回调。相比setTimeout,MessageChannel的时序更稳定,不受 4ms 嵌套定时器节流的影响,而且不需要处理延迟。
调度器的performWorkUntilDeadline会设定一个截止时间:currentTime + yieldInterval。在这个截止时间之前,调度器可以不断取出任务执行;超过截止时间,就必须让出线程,等下一次宏任务继续。这个分配机制的细节值得展开。
// 调度器回调的大致逻辑(简化版) function flushWork(hasTimeRemaining, initialTime) { let currentTime = getCurrentTime(); currentTask = peek(taskQueue); while (currentTask) { if (!hasTimeRemaining && currentTask.expirationTime > currentTime) { // 当前任务还没到必须执行的时间,且时间片已用完 break; } const callback = currentTask.callback; if (typeof callback === 'function') { currentTask.callback = null; const didUserCallbackTimeout = currentTask.expirationTime <= currentTime; const continuationCallback = callback(didUserCallbackTimeout); if (typeof continuationCallback === 'function') { // 任务没跑完,返回一个续跑函数 currentTask.callback = continuationCallback; } } else { pop(taskQueue); } currentTime = getCurrentTime(); currentTask = peek(taskQueue); } }3.2 5ms 切片与 shouldYield 的实际意义
React 的并发渲染默认时间切片长度是 5ms。这个值听起来很短,但实际使用中效果明显。它意味着渲染器每工作 5ms 就要让出主线程给浏览器处理事件、绘制帧、执行其他回调。用户输入事件因此能及时响应,不会因为一次巨大的列表渲染卡住几百毫秒。
渲染器通过shouldYield()来检查当前切片是否耗尽。这个函数在 React 18 里最终会调用Scheduler.unstable_shouldYield,其实现就是比较当前时间是否已经超过了currentTask.expirationTime和开始时间加上切片长度的最小值。
function shouldYieldToHost() { const currentTime = getCurrentTime(); if (currentTime >= taskTimeout) { return true; // 当前任务本身已过期,继续工作也没意义 } return currentTime >= deadline; }这里有个反直觉的地方:即使时间切片用完了,如果当前任务已经过期,调度器反而不会让出线程。因为任务过了超时时间,再拖下去只会让更新延迟更严重。这种情况下 React 会选择"加急"处理,而不是机械地守切片规则。
实践中,我经常用这个机制解释为什么某些低优先级任务反而会打断高优先级任务——因为低优先级任务可能已经饿太久,超过了自身的 expirationTime。
3.3 任务被中断后如何续跑
调度器里有一个针对可中断任务的关键设计:任务的回调函数如果返回了一个新的函数,调度器会把任务留在队列里,下次继续调用。React 中performConcurrentWorkOnRoot就会返回performConcurrentWorkOnRoot自己,前提是渲染被中断且尚未完成。
这带来一个好处:任务不需要重新排队,优先级保持不变,只需等下一轮调度继续。渲染器内部则通过workInProgress指针记住上次遍历到哪个 Fiber 节点。恢复渲染时,直接从该节点继续,而不是从根节点重新开始。
不过要注意,中断恢复并非完全没有成本。React 16 之前的栈调和用递归,打断就彻底丢失;React 16 之后用链表遍历,配合双缓冲 Fiber 树,让恢复成为一种常态操作。这是 Fiber 架构相比旧架构最大的进步。
另一个细节是"取消"操作。如果在任务还没开始执行时,根上来了更高优先级的更新,ensureRootIsScheduled会调用cancelCallback取消旧任务,然后排一个新任务。但如果旧任务已经开始执行,只能让它继续到第一次让出点,再在renderRootConcurrent的出口处判断是否需要重新渲染。这个判断基于workInProgressRootRenderLanes和当前getNextLanes的比较。
4. renderRoot 阶段:Fiber 树的遍历与 beginWork / completeWork
4.1 workLoopSync 与 workLoopConcurrent 的差别
performSyncWorkOnRoot内部调用renderRootSync,performConcurrentWorkOnRoot内部调用renderRootConcurrent。两者的主体框架相似,关键差异在 workLoop 上。
同步 workLoop 长这样:
function workLoopSync() { while (workInProgress !== null) { performUnitOfWork(workInProgress); } }并发 workLoop 则多了一个让步检查:
function workLoopConcurrent() { while (workInProgress !== null && !shouldYield()) { performUnitOfWork(workInProgress); } }同步循环会一口气把所有 Fiber 节点处理完,期间不让出主线程。并发循环在每次处理完一个节点后检查shouldYield(),如果切片耗尽,立即退出,等待下次宏任务继续。
这也解释了为什么并发渲染必须配合时间切片。如果渲染器只能同步跑完,调度器就算把任务拆成无数切片也没有意义,因为一旦开始就无法停下。
4.2 递归"递"与"归"的成本
performUnitOfWork做的事可以细化成两步。第一步是调用beginWork,处理当前节点,返回子节点。如果子节点存在,就把子节点作为下一个 workInProgress,进入"递"阶段。第二步是当我们遍历到叶子节点、没有子节点时,调用completeWork完成当前节点,然后寻找兄弟节点;没有兄弟节点就回到父节点,继续完成父节点。这就是"归"阶段。
这里有一个开销容易被忽视:completeWork并不是简单的状态记录,它会为原生 DOM 节点创建或更新实例,为函数组件执行useEffect、useLayoutEffect等副作用收集。这些工作在同步和并发模式下都会执行,但并发模式下被切碎后,每个宏任务只处理一部分节点,直觉上总耗时应该差不多,实际却因为浏览器绘制的机会更多,用户感知到的卡顿明显减少。
如果你对遍历顺序有经验,一定知道 React 的遍历是"深度优先、前序"的。也就是说,父节点先于子节点进入beginWork,但completeWork的顺序是子节点先完成、父节点后完成。这种顺序和 DOM 的生成顺序高度一致,也让副作用链表能自然地从叶子向根收集。
4.3 中断恢复为什么能做到不丢进度
Fiber 的alternate指针是这个问题的关键。每个 Fiber 节点都有一个指向工作树对应节点的alternate字段。渲染开始时,workInProgress树要么复用 current 树的节点,要么新建节点。workInProgressRoot记录了当前渲染的根,workInProgress记录了当前处理到的节点。
假设我们渲染到第 100 个节点时被中断。第 100 个节点的memoizedProps、memoizedState等中间结果已经保存在 workInProgress 树里。下一轮恢复时,直接从workInProgress继续往下走即可,不需要重新计算前 99 个节点。
但中断期间如果有新更新进来,情况就复杂了。React 的选择是丢弃当前不完整的 workInProgress 树,重新从根开始。为什么?因为新旧更新交错会导致 props 和 state 不一致,继续基于旧状态渲染会产出错误结果。丢掉重来虽然浪费,但能保证一致性。
实际分析里,我倾向于把"中断"和"失效"分开看待。单纯时间切片耗尽的叫中断,是良性的;新更新打断了旧的低优先级渲染叫失效,会产生一次额外渲染。这两者在开发工具的 profiling 里都能观察到,表现为 "render duration" 被切割成多段,或者出现重复渲染记录。理解了这一点,排查性能问题时就不会误判。
5. commitRoot:为什么提交阶段必须同步
5.1 三阶段提交:before mutation / mutation / layout
渲染阶段结束,意味着 Fiber 树已经完整地计算出了新的状态、props、副作用。但这棵树上还没有任何 DOM 真正被修改。commitRoot就是把这些计算结果落地的阶段。
整个 commit 过程被拆成三个阶段:
第一个阶段是commitBeforeMutationEffects,在 DOM 修改之前执行。这里最典型的工作是调用getSnapshotBeforeUpdate,让 class 组件在 DOM 变更前拿到当前快照。此外还会处理一些与文本节点相关的内容,把需要更新的文本值准备好。
第二个阶段是commitMutationEffects,核心的 DOM 操作发生在这里。节点的新增、删除、更新都在这个阶段执行。React 会遍历副作用链表,对每一个带有MutationMask标记的 Fiber 执行对应的 DOM 操作。比如把新的 DOM 节点插入容器、删除旧节点、更新属性等。useLayoutEffect的销毁函数也会在这个阶段触发,确保清理发生在 DOM 变更前。
第三个阶段是commitLayoutEffects,DOM 操作完成后触发。componentDidMount、componentDidUpdate、useLayoutEffect的回调都在这里执行。这个阶段的名字也暗示了它的时机:布局相关操作必须等 DOM 真正插入后才能读取真实几何信息。
三个阶段之所以必须放在同一个同步过程中,是因为 DOM 一旦被修改,中间就不能被插入其他任务。否则浏览器可能在状态不一致的中间态绘制出错误的 UI,或者让事件处理器读到半更新半未更新的 DOM。
5.2 副作用链表为什么是 commit 的前提
如果你去看 render 阶段生成的数据结构,会发现 Fiber 节点上有一个firstEffect和nextEffect指针。它们把所有带副作用的节点串联成一个链表,commit 阶段就是遍历这条链表来执行 DOM 操作的。
这条副作用链表是在 completeWork 阶段构建的。completeUnitOfWork会把子树里所有带副作用的节点串起来,再把整条链挂到当前节点上。最终根节点的firstEffect指向整棵树第一个需要提交的节点。commit 阶段不需要再遍历整棵 Fiber 树,只要顺着副作用链表走一遍即可。
副作用节点的"增删改查"标记也很讲究。比如Placement标记表示节点要插入,Update表示属性或文本要更新,Deletion表示节点要删除。它们被收集在flags字段中,commit 时通过位运算判断该执行哪些操作。如果你曾经疑惑为什么 React 不直接在 render 里修改 DOM,答案就在这:把"计算"和"提交"分离能让计算可重复执行,而提交只在最后发生一次。
5.3 并发特性下 commit 的设计取舍
React 18 引入了很多并发特性,但commitRoot始终是同步的。这不是技术限制,而是刻意设计的选择。同步 commit 保证 DOM 变更过程中的一惯性,避免浏览器在中间状态绘制。
不过,并发渲染的视角下,commit 本身也承载了一些特殊逻辑。比如commitRoot执行前会检查根上是否有新的更高优先级更新。如果渲染阶段完成但节点已被新的同步更新标记为过期,React 会放弃提交,重新进入ensureRootIsScheduled。这个流程在commitRoot的开头部分有体现。
还有passive副作用(即useEffect)并不在 commit 阶段执行。它们会被记录在pendingPassiveEffects上,等 commit 完成后异步调度。这算作 commit 的后置阶段,但因为不在同步提交链路里,可以随时被更高优先级任务抢占,不会阻塞用户交互。
6. 全景串联与排查经验
6.1 一次点击事件引发的完整链路
为了把前面几节的逻辑串成一条线,我们模拟一个典型场景:页面上有一个按钮,点击后触发setCount(c => c + 1),同时一个startTransition包裹的数据请求开始更新列表。
点击事件触发dispatchReducerAction,沿 Fiber 链路上行找到FiberRootNode,调用ensureRootIsScheduled。源码里这里又叫做sync中优先级的lane。此时getNextLanes会拿到同步 lane。
然后scheduleSyncCallback把performSyncWorkOnRoot排入同步队列。浏览器当前宏任务结束前,React 会主动冲刷同步队列,执行performSyncWorkOnRoot。
进入renderRootSync,从根节点开始beginWork。根节点下有 App、Button、List 等组件。beginWork对 Button 生成新的状态,计算出新的子节点;对 List 因为被 transition 包裹,本次可能不渲染。
遍历到叶子节点后各个节点依次completeWork。整个渲染阶段在当前宏任务内同步完成。commitRoot随即执行,DOM 更新完毕。
接着,transition 对应的更新进入调度器。因为优先级较低,scheduleCallback把它排进 taskQueue,等到主线程空闲时执行。渲染器再次从根节点开始,但这次因为优先级低,每处理一个节点都会检查时间切片。如果列表很大切片用完,workLoopConcurrent 退出,等待下一个宏任务,再继续。
最后,列表渲染完成,commitRoot 执行,整个并发更新落地。这期间用户的输入事件一直能保持响应,因为我们没有长时间霸占主线程。
6.2 调试 WorkLoop 的几个实用手段
读通源码之后,真正能帮你验证理解的是调试手段。第一个方法是在伴生调试工具里查看 "Render Duration" 和 "Commit Duration"。并发任务下 render duration 会被切成多段,配合 commit duration 能看出哪一段最耗时。
第二个方法是在浏览器 Performance 面板里记录长任务。展开一个长任务的调用栈,能看到performConcurrentWorkOnRoot→renderRootConcurrent→workLoopConcurrent的调用链。如果栈中频繁出现shouldYield返回 true,说明时间切片在工作;如果渲染任务总是从根节点开始,则说明渲染被更高优先级更新打断了。
第三个方法更硬核:直接在源码里打断点。比如在workLoopConcurrent的while条件里打条件断点,观察workInProgress的节点名和shouldYield()的值。手动模拟一个超大列表渲染,能直观地看到 workInProgress 在多个宏任务间移动。要注意,React 源码经过编译工具处理后函数名可能改变,最好在构建配置中关闭压缩,或者直接阅读源码仓库的调试构建版本。
6.3 我踩过的几个坑
我在实际项目和源码梳理中遇到过几个典型误区,这里分享给正卡在理解路上的朋友。
第一个误区是认为setState一定会导致ensureRootIsScheduled被调用。实际上,如果更新发生在某一帧的渲染周期内,且有更高优先级的更新已经在执行,新更新可能只是被 "mark" 到 pendingLanes 上,并不会立刻产生新的调度。这也解释了为什么连续快速 setState 多次,渲染只发生一次。
第二个误区是混淆了「阻塞渲染」和「任务被抢占」。以前我总以为高优先级更新会立即中断当前渲染,调试时发现低优先级任务还会继续跑一小段,直到下一个shouldYield检查点才停。这个停顿发生在每一个performUnitOfWork的结束处,因此中断粒度是"每个节点",而不是"任意行代码"。理解了这一点,就明白为什么 React 要求组件渲染函数是纯的、不能有副作用——中断随时可能发生在任意一个节点之间,副作用会反复执行。
第三个坑是时间切片长度的感受。5ms 在 PC 上压力不大,但在低端移动设备上,一次 Fiber 树更新可能要切分成几十段,任务恢复开销会变得可观。如果你发现页面滚动时有明显卡顿,除了排查渲染本身的时间,还要留意多次宏任务间MessageChannel调度的延迟。某些浏览器在后台标签页会大幅降低定时器/消息事件的触发频率,导致并发渲染迟迟不恢复,这时候用户体验会差得超乎预期。
我记得有一次做首屏优化,遇到一个奇怪现象:并发渲染下,整个页面要等一个低优先级的图表更新完成才显示出来。排查后发现是高优先级输入更新的渲染每次都因状态未变化而被合并掉,反而把图表任务一直堵在调度队列里。加了几个优先级标记和批处理策略的调整,问题才解除。这个过程再次说明,只看单个函数无法理解 React 的调度全貌,必须把ensureRootIsScheduled、调度器、渲染器和 commit 阶段当作一个整体来看。
把这条全景链路走一遍,胜过单独背五个源码函数。下次再遇到 React 相关的性能问题,不妨先问自己一句:这条工作循环现在被卡在哪一环节了?找到卡点,解决方案往往就水到渠成。