引言:
大家应该都学过,Vue3 的快速 Diff 算法中,运用了 算法,即最长上升子序列。
为什么运用了这个算法呢?本质上是求解 ,通过 虚拟 Dom节点 唯一的 Key , 将 问题 降维为 问题 而来。
概念引入
LCS(最长公共子序列)
假设我们有:
旧序列 :[1, 2, 3, 4, 5]
新序列 :[1, 4, 2, 3, 5]
直观上看,[1, 2, 3, 5] 这四个元素的相对顺序在 和 中都没变。这在 和 中元素是完全一致的最长子序列
这在算法上对应了一个经典概念:(最长公共子序列)。
LIS(最长上升子序列)
假设我们有:
序列 :[4,2,3,1,5]
其中,[2,3,5] 是 的一个子序列,且保证了升序,并且是所有满足升序的 的子序列中最长的,这就是 的
如何求出同层DOM节点最少移动次数
显然,最少移动次数的本质是求 。即:取新旧 DOM 序列中,最长的、相对顺序完全一致的子序列。这部分节点作为“不动点”,其余节点进行平移。这在逻辑上能保证 DOM 操作次数达到理论最低值,是最优解。
简单来讲:
物理含义:最少移动次数 = 总元素数 - 不必移动的元素数。
不必移动的元素:必须构成一个 LCS(最长公共子序列)。
利用唯一的key,将求 LCS 转化为求 LIS
虽然 LCS 能给完美答案,但传统的 LCS 动态规划算法时间复杂度是 ,其中 是旧 DOM 序列长度, 是新 DOM 序列长度。这个时间复杂度在 和 非常大的情况下很劣势。
然而由于我们给了唯一的 Key,就产生了一个很妙的性质:
一般意义上的 :处理的是一般序列(允许重复元素),不存在唯一的单射关系,属于典型的动态规划问题,复杂度下限为 。
特定约束下的 (唯一 Key):从离散数学角度看,由于建立了强一致的索引映射,它退化为了一个偏序集的最长链问题(Longest Chain in a Partial Order)。根据 Dilworth 定理 的相关应用,这类特殊的偏序最长链可以通过将二维关系压缩为一维索引,是利用了唯一 Key 带来的单射(Injection)关系。本质是一种坐标变换,把二维的“位置对比”压缩成了一维的“数值增长”。利用 贪心 + 二分查找 在 时间内完成求解。 简单推导
的本质:寻找一个子序列,使得其中的元素在 和 中都满足相同的全序关系。
的本质:在序列 中寻找一个子序列,使其元素值严格单调递增。
如果 且 ,这意味着:
在新序列中, 在 之前(由 决定)。
在旧序列中, 也在 之前(由 决定)。
等价性证明:满足上述条件的子序列,就是一个保序映射(Order-preserving map)。在元素唯一的条件下,新序列中索引的最长递增子序列(),逻辑上等价于两个全序集之间的最长公共子序列()。
说人话,就是有两个序列, 和 , 若 和 中各元素都唯一(有唯一的Key),则求 的 ,可以通过如下过程转换为求 :
-
新序列建表,留出空位:
-
遍历新序列 ,使用
Map记录每个元素的index映射。例如B = [a, c, g],则Map记录为{a: 0, c: 1, g: 2}。 -
同时,定义一个数组
source,它的长度等于序列 的长度,并全部填充为0。这里的0有特殊含义,代表“这是一个全新增加的节点,在旧序列里没有”。 -
此时
source = [0, 0, 0]。
-
-
旧序列查表,新位置填入旧下标:
-
从前往后遍历旧序列 。
-
去
Map中查当前的旧元素是否在新序列 中。如果存在,我们就拿到了它在新序列里的位置newIndex。 -
关键操作:把该元素在旧序列 中的原始下标(为了避开初始值
0,通常会 )填入到source数组的newIndex位置上。也就是:source[newIndex] = oldIndex + 1。
-
-
求出 LIS,圈定“不动点”:
-
遍历结束后,
source数组中就填满了旧节点的位置索引。 -
此时,对
source数组求解 最长递增子序列(LIS)。 -
感性理解:
source数组中递增的序列,意味着这几个节点在旧家和新家里的相对先后顺序完全没变。算法最终返回的正是这些“不动点”的索引集合,我们在操作 DOM 时,直接跳过它们即可,剩下的节点才需要真正调用 DOM API 进行移动。
-
稳定唯一的 Key 是将 LCS 问题转换为 LIS 问题的保证
根据以上的推导,唯一的 ,是将 问题转换为 ,使得时间复杂度从低效的 降低至 的前提。 因此,在开发过程中,需要保证标识节点所用的 稳定且唯一。否则就会引发以下问题:
1. 如果完全没有 key:执行“就地复用”策略
当 Vue 发现子节点组没有 key 时,它不会调用复杂的 patchKeyedChildren(即包含 LIS 的算法),而是调用 patchUnkeyedChildren。
- 它会同时遍历新旧两个序列,取二者长度的最小值(
commonLength)。 - 逐个 Patch:不管这两个节点内容差多少,Vue 都会强行认为“第 个旧节点”就是“第 个新节点”,直接进行对比和更新。
- 可能造成性能损耗和状态错位
2. 如果 key 重复:陷入“索引混乱”
A. 建立 Map 阶段的覆盖
在构建 keyToNewIndexMap(新序列映射表)时,Vue 会遍历新序列。
- 如果发现两个节点
key相同,后出现的节点会覆盖先出现的节点。 - Map 里只剩下了重复
key的最后一个位置。
B. 查找映射阶段的错误
当遍历旧序列去 Map 里通过 元素-index 映射生成 source 数组时:
-
旧序列中所有带该重复
key的节点,都会匹配到新序列中的同一个位置(即最后那个位置)。 -
后果:
-
多余的卸载:某些旧节点可能因为映射逻辑混乱,被误判为“新序列中不存在”,从而被错误地删除。
-
DOM 冲突:Vue 可能会尝试把多个旧 DOM 挂载到同一个新位置,或者在
patch时因为 VNode 引用混乱导致浏览器报错
-