React 的 Fiber 架构和 Diff 算法是前端工程化中非常精妙的设计。要彻底讲清楚它们,我们需要回到问题的源头:React 为什么要引入 Fiber?它解决了什么问题?
在 React 16 之前,React 使用的是基于递归的对比(Stack Reconciler)。如果组件树非常深,递归的执行会长期占用浏览器的单线程。由于浏览器的渲染和 JS 执行是互斥的,如果一段 JS 执行时间超过了 16.6ms(假设 60fps),浏览器就无法按时绘制下一帧,导致页面掉帧、卡顿。
Fiber 的核心目标只有一个:将同步的、不可中断的更新,变成异步的、可中断的更新。
为了实现这一点,React 重新实现了自己的协调(Reconciliation)引擎。我们分三个核心部分来拆解:Fiber 数据结构、双缓存树、以及基于 Fiber 的 Diff 算法。
一、 什么是 Fiber?(数据结构层)
在过去,React 的虚拟 DOM 真的是一棵“树”。而 Fiber 将这棵树扁平化,变成了一个单链表树结构。
每一个 React 元素不仅记录了父子关系,还记录了兄弟关系。这种结构使得工作可以随时暂停,并沿着指针找到回家的路(通过 return 指针返回父节点)。
1. Fiber 节点的本质
我们可以把一个 Fiber 节点看作是一个工作单元。下面是一个简化的 Fiber 节点核心属性的 JS 代码表示:
JavaScript
class FiberNode {
constructor(tag, pendingProps, key, mode) {
// 【1. 实例相关属性】
this.tag = tag; // 组件的类型(如函数组件、类组件、原生DOM等)
this.key = key; // React元素的key
this.type = null; // 对应的DOM元素类型,比如 'div', 'span'
this.stateNode = null; // 指向真实的DOM节点或组件实例
// 【2. 链表树结构】(核心:通过这三个指针实现了可中断和恢复)
this.return = null; // 指向父 Fiber 节点
this.child = null; // 指向第一个子 Fiber 节点
this.sibling = null; // 指向右侧第一个兄弟 Fiber 节点
// 【3. 状态与工作相关】
this.pendingProps = pendingProps; // 新传入的 props
this.memoizedProps = null; // 上次渲染完成后的 props
this.memoizedState = null; // 组件的状态(比如 hooks 的链表就存在这里)
// 【4. 副作用与标记】
this.flags = 0; // 记录当前节点需要做什么操作(插入、更新、删除等,以前叫 effectTag)
// 【5. 双缓存机制】
this.alternate = null; // 指向另一个状态的自己(current <-> workInProgress)
}
}
2. 图解 Fiber 链表结构
这种设计被称为“子节点指向父节点,父节点只指向第一个子节点,子节点指向兄弟节点”。在 Obsidian 中渲染如下:
代码段
二、 运行机制:双缓存与两阶段 (执行层)
React 在内存中维护了两棵 Fiber 树:
-
Current Tree:当前屏幕上显示的树。
-
WorkInProgress (WIP) Tree:正在内存中构建的树。
React 的更新流程被严格拆分为两个阶段:
-
Render 阶段(可中断): 在这个阶段,React 会自顶向下再自底向上地遍历节点,对比新的 React Element(JSX 编译产物)和老树(Current Tree),生成 WIP 树。如果浏览器主线程有更高优先级的任务(如用户输入、动画),这个阶段会暂停,让出控制权,之后再回来继续(通过
alternate找到之前的状态)。 -
Commit 阶段(不可中断): 一旦 WIP 树构建完毕,React 会一次性把所有的 DOM 变更(在 Render 阶段打上的
flags)同步更新到真实 DOM 上。此时 WIP 树变成新的 Current 树。
三、 Fiber 架构下的 Diff 算法
Diff 算法发生在哪里?发生在 Render 阶段。
准确地说,Diff 算法是对比新的 React Element(比如执行函数组件后返回的 JSX)和老的 Fiber 节点(Current Tree),然后生成新的 Fiber 节点(WIP Tree)的过程。
React 为了保持 的时间复杂度,做出了三个经典假设,这在 Fiber 时代依然适用:
-
跨层级移动极少,只在同层级比较。
-
类型不同,直接销毁重建。
-
列表元素通过
key保持稳定。
Diff 分为两类:单节点 Diff 和 多节点 Diff(数组)。这里重点拆解最复杂的多节点(数组)Diff。
多节点 Diff 的核心逻辑
当节点是一个数组时,React 会经历两轮遍历。它的核心思想是:日常开发中,节点的更新(改属性/内容)比节点的插入、删除、移动发生得更频繁。
第一轮遍历:处理更新
从左到右比对新老节点。如果 key 相同且 type 相同,则复用老 Fiber 节点,打上更新的标记;如果 key 不同,说明节点位置变了,立刻跳出第一轮遍历。
第二轮遍历:处理移动、插入和删除
将剩下没有比对完的老 Fiber 节点放入一个以 key 为键的 Map 中(existingChildren)。然后继续遍历剩下的新节点,通过新节点的 key 去 Map 里找老节点进行复用。
如何判断是否需要移动?(最精妙的部分:lastPlacedIndex)
React 内部维护了一个变量 lastPlacedIndex,它表示在当前为止,最后一个被复用的、在老 Fiber 树中最靠右的索引。
对于接下来的新节点,如果在 Map 中找到了可以复用的老节点,会取出它的老索引 oldIndex:
-
如果
oldIndex < lastPlacedIndex:说明这个节点在老树中的相对位置在左边,但现在它在新的序列中排在后面,所以需要向右移动。 -
如果
oldIndex >= lastPlacedIndex:说明相对位置没变,不需要移动,并将lastPlacedIndex更新为当前的oldIndex。
JS 代码还原 Diff 移动逻辑
下面这段代码剥离了复杂的 Fiber 连线逻辑,仅聚焦于“如何利用 lastPlacedIndex 决定节点是否移动”这一核心第一性原理:
JavaScript
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren) {
let lastPlacedIndex = 0; // 核心变量
let newIdx = 0;
// 省略第一轮遍历...直接看第二轮核心思想
// 假设我们将剩下的老节点放入了 Map
const existingChildren = new Map();
let oldFiber = currentFirstChild;
while(oldFiber) {
existingChildren.set(oldFiber.key, oldFiber);
oldFiber = oldFiber.sibling;
}
// 遍历剩下的新元素 JSX
for (; newIdx < newChildren.length; newIdx++) {
const newElement = newChildren[newIdx];
const matchedFiber = existingChildren.get(newElement.key);
if (matchedFiber) {
// 找到了可以复用的老节点!从 Map 中剔除
existingChildren.delete(newElement.key);
const oldIndex = matchedFiber.index; // 这个节点在老树里的位置
if (oldIndex < lastPlacedIndex) {
// 【核心逻辑】老位置 < lastPlacedIndex,说明它本该在左边,现在要移动到右边
matchedFiber.flags |= Placement; // 打上移动(插入)标记
} else {
// 老位置在右边,说明它的相对顺序没乱,留在原地即可
lastPlacedIndex = oldIndex;
}
} else {
// 没找到老节点,说明是全新的元素,直接打上插入标记
const newFiber = createFiberFromElement(newElement);
newFiber.flags |= Placement;
}
}
// Map 中剩下的节点都没用了,打上删除标记
existingChildren.forEach(child => {
child.flags |= Deletion;
});
}
总结串联
-
痛点驱动:因为组件树太深导致同步渲染卡顿,所以引入 Fiber。
-
结构改变:将树打平为带有
return、child、sibling指针的链表,使得渲染过程成为一个可以暂停、恢复的“协程”。 -
空间换时间:利用双缓存树机制(
current和workInProgress),在后台悄悄进行计算。 -
高效对比:在可中断的 Render 阶段进行 Diff。面对复杂的列表,优先处理更新,随后利用
lastPlacedIndex作为标尺,精确计算出哪些真实 DOM 需要移动、插入或删除。最后在不可中断的 Commit 阶段一次性更新屏幕。
理解了底层执行逻辑后,其实会发现很多 React 的现象都变得顺理成章(例如为什么不能在条件语句中写 Hooks,因为它依赖链表的顺序结构)。
在面试中,你可以这样简洁有力地回答:
React Fiber 是一种将原本不可中断的同步渲染重构为异步可中断任务的架构,其核心是将树形结构扁平化为基于单链表的工作单元,从而实现“时间分片”以避免长任务阻塞浏览器主线程。在运行机制上,它利用双缓存技术将更新分为两个阶段:在可中断的 Render 阶段,通过高效的 Diff 算法对比新旧 Fiber 树并记录副作用(其中对多节点 Diff 采用两轮遍历及 lastPlacedIndex 标尺来最小化 DOM 移动);最后在不可中断的 Commit 阶段将这些变更一次性提交到真实 DOM,从而兼顾了复杂的 UI 更新与极致的用户响应体验。
面试加分小建议: 如果你想进一步展示深度,可以在说完这段话后主动补充一句:
“这种设计的本质是从‘递归调用栈’向‘虚拟堆栈’的转变,让 React 拥有了手动控制任务优先级和调度执行的能力。”
你想让我继续为你拆解 React 调度器(Scheduler)是如何根据任务优先级来“见缝插针”执行这些 Fiber 任务的吗?