一个有趣的问题
之前我写了一篇狗教我 React------原理篇之 Diff 算法 - 掘金 (juejin.cn)简单介绍了 diff 算法,收到了一个有意思的疑问:
大佬讲得非常易懂,我有个疑惑就是都说 diff 处理节点前移比较差,比如 a→b→c→d 更新为 d→a→b→c,如果第一遍循环到第一个就截止了,把剩余旧的节点全放入剩余 map 中,第二次遍历不是都可以复用的吗,何来处理差这一说呢
这个问题看似简单,实则涉及到了 React diff 算法的细节,我在评论区简单回复了这个问题,但感觉还是不够详细,所以单独写一篇文章来详细解释一下。
示例中的更新流程是什么样的
首先,我们需要知道,React diff 算法是分两轮进行的,第一轮是处理节点复用,第二轮是处理节点重排。
如上面评论提到,第一次遍历时,第一个节点 key 不同,所以直接跳过,进行第二次遍历。
首先,我们来看一下更新流程是什么样的。
jsx
// 更新前
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
</ul>
jsx
// 更新后
<ul>
<li key="d">d</li>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
</ul>

在第一轮遍历时,我们遇到第一个节点,发现 key 不同,所以直接跳过,进行第二轮遍历。
剩余的旧的 FiberNode 放入到一个 map 里:


遍历,从 map 找 key 为 d
,type 为 li
的节点,找到了,复用并移动位置。
从 map 找 key 为 a
,type 为 li
的节点,找到了,复用并移动位置;
...
依次查找,map 中有 li.d
, li.a
,li.b
,li.c
,所以可以复用。
看似是全部都复用了,但实际上,复用并不代表 0 成本,即使节点被复用,移动本身需要 DOM 操作。
那么具体是怎么移动的呢?
React 的移动判断(锚点推进)
React 在第二轮遍历中判断是否需要移动时,使用的是旧 Fiber 的索引顺序,即 lastPlacedIndex
,而不是节点本身。
- 每复用到一个节点,会记录该节点在旧 Fiber 中的索引(
lastPlacedIndex
) - 如果当前节点的旧索引小于
lastPlacedIndex
,则标记为需要移动 - 即使节点可以复用,移动操作本身就会产生 DOM 代价
lastPlacedIndex 的更新逻辑:
- 仅在节点不需要移动时更新:只有当复用的旧节点索引(oldIndex) ≥ 当前的 lastPlacedIndex 时,才更新 lastPlacedIndex 为该旧索引
- 需要移动时不更新 :即使复用了节点,若旧索引(oldIndex) < lastPlacedIndex,则标记移动,但 lastPlacedIndex 保持原值
- 比较对象始终是 lastPlacedIndex:每次判断时,总是与当前全局的 lastPlacedIndex 比较,而不是与上一个处理的节点索引比较
案例推演(abcd → dabc)
还是从上面示例的第二轮遍历开始看。旧 Fiber : a(0)
, b(1)
, c(2)
, d(3)
, lastPlacedIndex
初始值是 0

li.d 在 oldFiberMap
中找到了,oldIndex = 3
,此时 oldIndex
> lastPlacedIndex
, 更新 lastPlacedIndex = 3
;d 节点位置不变。

li.a 在 oldFiberMap
中找到了,oldIndex = 0
,此时 oldIndex(0)
< lastPlacedIndex(3)
, 不更新 lastPlacedIndex;a 节点向右移动。

li.b 在 oldFiberMap
中找到了,oldIndex = 1
,此时 oldIndex(1)
< lastPlacedIndex(3)
, 不更新 lastPlacedIndex;b 节点向右移动。

li.c 在 oldFiberMap
中找到了,oldIndex = 2
,此时 oldIndex(2)
< lastPlacedIndex(3)
, 不更新 lastPlacedIndex;c 节点向右移动。

