读 React 源码的时候很多人第一次产生我好像懂了但又没完全懂的感觉就是在ensureRootIsScheduled到commitRoot这一段。这段代码横跨调度器和渲染器把任务优先级、时间切片、Fiber 树遍历、副作用提交串成一条完整的链路。你可以把WorkLoop想象成一个工厂的生产线ensureRootIsScheduled是接单系统调度器是排产系统renderRoot是加工车间commitRoot是质检和发货。任何一个环节掉链子整个订单都交付不了。今天这篇文章我想把这个全景拆开揉碎讲清楚特别是那些读源码时容易被忽略的边界条件和设计取舍。适合所有想深入理解 React 运行机制、或者正在准备源码级别面试的人。1. 先分清两条循环Scheduler 的 WorkLoop 和 Renderer 的 WorkLoop1.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 发送一个消息浏览器会在当前宏任务结束后、下一个宏任务开始时触发回调。相比setTimeoutMessageChannel的时序更稳定不受 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 / completeWork4.1 workLoopSync 与 workLoopConcurrent 的差别performSyncWorkOnRoot内部调用renderRootSyncperformConcurrentWorkOnRoot内部调用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 变更前。第三个阶段是commitLayoutEffectsDOM 操作完成后触发。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 相关的性能问题不妨先问自己一句这条工作循环现在被卡在哪一环节了找到卡点解决方案往往就水到渠成。