写 React 源码解析最怕什么就是只记住几个函数名比如ensureRootIsScheduled、commitRoot但不知道它们之间怎么咬合不知道一条更新从触发到页面真正变掉中间到底过了多少关。这篇是这个系列的第 3 篇我准备把 React 工作循环WorkLoop这条链路完整摊开从ensureRootIsScheduled这个总开关开始一路讲到commitRoot把改动落到 DOM 上。先给结论整条链路由两层workLoop组成。一层在调度器里负责“在浏览器有空的时候干活”另一层在渲染器里负责“构建 fiber 树、提交副作用”。这两层通过ensureRootIsScheduled接起来你才能看到 React 既能“并发渲染可中断”又能在提交时“一次性改 DOM 不回头”。这篇文章就是带你把这个全景装在脑子里。1. 先把两个 workLoop 分清楚别混在一起看很多人读 React 源码最容易栽的坑是看到workLoop就以为只有一个。实际上源码里有两个workLoop一个叫 Scheduler 的workLoop一个叫 React 渲染阶段的workLoopConcurrent/workLoopSync。名字像职责完全不同。1.1 调度器层的 workLoop给任务排队和抢时间调度器Scheduler那一层维护了一个taskQueue里面是排好序的任务排序依据是过期时间。调度器的workLoop做的事是从队列里取出最紧急的任务执行它一小段然后看时间够不够够就继续不够就让出主线程。这个workLoop长这样我简化了一下核心逻辑function workLoop(hasTimeRemaining, initialTime) { let currentTask peek(taskQueue); while (currentTask ! null) { // 如果任务过期了不管时间够不够都得执行 if (currentTask.expirationTime initialTime !hasTimeRemaining) { break; } const callback currentTask.callback; callback(); // 执行完一个任务从队列里弹出继续下一个 pop(taskQueue); currentTask peek(taskQueue); } }它不是循环调用fiber的它只负责“执行回调”。真正构建 fiber 树的回调是 React 层塞进来的performConcurrentWorkOnRoot或者performSyncWorkOnRoot。所以调度器不关心你是不是 React你只要给我一个callback我保证在合适的时机叫你。1.2 React 层的 workLoop一颗一颗地处理 fiberReact 层在收到调度器回调之后才进入自己的workLoop。这里的循环对象是 fiber 节点function workLoopConcurrent() { while (workInProgress ! null !shouldYield()) { workInProgress performUnitOfWork(workInProgress); } } function workLoopSync() { while (workInProgress ! null) { workInProgress performUnitOfWork(workInProgress); } }workLoopConcurrent里每处理完一个 fiber 节点都会问一句“时间还够吗”不够就跳出来把控制权交还给浏览器workLoopSync则一口气不回头地干完。我的理解这俩 workLoop 是老板和员工的关系。调度器是老板决定什么时候给你活儿干、给你多少时间React 是员工在给定时间里尽量多干干不完下次继续。很多人在分析“React 为什么能中断”时把注意力放在 Fiber 的可中断数据结构上其实忽略了调度器的shouldYield机制才是触发中断的那个开关。2. 总开关ensureRootIsScheduled每次更新都从这里进场在 React 源码里任何更新——不管来自setState、dispatch、useState的触发还是来自renderRoot的中断恢复——最后都会汇聚到同一个入口ensureRootIsScheduled。你可以把它理解成“急诊室分诊台”所有病人来了先在这里确定优先级再决定排哪个队、叫哪个医生。2.1 这个函数的完整逻辑拆解ensureRootIsScheduled的执行流程可以简化成三步算出当前 root 上最高优先级的 lanes跟上次记录的callbackPriority比较如果一样说明已经有任务在排队了直接复用不用重复调度如果不一样取消旧任务按新优先级派发新任务。对应到源码逻辑是这样的省略了大量边界只留主线function ensureRootIsScheduled(root, currentTime) { const existingCallbackPriority root.callbackPriority; const nextLanes getNextLanes(root, root workInProgressRoot ? workInProgressRootRenderLanes : NoLanes); const newCallbackPriority getHighestPriorityLane(nextLanes); if (existingCallbackPriority newCallbackPriority) { // 优先级没变同一个任务还在排队别重复创建回调 return; } if (root.callbackNode ! null) { // 旧的调度要取消否则会重复执行 cancelCallback(root.callbackNode); } if (newCallbackPriority SyncLane) { // 同步优先级不走调度器直接插队 scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root)); } else { // 其他优先级走调度器给一个超时时间 let schedulerPriorityLevel lanePriorityToSchedulerPriority(newCallbackPriority); scheduleCallback(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root)); } }这里有几个关键点我说一下我阅读时的体会首先是“复用已有回调”这个分支。看这段代码第一反应是“直接 return 会不会漏掉更新”其实不会。因为每次更新触发时会走完整的scheduleUpdateOnFiber而ensureRootIsScheduled已经被调用过了如果优先级没变说明同一个任务还在队列里等着执行不需要再派发一个重复的回调。这个优化主要避免的是一次事件里连续setState多次每次都要重新调度。其次是SyncLane和异步走调度器的区别。同步优先级比如离散事件里的点击、flushSync包裹的更新不走调度器的taskQueue而是直接通过scheduleSyncCallback塞进同步队列由微任务执行的flushSyncCallbacks一口气清空。这就是为什么同步更新不会被并发打断。2.2 为什么要引入callbackPriority直接每次派发不好吗直接每次派发的话setState100 次就调度 100 次而每次调度其实都是一次宏任务。连续 100 个宏任务每个只干一点点活浏览器中间没有任何喘息的余地性能会变得很糟。callbackPriority在这里充当的是“哈希值校验”角色优先级一样说明这几次更新属于同一批工作没必要重新安排线程。注意这个“优先级一样”不等于“更新内容一样”它只说明你的更新都落在同一个 lane 区间里React 打算把它们放在同一个 render 周期里一起处理。2.3 常见误区ensureRootIsScheduled不是在处理更新内容而是在派发调度很多人找半天源码发现这个函数里根本没看到setState的新状态存哪儿了很容易一头雾水。其实更新内容早就写进root上的updateQueue和pendingLanes了ensureRootIsScheduled只负责一件事确保将来有一个执行performConcurrentWorkOnRoot/performSyncWorkOnRoot的任务被放到合适的位置。所以你把ensureRootIsScheduled当作“update 调度入口”没错但别指望它能告诉你“等于多少”这种逻辑。3. 调度器的workLoop在浏览器时钟里挤时间从ensureRootIsScheduled派发出去的任务如果是异步的会进入调度器的taskQueue。接下来真正驱动它执行的是 Scheduler 模块里那套基于MessageChannel的时间片机制。3.1 为什么不用requestIdleCallback而是用MessageChannel看 Scheduler 源码时你会疑惑为什么不直接用浏览器原生的requestIdleCallback答案很现实requestIdleCallback的触发频率太低而且在一帧的“空闲时间”上并不保证即时调用。React 需要的是一个能精准控制的宏任务触发机制每执行一小段任务后主动检查时间不够就postMessage让自己在下一帧再来。MessageChannel的好处是它是一个宏任务不会像Promise的微任务那样在同一个事件循环里连续执行从而阻塞主线程且浏览器对MessageChannel的处理时间非常短可以近似精确地在帧空隙里挤进一段 work。3.2 调度器的performWorkUntilDeadline时间切片的执行器调度器核心是一个叫performWorkUntilDeadline的函数它接收一个yieldInterval默认 5ms把当前时间加上切片时长作为 deadline然后执行workLoop。简化后的逻辑const performWorkUntilDeadline () { if (scheduledHostCallback ! null) { const currentTime getCurrentTime(); deadline currentTime yieldInterval; const hasTimeRemaining true; try { const hasMoreWork scheduledHostCallback(hasTimeRemaining, currentTime); if (!hasMoreWork) { isMessageLoopRunning false; scheduledHostCallback null; } else { port.postMessage(null); } } catch (error) { port.postMessage(null); throw error; } } else { isMessageLoopRunning false; } };这里最有意思的是这个“递归”方式performConcurrentWorkOnRoot执行完如果返回true说明还有剩下的事没干完调度器就继续postMessage把performWorkUntilDeadline再触发一次。这就是你要理解 React 为什么能把一个复杂渲染拆成好多片——不是在一个任务里切而是靠任务和任务之间的“间隙”切。3.3 过期时间与优先级taskQueue 的排序规则Scheduler 的taskQueue是小顶堆按expirationTime排序。每个scheduleCallback传入的priorityLevel会被换算成timeout比如ImmediatePriority立即执行timeout -1相当于永不等待UserBlockingPrioritytimeout 250NormalPrioritytimeout 5000。我最初很不能理解为什么NormalPriority是 5 秒。因为在浏览器里用户感知 5 秒已经是一件非常漫长的事情但 React 的语义是超过 5 秒你就不再适合等待浏览器闲着直接强制执行。它更像是一个“测试你有没有正确使用并发特性”的宽容底线而不是“我希望用户等 5 秒”。在排查卡顿问题时如果你发现某个大树的渲染被拖到接近 5 秒那大概率不是调度器的问题而是你的事件被判定成了普通优先级而不是可中断优先级。4.renderRootConcurrentrender 阶段怎么“切香肠”调度器到点后会执行performConcurrentWorkOnRoot这个函数会调用renderRootConcurrent。从这开始我们才真正进入 React 渲染层。4.1 递归变循环fiber 的链式遍历React 之所以能中断渲染根本原因是把原先“递归整棵树”的过程改成了“循环处理单链表节点”。每个 fiber 上有child、sibling、return三个指针构成一个三叉链结构。performUnitOfWork做的事情是从当前 fiber 出发通过beginWork生成子节点如果子节点存在就下探到子节点如果没有子节点就进入completeUnitOfWork处理兄弟节点或者回到父节点。这是一个典型的深度优先遍历只不过不用系统调用栈而是用 fiber 链手动维护。好处是随时可以停因为系统栈不深状态下一次从workInProgress这个全局变量接着走就行。4.2beginWork只创造不评判beginWork做的事是根据当前 fiber 的类型FunctionComponent、ClassComponent、HostComponent等调用对应的更新逻辑。函数组件就调用renderWithHooks执行函数体拿到新的 React 元素HostComponent就根据新的 props 生成子 fiber 对应的元素。但beginWork不做 DOM 操作它只是构建“新的虚拟树”。这里有个初学者最容易忽略的点beginWork阶段可能会复用上一次的 fiber如果current和目标 fiber 的type和key都相同React 会走bailoutOnAlreadyFinishedWork的优化路径减少子树的重复计算。4.3completeWork做完一件事结个账当beginWork返回null说明没有子节点了开始completeWork。这个阶段会处理一些局部状态对HostComponent来说在这里创建或更新 DOM 节点注意是创建节点不插入到页面对FunctionComponent来说在这里执行useLayoutEffect的destory如果标记了update。还有一个重要的节点completeWork是计算effectTag现在叫flags攒副作用的地方。子树的副作用会通过completeUnitOfWork里的appendAllChildren、bubbleProperties向上合并到父节点。最后你才能从根 fiber 上一把拿到所有待提交的副作用列表。4.4 中断与恢复不是从头再来workLoopConcurrent里shouldYield()返回true时React 在performConcurrentWorkOnRoot里会判断renderRootExitStatus把workInProgress保存到 root 上然后返回true给调度器。下次调度器再回调时performConcurrentWorkOnRoot发现workInProgress不为空就直接从上次中断的位置恢复不会重新遍历已经做完的节点。这是 React 并发渲染和“普通虚拟 DOM diff”最大的分水岭普通 diff 要不就一口气算完要不就丢弃重来React 的并发渲染是允许“计算中间态”长期存在的。所以你会听到很多“可中断渲染”的介绍但很少有人强调“中断后不重算”这个特征而这个特征才是真正高性能的关键。5.commitRoot不可逆的提交阶段render 阶段再怎么切、怎么断都可以因为那只是构建一棵新的 fiber 树DOM 一个字没动。但从commitRoot开始每一行代码都在改真实页面一旦开始就不能回头。5.1 commit 阶段的三大步骤before mutation、mutation、layoutReact 的 commit 被拆成三个阶段commitBeforeMutationEffectsDOM 变更之前。主要做instance.getSnapshotBeforeUpdate这类“拿旧状态”的工作还有 React 16 时代遗留的useEffect的部分 cleanup 会被安排在后续被动效果阶段做。这个阶段拿到的东西就是为了在 DOM 变更后能算出正确的 DOM 位置或滚动位置。commitMutationEffects真正操作 DOM。删除旧节点、更新节点属性、插入新节点都是在这个阶段完成的。同时也会执行useLayoutEffect的destroy。Mutation 阶段进行时页面处于“半新半旧”状态此时不执行任何用户事件也不能触发新的渲染。commitLayoutEffectsDOM 已经改完了可以执行布局类副作用了比如useLayoutEffect回调、componentDidMount、componentDidUpdate。在这个阶段读 DOM 尺寸、位置都是最新值。这三个阶段分别对应了 flags 里的BeforeMutationMask、MutationMask、LayoutMask。这也是为什么useLayoutEffect不会出现视觉闪烁的根本原因它在浏览器绘制之前同步执行并阻塞浏览器绘制。5.2 双缓存树切换fiberRootNode.current 的指向转变整棵树构建完成后React 在commitRootImpl的最后会把root.current从旧的 fiber 树切到新的finishedWork这个动作在源码里叫root.current finishedWork。这一步看似轻松实际上意义重大。因为后续的渲染、事件绑定、hooks 状态的获取都以current指向的树为准。比如函数组件执行时ReactCurrentFiber.current就是从练度 tree 上取的。切换完成之后旧的树就成了“被抛弃”的树等待未来 GC 或者被复用。注意root.current的切换发生在commitMutationEffects之前还是之后不同版本略有差异。在 React 18 稳定版里切换发生在 mutation 阶段之前以确保 mutation 阶段能读取到最新树。5.3 commit 阶段被忽略的清理工作commit 不只是“执行三个 effects”就完了它还会做这些容易被忽略的事收集 passive effectsuseEffect注意不是useLayoutEffect的回调和 cleanup 不在 commit 时同步执行而是被收集成 pendingPassiveEffects等 commit 结束后在另一个宏任务里执行flushPassiveEffects。这个“延迟”就是为什么useEffect不影响视觉更新但不适合做 DOM 尺寸测量的原因。清理中断状态如果本次提交来自并发渲染需要把root.finishedWork置空、把workInProgressRoot置空恢复正常的调度状态。触发生命周期onRecoverableError之类的错误处理回调以及componentDidCatch、getDerivedStateFromError的触发也发生在 commit 阶段。commitRoot结束意味着一个从“状态变更”到“页面更新”的完整周期收口。此刻页面上的 DOM 和 fiber 树上的memoizedState是严格一致的。6. 从一次点击到页面更新全景链路完整串联刚才分别拆了各个阶段现在把它们串起来。假设页面上有一个按钮你点了一下触发了一个onClick事件处理函数里面调用了setState。第一步setState进入dispatchSetState创建更新对象把它追加到 fiber 的updateQueue环形链表尾部。接着调用scheduleUpdateOnFiber顺着 fiber 的return链回溯到根节点把 lane 合并进root.pendingLanes。第二步根节点的root.pendingLanes变了于是调用ensureRootIsScheduled。如果当前没有已排队的任务或优先级比之前的更高就会取消旧任务、派发新任务。第三步如果是同步优先级任务直接进入同步队列否则被scheduleCallback放进 Scheduler 的taskQueue。调度器的workLoop在某个宏任务里被触发在时间片内执行performConcurrentWorkOnRoot。第四步performConcurrentWorkOnRoot调用renderRootConcurrent→workLoopConcurrent开始遍历 fiber 树。beginWork负责生成子节点、completeWork负责收集副作用直到整棵树完成或者shouldYield()让它中断。第五步如果中断了performConcurrentWorkOnRoot返回true调度器再过一帧继续如果完成了finishConcurrentRender标记finishedWork进入commitRoot。第六步commitRoot执行三个阶段的副作用最后root.current finishedWork把新树切换到 current然后调度器再遍历清理 pendingPassiveEffects。这一路的每一步都环环相扣。很多人读源码容易在某一个函数里陷进去出不来说明是因为没有把“上一步输出了什么、下一步需要什么”打通。如果你只是想知道“setState 后发生了什么”最有效的阅读方式不是从setState往深看而是从ensureRootIsScheduled顺着这条主线往下看。我画过一张脑图放在本地最左边是触发器事件、生命周期、discrete/default/idle中间是ensureRootIsScheduled再往右是 Scheduler再往右是 render最后是 commit。所有的源码细节最终都能在这张图上找到自己该在的位置。7. 常见问题与排查技巧实录看源码不只是为了看还得能在线上问题里派上用场。下面几个是我在实际排查和源码调试中经常遇到的问题。7.1 为什么有时console.log重复了好几次如果你在函数组件里打console.log发现挂载时输出了两次先别怀疑你的代码逻辑有问题。React 18 在开发模式下默认会开启 Strict Mode 的“双调用 render”机制用来帮你发现不纯的渲染。renderWithHooks会被故意调用两次目的是让不纯的渲染提早暴露。严格模式双调用并不会影响生产环境也不会产生二次提交。如果你在生产环境也看到类似现象那就要考虑是不是有多个React.StrictMode包裹、或者组件挂在多个 root 上。7.2 怎么用浏览器 Performance 面板定位到底卡在渲染还是提交排查性能问题最常用的是 Performance 面板录制一段交互然后看主线程的活动。如果看到一长串MessageChannel相关任务每个都很短但总时间很长说明是并发渲染被打断多次可能原因是你的组件树太大、shouldYield被过度触发。如果看到一个很长的同步任务包含renderRootSync、commitRoot且期间主线程没有任何空隙说明是同步渲染导致的卡顿优先检查是不是有flushSync、是不是离散事件、是不是useLayoutEffect里同步触发了重渲染。React DevTools 的 Profiler 里也提供了渲染时长和提交时长的区分可以辅助判断。7.3 死循环、无限调度怎么排查源码阅读时最怕遇到“调度死循环”。症状是页面卡死、CPU 飙满问题是某个任务执行完又派发一个新任务新任务又派发一个根本停不下来。最常见的触发原因useEffect里更新状态而状态更新后又导致useEffect的依赖变化触发新的更新如此反复。render 阶段内部意外调用了外部函数而这个外部函数又直接触发了另一个 root 的scheduleUpdateOnFiber导致同一帧内不断有新任务。排查死循环时我会在ensureRootIsScheduled和performConcurrentWorkOnRoot上打断点观察callbackPriority是否一直在变、pendingLanes是否持续增长。如果pendingLanes一直在加新 lane 而不被消费那肯定是“产生更新 → 调度 → 产生更新”的循环里有个地方漏了消费去重。7.4 如何验证当前渲染是否可中断写一个小 demo在函数组件里用useTransition包一个大的更新然后在渲染过程中用setTimeout高频点击按钮观察主线程是否会被让出。用 Performance 面板可以清楚看到两个宏任务之间的空隙。如果你把useTransition换成flushSync空隙会直接消失整个更新变成同步任务。这个实验是我强烈推荐每个想理解 WorkLoop 的人做一次的。因为单单看源码很难建立体感但通过实际对比“能中断”和“不能中断”之间的差异你会秒懂调度器的意义。8. 想读源码我建议你这样顺着读最后分享一点我自己的阅读心得。读 React 源码不要按文件名顺序读要按数据流顺序读。我的推荐顺序是先读ReactFiberWorkLoop.js里的scheduleUpdateOnFiber→ensureRootIsScheduled→performConcurrentWorkOnRoot→renderRootConcurrent→commitRoot把主线串起来。再读Scheduler.js只关注scheduleCallback、workLoop、performWorkUntilDeadline理解时间切片。然后读ReactFiberBeginWork.js和ReactFiberCompleteWork.js搞清楚单个 fiber 节点怎么被处理。最后读ReactFiberCommitWork.js这时候你已经知道整棵树长什么样了再去看 DOM 变更就有上下文了。我在第一次读时犯过的错误是一直从ReactDOM.render往里面钻结果把createRoot一大套初始化流程全看了一遍跟 WorkLoop 主线没什么关系。你应该反过来先在ReactFiberWorkLoop.js里锁定workLoop然后向上追入口、向下追出口。等你能闭着眼睛说出整条链路的六个环节时这个全景就算真正装进脑子里了。再提醒一句源码版本尽量固定一个版本读不要今天看 17 明天看 18 后天看 19。虽然主干一致但 lane、flags等细节变化很多混着读很容易把自己绕晕。我建议以你实际在用的 React 版本为主把源码对应包下载到本地配合调试器单步执行效果是最好的。