最终,虽然复用了所有节点,但是只有 d 节点位置不变,a、b、c 节点向右移动到 d 后面。
为什么要这样设计呢? 这种机制本质上是寻找一个参考点,表示"已处理完的节点"的索引,在遍历过程中,如果发现旧节点的索引小于这个参考点,则说明该节点需要移动。这种策略简化了判断的逻辑,虽然可能引起不必要的移动,但整体比较稳定。
通过上面的过程我们可以看出,尽管新顺序是连续的(d→a→b→c),但 React 的移动判断并不能感知这种连续性,也就无法识别"整体右移"。这也是 React 通过 lastPlacedIndex
的单向推进算法的局限性。
那么,Vue3 的 diff 算法就能识别"整体右移"或者"循环移动"等模式吗?
Vue3 的移动判断(LIS)
Vue3 通过预计算最长递增子序列
,识别无需移动的稳定节点
,来优化重排序性能。
Vue3 的最长递增子序列算法本质上是通过数学方法寻找新旧节点序列中相对位置保持稳定的最大子集。
LIS(最长递增子序列,Longest Increasing Subsequence)是一个经典的算法概念,用于在一组数字中找到 最长且严格递增的子序列 。
- 递增:即子序列中的每个元素都比前一个大(严格递增,如 2 < 5 < 7)
- 子序列:元素在原序列中按顺序出现,但不需要连续 通常用动态规划算法(时间复杂度 O(n²))或贪心+二分法(O(n log n))计算。算法细节可以自行搜索了解。
LIS 的计算过程(以 abcd → dabc 为例)
1. 建立新旧节点位置映射。
示例:旧节点[a(0), b(1), c(2), d(3)] → 新位置[d(0), a(1), b(2), c(3)]
旧节点 | 旧索引 | 新索引 |
---|---|---|
a | 0 | 1 |
b | 1 | 2 |
c | 2 | 3 |
d | 3 | 0 |
旧节点在新序列中的索引数组:[1, 2, 3, 0]
2. 计算最长递增子序列。
数组[1, 2, 3, 0]的 LIS 为[1, 2, 3](对应旧 a、b、c)。

3. 仅移动非 LIS 节点。
稳定节点:a、b、c(索引 1、2、3 构成递增序列,保持相对顺序)。

移动节点:d(从旧位置 3→ 新位置 0)。
结果:仅 1 次 DOM 移动操作。
LIS 的优势分析
优势:减少移动次数(从 O(n)→O(1))。
代价:时间复杂度从 O(n)→O(n log n)(计算 LIS 的预处理成本)。
在当前的案例中,React 和 Vue3 对于于节点重排的处理方式对比如下:
React 的锚点推进 | Vue3 的移动判断 | |
---|---|---|
判断依据 | 全局锚点 lastPlacedIndex | 长递增子序列 (稳定序列) |
移动逻辑 | 要旧索引<当前锚点就标记移动 | 移动不在 LIS 中的节点 |
结果 | 整体右移场景中,所有非首节点都会被标记移动(a、b、c) | 同一场景只需移动 1 个节点(d) |
不同场景移动次数对比
通过上面的过程,我们可以看到,Vue3 的 LIS 算法在处理节点重排时,对于整体右移的情况,可以显著减少移动次数。
当然,LIS 算法并不是在任何情况下都优于 React 的锚点推进算法,比如更极端的完全乱序的情况( a→b→c→d 更新为 d→c→b→a),这种优势就不存在了。
简单对比一下两种算法在不同场景下的移动次数:
场景 | React 移动次数 | Vue3 移动次数 |
---|---|---|
循环右移(d→a→b→c) | 3 | 1 |
完全逆序(d→c→b→a) | 3 | 3 |
尾部插入新元素 | 1 | 1 |
小结
LIS 的核心优势:通过数学方法识别最大稳定子序列,最小化移动次数
适用范围:
- 尾部元素前移(如聊天记录加载更多)
- 局部顺序保持稳定的调整
思考
通过上面的分析我们可以看出,React 和 Vue3 在处理节点重排时,采用了不同的策略,它们都在性能和实现复杂度之间进行了权衡和取舍。
时间复杂度
- React:O(n)
- Vue3(LIS):O(n log n)(预处理 LIS)
实现简洁性
- React 算法只需线性遍历
- LIS 需要预处理、建立映射、计算子序列
设计思想
- React 优先保证通用场景性能
- Vue3 更强调复杂动态列表的优化
以上是个人阶段性总结思考,如有不足,欢迎指正~