在上一篇文章中,我们学习了 Diff 算法的基础原理和 key 的重要性。今天,我们将深入 Vue2 中经典的双端比较算法——这个算法通过四个指针的巧妙移动,实现了高效的节点更新。理解这个算法,不仅有助于掌握Vue2的diff原理,也为理解 Vue3 的更优算法打下基础。
前言:为什么需要双端比较?
我们还是以积木为例,假如我们有这样一排积木:
A B C D
然后我们想把它变成这样:
D A B C
也就是仅仅把 D 提到 A 的前面,如果我们用上一篇文章学的简单 Diff 算法,会怎么做呢?
- 比较位置0:
AvsD,节点不同,更新为D - 比较位置1:
BvsA,节点不同,更新为A - 比较位置2:
CvsB,节点不同,更新为B - 比较位置3:
DvsC,节点不同,更新为C
上述 4 次更新操作中,没有复用任何节点。但实际上,这些节点除了顺序变化外,内容根本没有变。我们其实只需要通过移动 DOM 就复用它们,而且只需要移动一次(把 D 移动到 A 前面),就可以达到我们想要的效果。
双端 Diff 的核心思想
四个指针的设计
双端 Diff 算法在旧子节点数组和新子节点数组的两端各设置两个指针:
// 四个指针
let oldStartIdx = 0; // 旧节点起始索引
let oldEndIdx = oldChildren.length - 1; // 旧节点结束索引
let newStartIdx = 0; // 新节点起始索引
let newEndIdx = newChildren.length - 1; // 新节点结束索引
// 对应的节点
let oldStartVNode = oldChildren[oldStartIdx];
let oldEndVNode = oldChildren[oldEndIdx];
let newStartVNode = newChildren[newStartIdx];
let newEndVNode = newChildren[newEndIdx];
这四个指针的布局如图所示:
四种比较情况
双端比较的核心是进行四种比较:
1. 旧开始 vs 新开始
if (isSameVNodeType(oldStartVNode, newStartVNode)) {
// 节点相同,直接复用
patch(oldStartVNode, newStartVNode);
oldStartIdx++;
newStartIdx++;
}
2. 旧结束 vs 新结束
if (isSameVNodeType(oldEndVNode, newEndVNode)) {
// 节点相同,直接复用
patch(oldEndVNode, newEndVNode);
oldEndIdx--;
newEndIdx--;
}
3. 旧开始 vs 新结束
if (isSameVNodeType(oldStartVNode, newEndVNode)) {
// 节点相同,但位置不同,需要移动
patch(oldStartVNode, newEndVNode);
// 将旧开始节点移动到旧结束节点之后
insertBefore(oldStartVNode.el, oldEndVNode.el.nextSibling);
oldStartIdx++;
newEndIdx--;
}
4. 旧结束 vs 新开始
if (isSameVNodeType(oldEndVNode, newStartVNode)) {
// 节点相同,但位置不同,需要移动
patch(oldEndVNode, newStartVNode);
// 将旧结束节点移动到旧开始节点之前
insertBefore(oldEndVNode.el, oldStartVNode.el);
oldEndIdx--;
newStartIdx++;
}
通过 key 查找复用
为什么需要key查找?
当四种指标的比较都不匹配时,即非理想状况下,说明节点位置发生了较大变化。这时就需要通过 key 在旧节点中查找可复用的节点,如以下示例:
旧: A - B - C - D
新: C - A - D - B
第1轮比较时,四种指针比较都不匹配。这时就需要通过 key 查找,查找新开始节点 C 在旧节点中的位置,找到位置 2,就移动旧节点的 C 到开始位置。
// 在循环开始前建立key索引表
const keyToOldIndexMap = new Map();
for (let i = 0; i < oldChildren.length; i++) {
const child = oldChildren[i];
if (child.key != null) {
keyToOldIndexMap.set(child.key, i);
}
}
// 在四种比较都不匹配时使用
const idxInNew = keyToOldIndexMap.get(oldStartVNode.key);
if (idxInNew !== undefined) {
// 找到了可复用的节点
const vnodeToMove = newChildren[idxInNew];
patch(oldStartVNode, vnodeToMove, container);
// 移动节点
container.insertBefore(oldStartVNode.el, oldStartVNode.el);
// 标记该位置已处理
newChildren[idxInNew] = undefined;
}
key查找的性能影响
| 场景 | 无key查找 | 有key查找 | 优势 |
|---|---|---|---|
| 头部插入 | 全量比较 | 直接定位 | O(n) vs O(1) |
| 节点移动 | 难以复用 | 精确复用 | 减少DOM操作 |
| 列表重排 | 性能差 | 性能优 | 差距可达10倍 |
完整的双端 Diff 实现
class DoubleEndedDiff {
constructor(options = {}) {
this.options = options;
}
/**
* 执行双端比较
*/
patchChildren(oldChildren, newChildren, container) {
// 初始化指针
let oldStartIdx = 0;
let oldEndIdx = oldChildren.length - 1;
let newStartIdx = 0;
let newEndIdx = newChildren.length - 1;
let oldStartVNode = oldChildren[oldStartIdx];
let oldEndVNode = oldChildren[oldEndIdx];
let newStartVNode = newChildren[newStartIdx];
let newEndVNode = newChildren[newEndIdx];
// 创建key索引表
const keyToOldIndexMap = this.createKeyMap(oldChildren);
// 记录移动次数
let moveCount = 0;
let patchCount = 0;
let mountCount = 0;
let unmountCount = 0;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 跳过已处理的节点
if (!oldStartVNode) {
oldStartVNode = oldChildren[++oldStartIdx];
} else if (!oldEndVNode) {
oldEndVNode = oldChildren[--oldEndIdx];
}
// 情况1: 旧开始 = 新开始
else if (this.isSameNode(oldStartVNode, newStartVNode)) {
this.patch(oldStartVNode, newStartVNode, container);
oldStartVNode = oldChildren[++oldStartIdx];
newStartVNode = newChildren[++newStartIdx];
patchCount++;
}
// 情况2: 旧结束 = 新结束
else if (this.isSameNode(oldEndVNode, newEndVNode)) {
this.patch(oldEndVNode, newEndVNode, container);
oldEndVNode = oldChildren[--oldEndIdx];
newEndVNode = newChildren[--newEndIdx];
patchCount++;
}
// 情况3: 旧开始 = 新结束
else if (this.isSameNode(oldStartVNode, newEndVNode)) {
this.patch(oldStartVNode, newEndVNode, container);
container.insertBefore(
oldStartVNode.el,
oldEndVNode.el.nextSibling
);
oldStartVNode = oldChildren[++oldStartIdx];
newEndVNode = newChildren[--newEndIdx];
moveCount++;
patchCount++;
}
// 情况4: 旧结束 = 新开始
else if (this.isSameNode(oldEndVNode, newStartVNode)) {
this.patch(oldEndVNode, newStartVNode, container);
container.insertBefore(
oldEndVNode.el,
oldStartVNode.el
);
oldEndVNode = oldChildren[--oldEndIdx];
newStartVNode = newChildren[++newStartIdx];
moveCount++;
patchCount++;
}
// 情况5: 都不匹配,通过key查找
else {
const idxInOld = keyToOldIndexMap.get(newStartVNode.key);
if (idxInOld !== undefined) {
const vnodeToMove = oldChildren[idxInOld];
this.patch(vnodeToMove, newStartVNode, container);
container.insertBefore(
vnodeToMove.el,
oldStartVNode.el
);
oldChildren[idxInOld] = undefined;
moveCount++;
patchCount++;
} else {
this.mount(newStartVNode, container, oldStartVNode.el);
mountCount++;
}
newStartVNode = newChildren[++newStartIdx];
}
// 处理剩余节点
if (oldStartIdx > oldEndIdx) {
for (let i = newStartIdx; i <= newEndIdx; i++) {
const newVNode = newChildren[i];
if (newVNode) {
this.mount(newVNode, container, newChildren[newEndIdx + 1]?.el);
mountCount++;
}
}
} else if (newStartIdx > newEndIdx) {
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const oldVNode = oldChildren[i];
if (oldVNode) {
this.unmount(oldVNode);
unmountCount++;
}
}
}
}
/**
* 创建key索引表
*/
createKeyMap(children) {
const map = new Map();
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child?.key != null) {
map.set(child.key, i);
}
}
return map;
}
/**
* 判断两个节点是否相同
*/
isSameNode(n1, n2) {
return n1 && n2 && n1.type === n2.type && n1.key === n2.key;
}
/**
* 更新节点
*/
patch(oldVNode, newVNode, container) {
if (oldVNode.el) {
newVNode.el = oldVNode.el;
if (newVNode.children !== oldVNode.children) {
newVNode.el.textContent = newVNode.children;
}
}
}
/**
* 挂载新节点
*/
mount(vnode, container, anchor) {
const el = document.createElement(vnode.type);
vnode.el = el;
el.textContent = vnode.children;
if (anchor) {
container.insertBefore(el, anchor);
} else {
container.appendChild(el);
}
}
/**
* 卸载节点
*/
unmount(vnode) {
if (vnode.el && vnode.el.parentNode) {
vnode.el.parentNode.removeChild(vnode.el);
}
}
}
源码对标:Vue2的双端 Diff
Vue2 的双端 Diff 算法实现位于 src/core/vdom/patch.js 中:
// Vue2源码中的双端比较(简化版)
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let newStartIdx = 0;
let newEndIdx = newCh.length - 1;
let oldStartVnode = oldCh[oldStartIdx];
let oldEndVnode = oldCh[oldEndIdx];
let newStartVnode = newCh[newStartIdx];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx, idxInOld, vnodeToMove;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode);
api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key];
if (isUndef(idxInOld)) {
api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm);
} else {
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode);
oldCh[idxInOld] = undefined;
api.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
} else {
api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
if (oldStartIdx > oldEndIdx) {
// 挂载剩余新节点
} else if (newStartIdx > newEndIdx) {
// 卸载剩余旧节点
}
}
结语
双端比较算法是 Vue2 响应式系统的核心之一,理解它不仅能帮助我们写出更高效的代码,也为理解 Vue3 的更优算法打下基础。虽然 Vue3 采用了新的算法,但双端比较的思想仍然值得我们深入学习。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!