资讯动态

React源码主线地图:从setState到页面更新的完整链路

发布时间:2026/10/16 1:44:21 来源:尧图企业网站定制
不管你是跟着某套课程刚啃完一遍 React 源码还是自己折腾过几个版本大概率都有同一个感觉每一章单独看都懂但合在一起就串不起来。比如 Fiber 是什么、lane 优先级怎么分配、commit 阶段到底做了什么单独讲好像都头头是道真被问到“一次 setState 到页面更新中间发生了什么”却很难一口气说完整。这就是“学过但没形成地图”的典型状态。这篇文章要做的就是把前 15 章的内容像拼积木一样拼成一张 React 源码主线地图让每个知识点都落到这条主线上再说清楚它们之间是怎么协作的。适合正在复习源码、准备面试梳理框架、或者被各种零散知识点绕晕的开发者看完之后你应该能自己把整条链路从头到尾讲一遍。1. 一张地图先看全貌React 源码的主线到底长什么样1.1 从 setState 到屏幕刷新一句话说清主线闭环先给你一句话版本用户触发 setStateReact 把这个更新封装成一条 update 放到对应 fiber 的 updateQueue 上然后沿着 fiber 树向上找到 root给这个更新分配一个优先级 lane调度器 Scheduler 接管后按照优先级决定什么时候进入 render 阶段render 阶段通过 beginWork 和 completeWork 两次遍历构建出一棵新的 workInProgress 树最后 commit 阶段把这棵新树一次性提交到真实 DOM浏览器把变化画到屏幕上。这句话就是整条主线的骨架其他所有内容都是长在骨架上的血肉。我理解 React 源码的时候特别喜欢用一个类比setState 就像你在餐厅点了菜updateQueue 是服务员记下的需求调度器是后厨的排单系统render 阶段是切菜配菜的过程commit 阶段则是把炒好的菜端上桌。任何一步出了问题整个链路都会出问题这也是为什么排查问题的时候先判断卡在哪一段比盲目看代码重要得多。这条主线还有两个特征值得注意。第一它是可中断的尤其 render 阶段可以随时暂停等更紧急的事情处理完再回来继续第二它是分叉的class 组件和函数组件的更新入口不同但最终都会汇入同一条链路。把这两点刻在脑子里后面每一个模块你都找得到自己的位置。1.2 双缓存 Fiber 树主线地图的底层坐标轴你随手打开 React DevTools 看组件树看到的其实是屏幕上已经渲染好的状态。但在源码内部同一时刻至少存在两棵 Fiber 树一棵叫 current对应屏幕上正在展示的内容另一棵叫 workInProgress是本次更新正在内存里构建的新版本。两棵树之间通过 fiber 的 alternate 指针相互引用。为什么要搞两棵树直接改旧树不行吗这是 React 的核心设计智慧。想象一下你正在刷新页面如果屏幕上的内容一点一点被改用户会看到半个旧状态、半个新状态体验很差。双缓存的做法是先在内存里把整棵 workInProgress 树完整构建好等全部搞定之后通过一次指针切换——把 FiberRoot 的 current 指向这棵新树——让屏幕直接切到新版本。这就是双缓存的意义构建过程对用户不可见新树永远是一份“完整的成品”。这个坐标轴在整个地图上非常重要。你看源码的时候会反复看到 current 和 workInProgress 这两个词比如 beginWork 里判断 current 是否存在commit 阶段替换 finishedWork实际都是围绕这两棵树的切换来做的。初学者容易忽略一个细节workInProgress 树的创建不是每次都从零开始的React 会尽量复用 alternate 上已有的 fiber 结构只是更新需要变动的部分这也是性能的关键来源。1.3 为什么主线要切成调度、协调、提交三个阶段如果说双缓存是地图的底层结构那三大阶段——调度Scheduler、协调Reconciler、提交Commit——就是地图上最明显的三条大河。它们各自管一件事情界限分得特别清楚。调度器只管“这个活要什么时候干、要不要插队、干到一半能不能暂停”它不关心具体要渲染什么组件只关心优先级和任务队列。协调器负责“新版本长什么样”它跑在工作循环里逐个处理 fiber 节点构建新的树结构。提交器则是“把新版本真正贴到 DOM 上”这是同步且不可中断的因为 DOM 操作一旦开始就不能做到一半停下来否则用户会看到半成品状态。这个三段式划分最大的价值是让 React 的并发渲染成为可能。调度器可以随时中断协调器的工作让更高优先级的任务先执行。但一旦进入提交阶段所有优先级都得靠边站必须一次性完成。你记这条主线的时候只要记住一句话就行调度可中断、协调可被掐断、提交不可打断。这个结论能帮你解释大量并发相关的源码行为。2. 起点从 createRoot 到一次更新的完整启动链路2.1 createRoot 里到底初始化了什么任何时候想追源码都从 createRoot 开始。这是我自己的习惯因为它是整条主线的入口。createRoot 做的事可以拆成几件创建一个 FiberRoot把当前容器对应的 host 树挂上去初始化首屏渲染需要的根节点rootFiber给根 fiber 建好 updateQueue然后启用 React 自己的事件系统把容器上的事件监听接管过来。这里有个容易忽略的概念FiberRoot 和 rootFiber 是两个不同的东西。FiberRoot 是整个应用的管理者它持有 current 指针知道自己被挂载在哪个容器上rootFiber 是最上层的那个 Fiber 节点类型是 HostRoot它是 Fiber 树的根。你可以把 FiberRoot 想象成公司的 CEOrootFiber 是 CEO 名下的一本总账本。首屏渲染时React 会从 rootFiber 出发向下构建整棵树。常见的问题是为什么 createRoot 之后调用 root.render( ) 时React 不立刻渲染因为 render 只是创建了一个更新把它放进 root 的更新队列然后请求调度。真正的渲染要等调度器发话。这就好比你把需求交上去了但具体什么时候开工由项目排期决定。理解了这一步就不太会被“render 是渲染吗”这种问题搞混。2.2 更新从哪来从 setState 到 updateQueue 的完整链路setState 不是直接改 state而是往 fiber 的更新队列里塞了一个 update 对象。class 组件的 setState 和函数组件的 dispatchSetState 都会走这条路只是入口不同。class 组件会把多个 setState 合并成一条 updateQueue 的链表函数组件则是在对应 hook 的 queue 上维护更新环。每次渲染时React 会把队列里的 update 挨个消费掉算出最终状态。react 18 之后有个被大量使用的特性自动批处理。哪怕你在 setTimeout、Promise 回调里连写多个 setStateReact 也会尽可能把它们合并到同一次渲染中。这是因为调度器只认最后生成的优先级并不会因为你调用多次就调度多次。源码里做批处理的关键是让更新处于同一个事件的执行上下文里通过一个标志位控制是否立即处理队列。我之前 debug 时发现一个坑直接打印 updateQueue 经常看不到内容因为很多更新在队列里已经被提前消费或者跳过了。想看真实队列应该在 fiber 的 memoizedState 上打断点或者查看 updateQueue.lastUpdate 的位置。理解 updateQueue 之后你会发现很多面试题里的“异步更新”其实不是 React 在设计上刻意做成异步而是一系列链路自然导致的结果。2.3 调度器的入场点为什么更新要先排队更新进入队列后会沿着 fiber 的 return 指针一直向上直到走到 rootFiber然后到达 FiberRoot。这一步会让 root 标记上本次更新的 lane 优先级。lane 是 React 源码里内建的一种优先级模型用二进制位表示不同的优先级轨道数值上越高的 lane 通常代表越紧急的任务。root 被标记之后调度器才正式入场。调度器入场的核心函数是 ensureRootIsScheduled它会看当前 root 上有没有任务已经在执行以及新任务的优先级是否更高。如果更高之前可能已经在跑的任务会被打断新任务插队。这里我强烈建议你在打断点的时候仔细看看任务队列里的 callback 和过期时间很多“为什么我的更新没生效”的诡异问题其实都是任务被更高优先级任务挤掉之后又找不到合适时机重新被调度导致的。优先级模型是整个调度器最抽象的部分。你可以把 lane 想象成不同颜色的赛道普通点击是蓝色跑道动画是红色跑道数据请求是绿色跑道。调度器会优先处理红色跑道上的任务蓝色跑道上的可以先放一放。源码里有一套从 lane 到 expiration 再到任务时间的换算逻辑如果你不是专门做 Scheduler 级别优化理解到“lane 决定谁先跑”这个层面就够用了。3. 中路render 阶段的两次旅行——beginWork 与 completeWork3.1 beginWork 的取舍复用还是重建render 阶段是整个地图里最复杂的一大段而它的工作方式其实很机械沿着 fiber 树从根往下走先用 beginWork 处理每个节点再用 completeWork 处理返回路上的节点。 beginWork 的职责是“当前节点要变成什么样”它要根据 fiber 的 tag 走不同分支——函数组件走 updateFunctionComponent类组件走 updateClassComponent原生 DOM 标签走 updateHostComponent每种类型都有各自的处理函数。beginWork 里最关键的一个判断是能不能复用。如果 current 节点存在且 oldProps 和 newProps 相等又没有特殊的更新标记React 会直接跳过该节点把子节点原样复用。这种优化叫 bailout它避免了无意义的子树重建。我第一次看到这段代码的时候特别惊讶原来 React 的性能优化很大程度上依赖“什么都不做”来达成。判断复用的过程中协调算法diff也在这里发生。当节点的子元素是一个列表时React 会根据 key 和 type 来决定是复用、新建还是删除。这是整个源码里最考验逻辑的地方。日常开发中遇到列表渲染错乱十有八九是这一步出了偏差不是 React 的问题而是 key 选得不对。3.2 completeWork 的收尾从 fiber 到 DOM 操作当某个 fiber 的所有子节点都处理完毕后React 会回到这个节点上执行 completeWork。这里做的事情主要有两类一类是创建或更新 DOM 实例比如 HostComponent 类型的 fiber会在 completeWork 里创建对应的 DOM 元素并把 props 加到元素上另一类是收集副作用effect把需要提交阶段处理的操作挂到父节点的 effect 链上。completeWork 里有个很关键的概念叫 effectList。它是一条由 fiber 节点组成的单向链表每个节点通过 nextEffect 指向下一个需要处理的副作用。React 在 completeWork 阶段会把子树里的 effect 链逐层向上合并最终形成一条完整的副作用列表。这么做的好处是提交阶段只需要沿着这条链表走一遍不用重新递归整棵树。我实际追踪的时候发现DOM 节点的挂载顺序跟你的直觉往往是反的子元素是先在内存里构建最后在父节点的 completeWork 阶段统一挂上去。所以你在 DOM 面板里看到的结构其实是在内存里先拼好整棵子树的 DOM再一次性 append 到父节点上。理解了这一步就不会再有“为什么子组件先执行 effect 还是父组件先执行 effect”的困惑了。3.3 优先级与时间切片如何“切”进主线render 阶段最重要的特性是“可中断”。它怎么做到的呢关键在于 workLoop 的设计React 把整棵树的协调拆成一个个最小工作单元每个单元就是一个 fiber 节点。每次进入 workLoop处理完一个单元后检查一下是否需要让出主线程。如果当前任务被打断workLoop 会停下来后续通过调度器在合适的时机恢复恢复时从上次中断的 fiber 节点继续往下走。让出线程的判断依据主要有两个当前任务是否已经超过时间片以及有没有更高优先级的任务插入。React 给每个任务分配固定时长的时间片通常是 5ms 左右。时间片用完之后即使当前子数还没遍历完也必须立刻让出等下个宏任务里再继续。Suspense 的挂起也发生在这个阶段如果组件里抛出了一个 PromiseReact 就会捕获它把这个 fiber 标记为挂起中断当前工作等 Promise 解决之后恢复渲染。要理解时间切片生活里最简单的类比就是健身时做的碎步分组每个组练 5 分钟就休息一下虽然中间有停顿但总量照样能做满。React 就是在浏览器的大任务里不断穿插这些小停歇保证页面不会因为一个巨量渲染而长时间卡死。调试的时候你可以在浏览器性能面板里看到一系列切割的 task那就是时间切片留下的痕迹。4. 终点commit 阶段的三段式提交与副作用执行4.1 before mutation提交前的最后读操作当 finishedWork 树构建完成React 就离开 render 阶段进入 commit 阶段。commit 并不是一个大函数一步到位而是被拆成了三个阶段before mutation、mutation、layout命名分别对应各自的时机。before mutation 阶段发生在 DOM 被修改之前此时页面上展示的仍然是旧内容但新树已经准备好了。这个阶段主要处理 snapshot 类的副作用。class 组件的 getSnapshotBeforeUpdate 就是在这里被调用的它拿到的 DOM 快照是旧状态下的整页结构这正好是计算滚动位置或者保存旧尺寸的窗口期。很多开发者以为 getSnapshotBeforeUpdate 只是 componentDidUpdate 的一个变体其实它们的执行时机完全不同前者在 DOM 改变前后者在 DOM 改变后。useEffect 的异步机制在这里也已经埋下伏笔。React 会把需要异步执行的 effect 安排到此时注册但不是立刻执行而是等到 paint 之后再统一 flush。这也是为什么你在 before mutation 阶段很难看到 useEffect 的运行痕迹。调试的时候如果你觉得“怎么 useEffect 执行时机这么奇怪”点头看看是不是在 commit 阶段的 async 分支里被挂起了。4.2 mutation真正动 DOM 的地方mutation 阶段是整条主线里少数“不可中断”的地方。commitMutationEffects 会遍历上一步整理好的 effectList根据副作用类型执行对应的 DOM 操作Placement 负责插入新节点Update 负责更新属性或文本Deletion 负责删除旧节点。这些 DOM 操作都是直接对浏览器实际的容器节点进行的一旦做到一半被打断页面就会出现中间态所以 React 禁止任何干扰行为。删除操作是一个隐藏的重灾区。当某个 fiber 子树被删除时React 不仅要移除真实 DOM还要执行类组件或者函数组件的清理工作调用 componentWillUnmount把 ref 置为 null执行 useEffect 的清理函数。这些工作统统在 mutation 阶段完成。我踩过的坑是在父组件的卸载逻辑里访问了已被删除的子组件 ref拿到的是 null因为 ref 置空发生在 rendering 阶段的删除处理中比父组件的 effect 执行要早。effectList 的类型判断用到了二进制位运算。React 会给每种副作用类型分配一个二进制位比如 Placement 可能是 0b10Update 是 0b100。一个 fiber 可能同时触发多种副作用通过位运算把它们合并成一个 flags 值。学到这里如果你的位运算基础薄弱建议先补一补不然后面看 flags 处理会非常吃力。4.3 layout 与 passiveuseEffect 的两种落地方式mutation 阶段把 DOM 改完之后紧接着是 layout 阶段。这个阶段的同步性特别强commitLayoutEffects 会立刻执行 useLayoutEffect 的创建函数、componentDidMount 和 componentDidUpdate此时你可以读取到最新的 DOM 尺寸、位置等信息。这也是为什么 useLayoutEffect 主要用来处理需要直接影响布局的操作比如滚动定位、DOM 测量。useEffect 则是另一条完全不同的路径。它被标记为 passive effect在 layout 阶段结束时并不会马上执行而是要等浏览器把这一帧真正绘制paint之后也就是用户可能已经看到新画面了React 才会异步执行这些 effect。两条路径的区别可以这样记useLayoutEffect 是“画前到位”useEffect 是“画后补刀”。这个区别在实际开发中会造成肉眼可见的影响如果你在 useEffect 里大量修改样式用户会看到页面闪一下而 useLayoutEffect 里的修改发生在绘制前不会有闪烁。我之前排查过一次图表初次渲染位置偏移的问题最后发现就是 useEffect 里做了测量但测量时 DOM 还没有稳定需要把测量逻辑挪到 useLayoutEffect 里才能拿到正确值。这两个 effect 选择不对就是这一类疑难杂症的典型导火索。5. 地图上的坐标点Hooks、事件系统、Context、diff 暗礁5.1 Hooks 是如何长在 Fiber 上的很多人学了 Hooks 但是没想过它在源码里长什么样。Hooks 其实以链表的形式挂在 fiber.memoizedState 上第一个 hook 的 next 指向第二个 hook以此类推。每次渲染时React 会按顺序遍历这个链表把每个 hook 上保存的状态、更新队列和处理函数取出来用。这也解释了最经典的规则hooks 不能写在条件语句里因为你第一次渲染时可能只有两个 hook第二次条件不成立只剩一个链表就对不上了React 无法判断你当前要消费的是哪个 hook。useState 在 mount 和 update 时走的是两个不同的逻辑分支。mount 阶段会创建 hook 对象并初始化初始值update 阶段则会消费 hook.queue 里保存的更新通过计算得出最新的 state。如果你在 setState 传了一个函数这个函数会作为更新的一部分被放进队列React 在计算时依次执行这些函数得到最终状态。我自己日常 debug Hooks 问题最常干的一件事是直接打印 fiber.memoizedState看链表上有几个 hook、有没有出现数量不一致的情况。你在排查“某些情况下组件状态错乱”的诡异问题时优先怀疑一下 hooks 顺序是否恒定比反复检查业务代码逻辑效率高得多。5.2 事件系统一次点击如何绕过原生事件直达状态更新React 事件系统在主线地图上的位置很特殊它既是更新的起点又是独立的子系统。React 没有把事件监听直接绑定在每个 DOM 节点上而是在 createRoot 的时候把大部分原生事件都委托到了容器根节点上。当你在某个按钮上点击事件会冒泡到根容器React 再根据事件目标target找到对应的 fiber 节点向上收集事件路径最后在路径上触发合成事件。合成事件的出现解决了跨浏览器一致性和性能问题。React 自己维护了一套事件对象把浏览器原生事件封装起来统一了 stopPropagation、preventDefault 等 API。但同时也会带来一个经典问题你在 React 的 onClick 里调用 e.stopPropagation()通常只能阻止 React 内部其他组件继续冒泡却不一定能阻止其他监听同一容器的原生事件。这是很多刚接触合成事件的人会踩的坑。React 18 的自动批处理也让事件系统里的 setState 表现发生了变化。以前浏览器原生事件处理器里的多次 setState 不会被批处理会导致多次渲染React 18 之后统一处理哪怕你在 setTimeout 里连续 setState最终也只会触发一次渲染。理解这个变化之后你再去排查“为什么我的计数只加了一次”就不会一头雾水了。5.3 Context 的穿透传播Context 是在那条主线上到处插针的机制。Provider 本质是一个 fiber 节点负责保存并提供 value。当 Provider 的 value 变化时React 在 beginWork 阶段会检测到变化然后顺着子树往下找哪些 fiber 订阅了这个 Context。订阅关系通过 fiber.dependencies 链表记录每个用 useContext 消费的组件都会在自己的 dependencies 上挂一个标记。这里的穿透效果看起来像魔法其实实现并不复杂Provider 变化后会向下标记出一条更新路径凡是 dependencies 命中的 fiber都会被标记为需要重新渲染即使它中间夹着 memo 组件也不例外。React 对 memo 的 bailout 判断会额外检查依赖是否变化一旦发现自己依赖的 Context 值变了就会强制打破缓存重新渲染。踩过的实际坑是在 Context.Provider 里放了一个每次渲染都新建的 object 之类的值哪怕这个 context 在业务上没有变化React 每次也会重新渲染所有消费者。问题根源在于 value 引用每次都不一样导致 beginWork 里判断“旧值等于新值”不成立于是向下传播了更新。解决方式也不复杂用 useMemo 缓存 value 对象保持引用稳定。5.4 diff 暗礁key 到底能不能用 indexdiff 算法在地图上就是一个暗礁带。单节点 diff 的逻辑是节点只有一个React 会对比新旧的 key 和 type两者完全相同才复用这个节点否则直接走删除重建。多节点 diff 更复杂React 会先按顺序做一次 key 匹配扫描如果找到匹配就复用并继续匹配过程中一旦发现 key 对不上就会退出第一阶段进入第二阶段根据旧 child 的 key 建立一个 map用来快速查找新位置。如果你用 index 作为 key效果和用业务 id 完全不同。比如列表头部插入一项index 作为 key 时第一项的 key 从 0 变成 1第二项也从 1 变成 2所有旧节点的 key 全变了React 就认为这不是同一批节点干脆全部销毁重建。表现上就是如果列表里有带输入框的节点插入新行后旧行的输入内容可能串到别的行上。状态跟随错误的 DOM 节点被“带”走是典型的 key 错配现象。在实际项目中我推荐按这个优先级选 key业务数据里唯一 id 最优先其次是稳定的自增 id最后才考虑 index。静态列表且不会重排的场景用 index 没有问题。一旦列表涉及增删、排序、过滤index 就是一颗定时炸弹。你不一定要背源码里的 diff 细节但必须搞清楚“为什么 index 会有状态串位”这道题几乎每个团队面试都会考。6. 用地图导航复习方法、排查技巧与面试答题框架6.1 把源码笔记画成主线路标回顾源码最好的方法不是从头再看一遍视频或者文章而是自己动手画一条主线图。我个人习惯是先在白纸上画一条横向的河流左侧是 setState 和 createRoot 这两个入口右侧是 commit 到浏览器绘制这个出口中间依次排上 Scheduler、render、commit 三个阶段。然后每个知识点往里贴调度器下面贴 lane 和时间切片render 下面贴 beginWork、completeWork、diff、双缓存commit 下面贴 before mutation、mutation、layout、passive effect。这样每个知识点都能找到自己在主线上挂载的位置再遇到新问题就不会像无头苍蝇一样乱转。画完之后做第二次加工把每一个知识点补上四张标签“是什么”“入口在哪”“出口到哪”“有什么坑”。比如“调度器”的入口是 ensureRootIsScheduled出口是 performConcurrentWorkOnRoot坑是任务打断后如果一直没机会恢复会饿死低优先级任务。这张带标签的地图就是你复习的导航仪。前面 15 章的笔记不用重抄直接把关键结论钉到对应位置就足够了。6.2 用主线回答“为什么”类题目面试里特别爱考“为什么”。为什么 setState 拿到的不是最新值为什么不建议用 index 做 key为什么 React 要引入 Fiber很多人的问题在于试图用一个孤立的结论去回答一整类问题结果总是差半口气。更好的答法是从主线上给答案先定位这个现象发生在哪一段再把这段的机制讲清楚。比如“React 的更新是异步的吗”你可以这样答更新进入 updateQueue 后要经过调度器的排队调度器会判断优先级和批量条件所以大多数情况下调用 setState 之后立即读状态拿到的还是旧值真正拿到新值的时机在 render 之后的提交阶段。然后你还能带一句在老版本 React 里原生事件中的 setState 是同步更新React 18 的自动批处理把边界收得更干净。这样既回答了问题又展示了对整条链路的掌握。再比如“key 为什么不能用 index”直接对应到多节点 diff 的匹配过程index 变化会导致 key 全部错位触发不必要的重建和状态错配。答到这一步面试官基本就能判断你确实读过源码而不是背了某篇博客的结论。用主线来组织答案最大的好处是逻辑闭环每个结论都有一个前因可以一路追到源码行为本身。6.3 常见排查从现象定位到具体模块地图最大的实用价值是排障。我整理了几类最常见的源码级问题对照地图看会非常清晰现象定位思路对应模块/阶段页面长时间卡顿不更新看任务队列里是否有高优先级任务反复打断低优先级任务导致低优先级被饿死Scheduler / lane 调度组件渲染了但 DOM 没变化检查 mutation 阶段的 placement/update flags 是否正确看是否被标记为跳过commit / mutation列表插入或删除后状态错乱检查列表的 key 是否稳定大概率用了 indexdiff / 协调setState 之后组件始终不按预期更新检查状态更新是否被合并成批量或优先级被更高任务覆盖updateQueue / ScheduleruseEffect 里读不到最新 DOM 尺寸检查是否应该在 useLayoutEffect 中执行commit / passive死循环渲染看 render 阶段是否有持续触发的更新或 useEffect 依赖数组写得不当Scheduler / Hooks全局事件触发时拿不到最新 state看事件与更新调度之间是否隔着 React 的批处理边界事件系统 / React 18 自动批处理排查的时候先判断现象发生在主线的哪一段再钻进对应模块去看代码效率比从头把所有源码翻一遍高很多。这个表格你可以保存下来后续遇到类似问题直接对号入座。我最深的体会是React 源码的主线不是靠背出来的是靠画出来的。画过一遍之后很多看似无关的知识点会自动连成一张网。再过一阵子你再去看那些火热的并发特性、Suspense、服务端组件会发现它们其实都落在这张地图的某些坐标上根本不用从头理解。如果你决定自己画这张图记住一个原则先画主干再贴分支别一开始就陷进某个模块的细节里。毕竟地图的首要作用是导航不是收藏。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