在上一篇文章中,我们深入学习了 Vue2 的双端比较算法。今天,我们将探索 Vue3中 更先进的 Diff 算法——它通过最长递增子序列(Longest Increasing Subsequence,LIS)进一步减少了 DOM移动次数,实现了性能的又一次飞跃。
前言:从一道经典算法题说起
最长递增子序列是一道经典的算法题:给定一个无序的整数数组,找到其中最长的严格递增子序列的长度。
输入: [10, 9, 2, 5, 3, 7, 101, 18]
输出: 4
解释: 最长递增子序列是 [2, 3, 7, 101]
这时候大家可能会有疑问了:一个算法题和 Vue 的 Diff 又有什么关系?
那么,就让我们来看一个实际的 DOM 更新的场景的示例:
旧节点: A - B - C - D - E - F - G - H
新节点: A - C - E - B - G - D - H - F
针对上面的情况,我们该如何操作才能保证最少的移动次数呢?其实,只要我们能找出不需要移动的节点,那么剩下的就是需要移动的。这个不需要移动的节点序列,其实就是最长递增子序列!
为什么需要最长递增子序列?
从双端 Diff 的局限性说起
其实 Vue2 的双端 Diff 算法已经很优秀了,但它仍然存在一些局限性,还是以前言中的 DOM 更新场景为例,使用双端 Diff 算法要经过哪些步骤呢:
A = A:新旧节点一样,保持不动,往后比较;- 从第 2 个数据开始比较,发现需要移动
C; - 把
C移动到B前面; - 继续比较,发现需要移动
E; - 把
E移动到C的后面; - 继续移动比较,最终要移动很多次,即除了第 1 次比较不用移动外,剩下的都要移动。
这就是双端 Diff 算法的局限性,每次比较都是两端进行比较,是局部比较,无法做到全局最优。
最长递增子序列的思路
其实最长递增子序列的思路就是:找出新旧列表中,节点顺序相对一致的节点列表:
旧节点 + 索引: A(0) - B(1) - C(2) - D(3) - E(4) - F(5) - G(6) - H(7)
新节点: A - C - E - B - G - D - H - F
这时,我们需要建立新节点在旧列表中的位置映射:
新节点的位置映射:A(0) - C(2) - E(4) - B(1) - G(6) - D(3) - H(7) - F(5)
位置数组:0 2 4 1 6 3 7 5
此时位置数组的最长递增子序列是:0 2 4 6 7 ,即:A C E G H 几个节点是不需要移动的。
最长递增子序列算法原理
问题定义
最长递增子序列:给定一个序列,找到其中最长的严格递增子序列(不要求连续):
序列: [2, 1, 5, 3, 6, 4, 8, 9, 7]
最长递增子序列: [2, 5, 6, 8, 9] 长度5
或 [1, 5, 6, 8, 9] 长度5
动态规划解法
动态规划是最长增长子序列的诸多解法中,最容易理解的解法:
function lengthOfLIS(nums) {
if (!nums.length) return 0;
// dp[i] 表示以nums[i]结尾的最长递增子序列长度
const dp = new Array(nums.length).fill(1);
let maxLen = 1;
for (let i = 1; i < nums.length; i++) {
for (let j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
动态规划解法的时间复杂度是:O(n²), 对于大量节点来说,处理较慢。
贪心算法 + 二分算法优化
Vue3 使用的是更高效的贪心算法+二分算法,该算法的时间复杂度为:O(nlogn):
function getSequence(arr) {
const len = arr.length;
// 记录每个位置的最长递增子序列末尾的最小值
const result = [0]; // 存储索引
const p = new Array(len).fill(0); // 前驱节点记录
for (let i = 0; i < len; i++) {
const val = arr[i];
if (val === 0) continue; // 0表示新增节点,跳过
let low = 0;
let high = result.length - 1;
// 二分查找:找到第一个大于等于val的位置
while (low < high) {
const mid = (low + high) >> 1;
if (arr[result[mid]] < val) {
low = mid + 1;
} else {
high = mid;
}
}
if (arr[result[low]] < val) {
// val大于所有末尾值,直接添加到末尾
result.push(i);
p[i] = result[low];
} else {
// 替换对应位置的索引
result[low] = i;
p[i] = result[low - 1];
}
}
// 重建最长递增子序列
let u = result.length;
let v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
Vue3中LIS的具体应用
完整的Vue3 Diff流程
function patchKeyedChildren(oldChildren, newChildren, container) {
// 1. 预处理:处理相同的前缀和后缀
let i = 0;
let oldEnd = oldChildren.length - 1;
let newEnd = newChildren.length - 1;
// 处理相同的前缀
while (i <= oldEnd && i <= newEnd && isSameNode(oldChildren[i], newChildren[i])) {
patch(oldChildren[i], newChildren[i], container);
i++;
}
// 处理相同的后缀
while (i <= oldEnd && i <= newEnd && isSameNode(oldChildren[oldEnd], newChildren[newEnd])) {
patch(oldChildren[oldEnd], newChildren[newEnd], container);
oldEnd--;
newEnd--;
}
// 2. 处理简单的增删情况
if (i > oldEnd) {
// 旧节点遍历完,挂载剩余新节点
for (let j = i; j <= newEnd; j++) {
patch(null, newChildren[j], container);
}
return;
}
if (i > newEnd) {
// 新节点遍历完,卸载剩余旧节点
for (let j = i; j <= oldEnd; j++) {
unmount(oldChildren[j]);
}
return;
}
// 3. 处理未知序列(核心diff)
const oldStart = i;
const newStart = i;
const oldKeyed = oldChildren.slice(oldStart, oldEnd + 1);
const newKeyed = newChildren.slice(newStart, newEnd + 1);
// 3.1 建立新节点索引表
const keyToNewIndexMap = new Map();
for (let j = 0; j < newKeyed.length; j++) {
keyToNewIndexMap.set(newKeyed[j].key, j);
}
// 3.2 构建新节点在旧节点中的位置数组
const toBePatched = newKeyed.length;
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
for (let j = 0; j < oldKeyed.length; j++) {
const oldVNode = oldKeyed[j];
const newIndex = keyToNewIndexMap.get(oldVNode.key);
if (newIndex === undefined) {
// 旧节点不存在于新节点中,卸载
unmount(oldVNode);
} else {
// 记录旧节点位置(+1是为了区分0表示新增)
newIndexToOldIndexMap[newIndex] = j + 1;
// 更新节点
patch(oldVNode, newKeyed[newIndex], container);
}
}
// 3.3 获取最长递增子序列
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);
// 3.4 移动节点
let lastIndex = increasingNewIndexSequence.length - 1;
for (let j = toBePatched - 1; j >= 0; j--) {
const newIndex = j;
const newVNode = newKeyed[newIndex];
if (newIndexToOldIndexMap[newIndex] === 0) {
// 新节点,需要挂载
patch(null, newVNode, container);
} else if (j !== increasingNewIndexSequence[lastIndex]) {
// 不在最长递增子序列中,需要移动
container.insertBefore(newVNode.el, newKeyed[newIndex + 1]?.el);
} else {
// 在最长递增子序列中,不需要移动
lastIndex--;
}
}
}
过程解析
我们还是通过前言中的例子,来理解整个过程:
旧节点: A - B - C - D - E - F - G - H
新节点: A - C - E - B - G - D - H - F
- 预处理前缀和后缀:前缀相同:
A;后缀比较:H。剩余需要处理的:旧: B C D E F G 新: C E B G D F - 建立新节点在旧节点中的位置映射,得到位置数组:
[1, 3, 0, 5, 2, 4] - 计算最长递增子序列:
[1, 3, 5]对应节点:C E G - 从后向前遍历新节点,决定移动还是挂载:
- F: 不在LIS:需要移动
- D: 不在LIS:需要移动
- G: 在LIS:不需要移动
- B: 不在LIS:需要移动
- E: 在LIS:不需要移动
- C: 在LIS:不需要移动
完整的 Diff 实现
class Vue3Diff {
constructor(options = {}) {
this.options = options;
}
/**
* Vue3风格的核心diff
*/
patchChildren(oldChildren, newChildren, container) {
console.group('Vue3 Diff过程');
let i = 0;
let oldEnd = oldChildren.length - 1;
let newEnd = newChildren.length - 1;
// 1. 处理相同的前缀
console.log('阶段1: 处理相同前缀');
while (i <= oldEnd && i <= newEnd && this.isSameNode(oldChildren[i], newChildren[i])) {
console.log(` 复用前缀节点: ${oldChildren[i].key}`);
this.patch(oldChildren[i], newChildren[i], container);
i++;
}
// 2. 处理相同的后缀
console.log('阶段2: 处理相同后缀');
while (i <= oldEnd && i <= newEnd && this.isSameNode(oldChildren[oldEnd], newChildren[newEnd])) {
console.log(` 复用后缀节点: ${oldChildren[oldEnd].key}`);
this.patch(oldChildren[oldEnd], newChildren[newEnd], container);
oldEnd--;
newEnd--;
}
// 3. 处理简单的增删
if (i > oldEnd) {
console.log('阶段3: 挂载剩余新节点');
for (let j = i; j <= newEnd; j++) {
console.log(` 挂载新节点: ${newChildren[j].key}`);
this.mount(newChildren[j], container);
}
console.groupEnd();
return;
}
if (i > newEnd) {
console.log('阶段3: 卸载剩余旧节点');
for (let j = i; j <= oldEnd; j++) {
console.log(` 卸载旧节点: ${oldChildren[j].key}`);
this.unmount(oldChildren[j]);
}
console.groupEnd();
return;
}
// 4. 核心diff:处理未知序列
console.log('阶段4: 核心Diff');
const oldStart = i;
const newStart = i;
const oldKeyed = oldChildren.slice(oldStart, oldEnd + 1);
const newKeyed = newChildren.slice(newStart, newEnd + 1);
console.log(' 待处理旧节点:', oldKeyed.map(n => n.key).join(', '));
console.log(' 待处理新节点:', newKeyed.map(n => n.key).join(', '));
// 4.1 建立新节点索引表
const keyToNewIndexMap = new Map();
for (let j = 0; j < newKeyed.length; j++) {
keyToNewIndexMap.set(newKeyed[j].key, j);
}
// 4.2 构建新节点在旧节点中的位置数组
const toBePatched = newKeyed.length;
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
for (let j = 0; j < oldKeyed.length; j++) {
const oldVNode = oldKeyed[j];
const newIndex = keyToNewIndexMap.get(oldVNode.key);
if (newIndex === undefined) {
console.log(` 卸载旧节点: ${oldVNode.key}`);
this.unmount(oldVNode);
} else {
newIndexToOldIndexMap[newIndex] = j + 1;
console.log(` 更新节点: ${oldVNode.key} → 新位置 ${newIndex}`);
this.patch(oldVNode, newKeyed[newIndex], container);
}
}
console.log(' 位置数组:', newIndexToOldIndexMap);
// 4.3 计算最长递增子序列
const increasingNewIndexSequence = this.getSequence(newIndexToOldIndexMap);
console.log(' 最长递增子序列:', increasingNewIndexSequence);
// 4.4 移动节点
let lastIndex = increasingNewIndexSequence.length - 1;
for (let j = toBePatched - 1; j >= 0; j--) {
const newVNode = newKeyed[j];
if (newIndexToOldIndexMap[j] === 0) {
console.log(` 挂载新节点: ${newVNode.key}`);
this.mount(newVNode, container);
} else if (j !== increasingNewIndexSequence[lastIndex]) {
console.log(` 移动节点: ${newVNode.key} 到正确位置`);
container.insertBefore(newVNode.el, newKeyed[j + 1]?.el);
} else {
console.log(` 节点不动: ${newVNode.key} (在LIS中)`);
lastIndex--;
}
}
console.groupEnd();
}
/**
* 获取最长递增子序列
*/
getSequence(arr) {
const len = arr.length;
const result = [0];
const p = new Array(len).fill(0);
for (let i = 0; i < len; i++) {
const val = arr[i];
if (val === 0) continue;
let low = 0;
let high = result.length - 1;
while (low < high) {
const mid = (low + high) >> 1;
if (arr[result[mid]] < val) {
low = mid + 1;
} else {
high = mid;
}
}
if (arr[result[low]] < val) {
result.push(i);
p[i] = result[low];
} else {
result[low] = i;
p[i] = result[low - 1];
}
}
// 重建
let u = result.length;
let v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
/**
* 判断节点是否相同
*/
isSameNode(n1, n2) {
return n1 && n2 && n1.type === n2.type && n1.key === n2.key;
}
/**
* 更新节点
*/
patch(oldVNode, newVNode, container) {
newVNode.el = oldVNode.el;
if (newVNode.children !== oldVNode.children) {
newVNode.el.textContent = newVNode.children;
}
}
/**
* 挂载节点
*/
mount(vnode, container) {
const el = document.createElement(vnode.type);
vnode.el = el;
el.textContent = vnode.children;
container.appendChild(el);
}
/**
* 卸载节点
*/
unmount(vnode) {
if (vnode.el && vnode.el.parentNode) {
vnode.el.parentNode.removeChild(vnode.el);
}
}
}
Vue3 Diff相比Vue2的优化
| 比较维度 | Vue2 | Vue3 | 优化效果 |
|---|---|---|---|
| 算法核心 | 双端比较 | 最长递增子序列 | 移动次数更少 |
| 编译优化 | 无 | 静态提升 + PatchFlags | 跳过静态节点 |
| 静态节点 | 需要比较 | 直接复用 | 性能提升50%+ |
| Fragment | 不支持 | 支持 | 多根节点 |
| Tree-shaking | 较差 | 优秀 | 代码体积更小 |
| TypeScript | 支持有限 | 原生支持 | 类型安全 |
结语
最长递增子序列算法的应用,展示了 Vue3 团队如何将经典的算法问题与实际的 DOM 更新场景相结合,创造出既优雅又高效的解决方案。理解这个算法,不仅能帮助我们写出更好的 Vue 应用,也能提升我们的算法思维。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!