React 原理解析:从 Fiber 架构到 Diff 算法、Hooks 实现
带着问题去学习 React 源码
本文主要是记录了本人学习 React 原理的过程,以及一些理解和笔记。
- 为什么 react 会引入 fiber 架构
- 简述 fiber 节点的结构和作用
- fiber 架构 + 流程
- diff 算法
- hooks 原理
react 理念
- 核心公式:
UI = render(data) - 目标:快速响应用户操作
- 单向数据流:数据驱动视图,状态变更时重新渲染
我们可以反向推导出,可能存在一些导致响应慢的原因,所以才会有 react 这样设计的一些结构,就是为了解决这样的问题,如:
- CPU 卡顿
- 浏览器刷新率为 60Hz → 每帧预算约 16.6ms
- JS 执行时间过长(大量的虚拟 DOM 更新、组件更新或复杂的计算任务导致的 CPU 资源消耗过高)会占用主线程(负责 UI 渲染的线程),导致样式、布局、绘制无法在帧内完成,出现掉帧,影响应用响应速度和用户体验
- IO 卡顿
- 网络请求、数据加载等异步操作导致等待
- 需要通过区分不同操作的优先级(关键资源优先加载),或 Loading、Suspense、Error Boundary 等手段优化体验
react 如何解决这个问题?
针对 CPU 卡顿
- 时间切片:将长任务拆分为多个小任务,每个只执行约 5ms,到点让出主线程
- 优先级调度:用户输入等高优先级任务可以插队执行
- Concurrent Mode(并发模式):渲染过程变得可中断、可恢复,支持后台预渲染
- 核心转变:从同步阻塞的大任务 → 异步非阻塞、可中断的用户优先级渲染
针对 IO 卡顿
- Loading:请求期间展示加载状态
- Suspense fallback:组件尚未就绪时展示兜底内容
- Error Boundary:捕获子组件渲染错误,展示降级 UI(当前需类组件实现,依赖
componentDidCatch)
新老 react 架构对比
React 15 架构
- Reconciler(协调器):递归处理 Virtual DOM Diff,找出变化组件,通知 renderer 渲染
- Renderer(渲染器):将变化的组件渲染到 DOM
- 问题:
- 基于递归的方式进行虚拟 DOM 的比较和更新,而递归不可中断,组件层级深时执行时间易超 16ms,造成卡顿
- 没有任务优先级的概念,所有更新任务按照生成的顺序依次同步执行,可能会造成用户体验的延迟和不流畅
React 16.8+ 架构
新增 Scheduler(调度器),改造 Reconciler,形成三层架构:
- Scheduler:根据优先级调度任务,安排任务的执行顺序,实现时间切片
- Reconciler:协调过程改为可中断的循环,遍历 Fiber 树,标记副作用
- Renderer:同步提交副作用,更新 DOM,将更新的虚拟 DOM 转换为实际的 UI 输出
Scheduler(调度器)、Reconciler(协调器)和Renderer(渲染器)共同工作来提供React组件的渲染和更新。
React 18 架构
- 引入
createRoot替代ReactDOM.render,默认开启并发模式 - 新 API:
useTransition、useDeferredValue等,利用优先级调度降低交互阻塞 - 自动批处理:所有更新默认批量处理,减少不必要的渲染
- Suspense 能力增强,支持 SSR 流式渲染与服务端组件
Scheduler 调度器
作用是根据任务的优先级,安排任务执行顺序,实现时间切片和可中断渲染
部分浏览器的原生 API 实现了类似的功能:requestIdleCallback - Web API | MDN,React 没有直接使用,而是自行实现了一套调度器
- 任务拆分:将大型渲染任务拆分为多个小任务(每个对应一个 Fiber 节点)
- 优先级管理:维护任务队列,按优先级调度
- 时间切片:通过检查当前帧剩余时间决定是否暂停,交还主线程给浏览器
- 底层控制:利用浏览器 API 实现精确的时间控制
requestAnimationFrame:在每一帧开始时获取时间戳,计算剩余时间MessageChannel/setTimeout:用于在宏任务中调度回调,模拟requestIdleCallback的行为(兼顾兼容性和执行时机控制)
Reconciler 协调器
在 React 中,协调是指 决定如何高效地将新 UI(由 JSX 生成的 React Element)与旧 UI(当前 Fiber 树)进行对比,找出需要变动的部分,并生成可执行的更新序列(副作用标记)。
工作特点:
- 更新过程变为可中断的循环,每次执行一个工作单元(Fiber 节点)
- 每完成一个工作单元检查
shouldYield(),若当前帧剩余时间耗尽则暂停循环,留出时间给浏览器渲染 - 下一帧空闲时恢复执行,继续遍历(由 Scheduler 调度)
- 实现「递」和「归」两阶段遍历:
- 「递」beginWork:深度优先,创建或更新子 Fiber,对不同类型的节点分派处理
- 「归」completeWork:自底向上,创建真实 DOM,收集副作用标记
- 最终通过
subtreeFlags冒泡,让根节点知道哪些子树存在副作用,供 commit 阶段快速定位
协调过程主要包含:
- 触发更新(setState、props 变化等)
- 创建/获取 workinProgress 树(双缓冲机制)
- 执行 diff 算法(比较新旧节点类型、key、props)
- 标记副作用(在 Fiber 节点上打 flags,如 Placement、Update、Deletion)
- 通过
bubbleProperties将子树副作用合并到subtreeFlags中
可中断的循环(时间切片)
协调过程以 fiber 节点为工作单元,在一个
while循环中逐个处理每完成一个工作单元检查
shouldYield(),检查当前帧是否有剩余时间若时间耗尽则暂停循环,留出时间给浏览器渲染
下一帧空闲时恢复执行,继续遍历(由 Scheduler 调度)
React Fiber 节点
- Fiber 节点本质上是一个对象,使用了链表结构。每个 Fiber 节点是一个工作单元,对应一个 React Element,承载组件类型、DOM 节点、状态、副作用等信息
- 关键属性:
tag:标识节点类型(函数组件、类组件、宿主元素等)type:对应的组件或 HTML 标签stateNode:关联的真实 DOM 或类组件实例child/sibling/return:链表式的树结构,支持遍历时中断和恢复alternate:指向另一棵树上对应的节点,形成双缓冲memoizedState/pendingProps/memoizedProps:状态(函数组件中为 Hooks 链表,类组件中为 state,宿主组件为 null)和属性快照,flags/subtreeFlags:副作用标记(替代旧版 effectList)
- 作用:
- 作为 Fiber 架构的节点,通过链表的形式串联起来,可以支持状态更新的可中断、可恢复
- 作为静态数据结构,每个 Fiber 节点对应一个 React element,保存了该组件的类型、对应的 DOM 节点等信息
- 作为动态工作单元,驱动调和过程,每个 Fiber 节点保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新...)
Fiber 如何更新 DOM —— 双缓冲机制
- 同时维护两棵 Fiber 树:
- current Fiber:屏幕上当前显示的节点对应的树
- workInProgress Fiber:正在内存中构建的新树
- 两棵树的对应节点通过
alternate属性互相引用,便于对比和判断是否需改动
- Mount(初次渲染) 时:
- 创建
FiberRootNode,其current指向整个应用的根 Fiber - 创建
hostRootFiber,设置为FiberRootnode的current属性 - 从根节点开始构建 workInProgress 树,完成后直接作为新的
current
- 创建
- Update(更新)时:
- 基于 current 树克隆新的 workInProgress 树
- 在 Render 阶段完成比较和标记
- Commit 阶段结束后,
root.current指向 workInProgress 树,workInProgress 变为新的 current,旧的 current 树被回收
React 渲染的两个阶段

