🎯 动画演示 想要直观理解 React Diff 算法的工作原理?
通过 12 个精心设计的交互式示例,深入理解虚拟 DOM 比较与优化策略!
React Diff 算法是 React 用来比较新旧虚拟 DOM 树,找出差异并最小化实际 DOM 操作的核心机制。
启发式假设
在 React Fiber 架构中,Diff 算法在协调阶段(Reconciliation Phase)执行,其核心思想基于以下两个重要的启发式假设:
-
两个不同类型的元素会产生不同的树
当组件的类型发生变化时(例如,从
<div>
变为<span>
,或者从ClassComponent
变为FunctionComponent
),React 会直接销毁旧的组件树,并从头开始构建新的组件树。这意味着旧的 DOM 节点会被完全卸载,新的 DOM 节点会被挂载。 -
开发者可以通过
key
属性来暗示哪些子元素在不同的渲染中可能是稳定的当子元素列表发生变化时,React 会通过
key
属性来识别哪些元素是相同的,从而避免不必要的 DOM 操作。key
必须在同一层级的兄弟节点中是唯一的。如果没有key
,React 会默认按照顺序进行比较,这可能导致低效的更新,尤其是在列表元素的顺序发生变化、有元素插入或删除时。
基于这两个假设,Diff 算法在协调阶段会进行以下两种类型的比较:
单节点比较 (Single Node Comparison)
当新旧 Fiber 节点进行比较时,Diff 算法会根据节点的类型(type
)和 key
属性进行判断:
-
类型不同 :如果新旧节点的
type
不同,React 会直接销毁旧节点及其子树,并创建新节点及其子树。例如,一个<p>
标签变为<div>
标签。 -
类型相同,但
key
不同 :如果新旧节点的type
相同,但key
不同,React 也会销毁旧节点,并创建新节点。key
的作用是帮助 React 识别列表中的唯一元素,当key
改变时,意味着元素本身发生了变化。 -
类型和
key
都相同 :这是最理想的情况。React 会复用旧的 Fiber 节点,并继续比较它们的属性(props
)。如果属性有变化,React 会标记该节点需要更新,并继续递归比较其子节点。
子节点列表比较 (List Reconciliation)
当一个组件的子节点列表发生变化时,Diff 算法会采用更复杂的策略来优化更新过程。它会分两个阶段进行比较:
第一阶段:线性扫描
React 会从左到右线性扫描新旧两个列表,尝试直接按位置进行节点的复用。这个阶段会处理以下几种情况:
-
更新 :如果新旧节点在相同位置且
key
和type
都相同,React 会复用旧节点并更新其属性,然后继续比较它们的子节点。 -
删除 :如果旧列表中某个位置的节点在新列表中没有对应的
key
或type
匹配,或者新列表提前结束,那么旧列表中剩余的节点会被标记为删除。 -
插入/移动 :如果新列表中某个位置的节点在旧列表中没有对应的
key
或type
匹配,或者在旧列表中找到了匹配但位置不同,那么该节点会被标记为插入或移动。
这个阶段会一直进行,直到新旧列表中的某个指针到达末尾,或者遇到第一个 key
或 type
不匹配的节点,这个时候就会跳转到第二阶段进行哈希比较。
第二阶段:哈希映射比较
如果第一阶段没有完全匹配所有节点(即新旧列表的长度不同,或者中间出现了不匹配的节点),React 会进入第二阶段。在这个阶段,React 会将旧列表中剩余的未处理节点存储在一个 Map
结构中,以 key
为键,Fiber 节点为值。需要特别指出的是react内部通过lastPlacedIndex
机制来高效的判断哪些既有元素(在上次渲染中已存在的元素)需要移动位置,哪些可以保持在原位。lastPlacedIndex
会被赋值为在上一个阶段中最后一个被成功复用且不需要移动的元素其原始索引( oldIndex
),如果没有,则为0;
然后,React 会继续遍历新列表中剩余的未处理节点,并尝试在 Map
中查找匹配的 key
:
-
找到匹配 :如果在新列表中找到了一个节点,其
key
在Map
中有匹配的旧节点,React 会比较这个旧元素的原始索引 (current.index
或oldIndex
) 与当前的lastPlacedIndex
- 如果
oldIndex < lastPlacedIndex
:这意味着这个旧元素在旧列表中的位置,比我们上一个放置的、不需要移动的元素的位置还要靠前。为了维持新列表的顺序,这个旧元素必须向右移动到新的位置。因此,React 会给这个元素的 Fiber 节点打上Placement
标记,表示它需要被移动。 - 如果
oldIndex >= lastPlacedIndex
:这意味着这个旧元素在旧列表中的位置,不小于(即等于或在其后)我们上一个放置的、不需要移动的元素的位置。这表明该元素可以保持其相对顺序,不需要移动。在这种情况下,React 会更新lastPlacedIndex = oldIndex
,因为这个元素现在是新的 "最后一个不需要移动的元素" 中在旧列表里索引最大的那个。
- 如果
-
未找到匹配 :如果一个新的子元素在旧列表中找不到对应的元素,那么它就是一个新插入的元素。React 会为它创建一个新的 Fiber 节点,并打上
Placement
标记。这种情况下,lastPlacedIndex
通常不会因为这个插入操作而改变,因为它只关心旧元素的位置。
最后,Map
中剩余的旧节点(即在新列表中没有找到匹配的节点)会被标记为删除。
这种两阶段的处理方式有几个重要的优势:
- 第一阶段的线性扫描可以快速处理最常见的情况(列表末尾添加或不变)
- 第二阶段的哈希映射让查找复用节点的时间复杂度从 O(n) 降低到 O(1)
- lastPlacedIndex 的使用让 React 能够最小化 DOM 移动操作
这个算法在处理大型列表更新时特别高效,因为它能够在保持较低时间复杂度的同时,最小化 DOM 操作的次数。
具体应用
纯粹的理论知识可能会看着有点枯燥,所以我们来看一些具体的例子。
示例 1:完全相同的列表
jsx
// 旧列表
<ul>
<li key="A">A</li>
<li key="B">B</li>
<li key="C">C</li>
</ul>
// 新列表
<ul>
<li key="A">A</li>
<li key="B">B</li>
<li key="C">C</li>
</ul>
在这种情况下,第一阶段就能完成所有节点的复用,因为每个位置的节点都能完全匹配,不需要进入第二阶段。
示例 2:中间插入新节点
jsx
// 旧列表
<ul>
<li key="A">A</li>
<li key="C">C</li>
</ul>
// 新列表
<ul>
<li key="A">A</li>
<li key="B">B</li> // 新插入的节点
<li key="C">C</li>
</ul>
在这个例子中:
- 第一个节点 A 可以在第一阶段直接复用
- 当比较到第二个位置时,发现 key 不匹配(B ≠ C),第一阶段就会终止
- 剩余的节点会进入第二阶段处理
示例 3:节点重新排序
jsx
// 旧列表
<ul>
<li key="A">A</li>
<li key="B">B</li>
<li key="C">C</li>
<li key="D">D</li>
</ul>
// 新列表
<ul>
<li key="A">A</li>
<li key="C">C</li>
<li key="B">B</li>
<li key="D">D</li>
</ul>
处理过程:
-
第一阶段:
- A 可以直接复用
- 到 B/C 时发现不匹配,进入第二阶段
-
第二阶段:
- 将剩余的旧节点(B、C、D)放入 Map
- 处理 C:从 Map 中找到并复用,lastPlacedIndex = 2
- 处理 B:从 Map 中找到,但因为 B 的原始位置(1) < lastPlacedIndex(2),需要移动
- 处理 D:从 Map 中找到并复用,位置正确
示例 4:复杂的移动场景
jsx
// 旧列表
<ul>
<li key="A">A</li>
<li key="B">B</li>
<li key="C">C</li>
<li key="D">D</li>
<li key="E">E</li>
</ul>
// 新列表
<ul>
<li key="A">A</li>
<li key="D">D</li>
<li key="B">B</li>
<li key="E">E</li>
<li key="C">C</li>
</ul>
处理过程:
-
第一阶段:
- A 可以直接复用
- 到 B/D 时发现不匹配,进入第二阶段
-
第二阶段:
- 将剩余的旧节点(B、C、D、E)放入 Map
- 处理 D:从 Map 中找到,lastPlacedIndex = 3
- 处理 B:从 Map 中找到,因为 B 的原始位置(1) < lastPlacedIndex(3),需要移动
- 处理 E:从 Map 中找到,因为 E 的原始位置(4) > lastPlacedIndex(3),不需要移动,更新 lastPlacedIndex = 4
- 处理 C:从 Map 中找到,因为 C 的原始位置(2) < lastPlacedIndex(4),需要移动
示例 5:删除和新增混合
jsx
// 旧列表
<ul>
<li key="A">A</li>
<li key="B">B</li>
<li key="C">C</li>
</ul>
// 新列表
<ul>
<li key="A">A</li>
<li key="D">D</li>
<li key="B">B</li>
<li key="E">E</li>
</ul>
处理过程:
-
第一阶段:
- A 可以直接复用
- 到 B/D 时发现不匹配,进入第二阶段
-
第二阶段:
-
将剩余的旧节点(B、C)放入 Map
-
处理 D:在 Map 中找不到,创建新节点
-
处理 B:从 Map 中找到并复用,需要移动
-
处理 E:在 Map 中找不到,创建新节点
-
Map 中剩余的 C 节点将被删除
-