当用户点击按钮更新 count,Scheduler 先进行任务的调度,之后将任务交给 Reconciler 计算出新的 UI,最后由 Renderer 同步进行渲染更新操作
其中 Sceduler 和 Reconciler 的工作流程随时可能中断:
- 有更高优先级的任务需要执行
- 当前 time slice 没有剩余时间
- 发生了其它错误
Render 阶段
Reconciler 的工作阶段,这个阶段会调用组件的 render 方法
目标:内存中构建一棵新的 workInProgress 树,并标记所有需要变更的节点(副作用),不操作浏览器中的真实 DOM
「递」beginWork
- 目标:处理当前 Fiber 节点,从
rootFiber出发,深度优先遍历,为遍历到的每个 Fiber 节点调用beginWork方法,该方法会根据传入的 Fiber 节点创建子 Fiber 节点,并将这两个 fIber节点连接起来 - 对每个节点,根据 current Fiber 是否存在,决定 mount / update 分支:
- 有 current(update 分支):说明存在旧 Fiber,进入 update 逻辑,消费
updateQueue(有多个 Update 对象),计算新的memoizedState(状态更新后的值);执行组件函数 / 类实例的 render,得到新的 React Element / JSX;将新的 React Element 与current.child子 Fiber 链表进行 Diff,决定复用、更新、删除或移动子节点 - 无 current(mount 分支):没有旧 Fiber,直接根据新的 React Element 创建全新的子 Fiber 节点,不需要进行 Diff
- 有 current(update 分支):说明存在旧 Fiber,进入 update 逻辑,消费
- 生成 workInProgress 子 Fiber 链表,建立
child/sibling/return链接 - 返回第一个子 Fiber 继续递,若无子节点(遍历到了叶子节点)返回
null,则进入「归」(completeWork)
「归」completeWork
处理 宿主组件(如
div、button):- mount:调用
document.createElement创建真实 DOM 节点(内存中,独立的 JavaScript 对象),设置初始属性(把 JSX 上的className、id、style、onClick等属性赋给这个刚创建的 DOM 元素),并通过appendChild将子 Fiber 对应的 DOM 节点组装到自身,在内存中形成一棵 DOM 数,最后赋值给stateNode - update:对比新旧 props,将差异存入更新队列,并在
flags中标记Update(不新建 DOM,复用已有 DOM 节点)
- mount:调用
收集副作用标记:
- 自身标记:
flags(如Placement、Update、Deletion等) - 子树标记:通过
bubbleProperties将子 Fiber 的flags和subtreeFlags合并到当前节点的subtreeFlags中,使父节点能快速判断子树是否有副作用
- 自身标记:
如果不存在同级 Fiber,会进入到父级 Fiber 的“归”阶段;如果存在同级 Fiber,会进入同级 Fiber 的“递”阶段
render 阶段输出
一棵完整的 workInProgress Fiber 树,所有节点都通过
alternate与 current 树对应节点关联一棵完整的内存 DOM 子树(仅宿主组件),尚未插入页面
副作用标记(
flags/subtreeFlags)已全部冒泡至根节点,供 commit 阶段高效遍历
Commit 阶段,把变化提交到屏幕(同步,不可中断)
Renderer 的工作阶段,可以类比 git commit 提交,Renderer的工作主要就是将各种副作用(flags 表示)commit 到宿主环境的 UI 中
根据 Render 阶段生成的副作用标记,分三子阶段执行 DOM 操作:
- before mutation:DOM 操作前的准备工作(处理DOM节点渲染/删除后的
autoFocus、blur逻辑、触发getSnapshotBeforeUpdate生命周期方法、调度useEffect) - mutation:React根据调和阶段的计算结果,实际执行DOM的增删改操作(插入、更新、删除节点)
- layout:DOM 操作后的同步副作用处理(如执行
useLayoutEffect、类组件的componentDidMount/Update)
Diff 算法
- 在 Render 阶段,对于需要更新的组件,React 会将本次渲染的 JSX 对象(新的 React Element)与上一次更新后对应的 Fiber 节点(旧的 Fiber)进行比较,并尝试复用 current Fiber 树中的节点,从而生成新的 WorkInProgress Fiber 树。这个过程就是 Diff 算法
- 本质:对比 current Fiber Tree 和 JSX 对象,生成 workInProgress Fiber Tree
Key
作用:key 是 React 中用于标识节点的唯一性的一种机制。在 Diff 算法中,React 使用 key 属性来快速定位到需要更新的节点,从而提高 Diff 算法的性能
<div>
<p key="a">a</p>
<span key="b">b</span>
</div>
<div>
<span key="b">b</span>
<p key="a">a</p>
</div>
在上面的例子中,React 在比较两个 JSX 对象时,会按照从左到右的顺序进行比较。比较第一个子节点时,发现新节点的 key="b" 与旧节点的 key="a" 不同,于是立即跳出第一轮遍历。在第二轮中,React 通过 key 映射找到对应的旧节点(span 和 p),发现它们只是交换了位置,因此复用节点并标记移动,不会销毁重建。
Diff 算法的优化
为了提高 Diff 算法的性能,React 在实现时做了一些优化:
- 避免不必要的比较:React 在比较同级节点时,会按照从左到右的顺序进行比较,从而避免出现跨层级的节点移动问题
- 使用 key 属性:React 使用 key 属性来标识节点的唯一性,从而在比较时能够快速定位到需要更新的节点
Diff 的瓶颈及处理
完全两棵树比对的算法复杂度为 O(n³),开销巨大。React 通过三个预设限制降低复杂度:
只对同级元素进行 Diff,忽略跨层级移动(销毁并重建)
不同类型元素产生不同树,如
div变p,直接销毁旧树并重建新树通过
key标识子元素,使复用更稳定
Diff 的入口
- 入口函数:
reconcileChildFibers - 根据
newChild(JSX 对象)的类型分派不同的处理函数: object(React Element)→reconcileSingleElementstring/number→reconcileSingleTextNodearray→reconcileChildrenArray- 无命中则删除所有旧节点
对应源码:packages/react-reconciler/src/ReactChildFiber.new.js
单节点 Diff
适用场景:同级只有一个节点(newChild 为 object、string、number)
核心流程(以 reconcileSingleElement 为例):
- 遍历旧节点(
currentFirstChild链表),比较key和type- 如果
key不同 → 删除该旧 Fiber,继续比较兄弟节点 - 如果
key相同但type不同 → 删除该 Fiber 及其所有兄弟节点,跳出循环 - 如果
key和type都相同 → 复用旧 Fiber,返回
- 如果
- 遍历结束未找到可复用节点 → 创建新 Fiber
删除逻辑区分:
key相同type不同 → 调用deleteRemainingChildren(删除当前及所有兄弟)key不同 → 仅删除当前节点(deleteChild),继续比较下一个兄弟
多节点 Diff
适用场景:newChild 为数组,同级有多个节点
操作类型归纳:
节点更新(属性或类型变化)
节点新增 / 删除
节点位置移动
为什么不能用双指针?
- 当前 Fiber 子节点为单链表(
sibling连接),不能直接使用双指针同时遍历数组和链表
React 的策略:两轮遍历
- 第一轮遍历:处理更新的节点
- 同时遍历
newChildren和旧 Fiber 链表,按序比较 - 若
key和type都相同 → 复用,继续下一对 - 若
key相同但type不同 → 删除当前旧节点及其所有兄弟节点,并立即跳出第一轮遍历 - 若
key不同 → 跳出第一轮遍历 - 遍历结束条件:
newChildren遍历完,或旧 Fiber 链表遍历完
- 同时遍历
- 第二轮遍历:处理非更新的节点(新增、删除、移动)
- 首先会将剩余的旧的 Fiber 节点放入到一个
map里,接下来去遍历剩下的 JSX 对象数组,在map里找能否复用 - 情况 1:
newChildren和map都遍历完 → 无需额外操作,Diff 结束 - 情况 2:
newChildren剩余,map中的旧 Fiber 遍历完 → 剩余节点均为新增,依次标记Placement - 情况 3:
newChildren遍历完,map中的旧 Fiber 还有剩余 → 剩余旧节点均需删除,依次标记Deletion - 情况 4:双方都未遍历完 → 存在位置移动
- 首先会将剩余的旧的 Fiber 节点放入到一个
处理移动
- 将剩余旧 Fiber 存入以
key为键的 Map(existingChildren) - 遍历剩余的
newChildren,通过key查找对应的旧 Fiber - 判断是否移动:
- 参照物:最后一个可复用节点在旧数组中的位置索引(
lastPlacedIndex)- 每个可复用节点在旧数组中的索引记为
oldIndex - 若
oldIndex >= lastPlacedIndex→ 位置不变,更新lastPlacedIndex = oldIndex - 若
oldIndex < lastPlacedIndex→ 节点需要向右移动,标记Placement
- 每个可复用节点在旧数组中的索引记为
Demo 解析
abcd→acdb- 第一轮:
a匹配,c与bkey 不同,跳出- 第二轮:处理剩余
cdb,c索引 2 ≥ 0 → 不动;d索引 3 ≥ 2 → 不动;b索引 1 < 3 → 移动
- 第二轮:处理剩余
abcd→dabc- 第一轮:
d与akey 不同,直接跳出- 第二轮:
d索引 3 ≥ 0 → 不动;a索引 0 < 3 → 移动;b索引 1 < 3 → 移动;c索引 2 < 3 → 移动。最终a, b, c都移动到右边
- 第二轮:
结论:尽量减少节点从后往前移动的操作,可优化 Diff 性能