虚拟 dom 之移动 (opens new window) 中我们介绍了一个简单的虚拟 dom diff 的算法,这篇文章主要介绍一下对它的优化。
# 场景
考虑下边的场景:

按照 虚拟 dom 之移动 (opens new window) 中的算法,遍历 newVnode ,a 对应的 index = 0 小于 4 ,所以要把 dom 中对应的 a 移到 e 后边,同理依次把 b 再移到 a 后边,c 移到 b 后边,d 移到 c 后边,最终将 oldVnode 对应的 dom 结构转换为了 newVnode 对应的 dom 结构。
部分过程如下:


总共需要 4 次的 dom 移动,但如果我们仔细观察 oldVnode 到 newVnode 的变化:

我们只需要将 e 移动到 a 的前边,移动 1 次就把 oldVnode 对应的 dom 结构转换为了 newVnode 对应的 dom 结构。
下边我们就针对上边这类情况进行算法的优化。
# 双端 diff 算法
对于上边的场景,我们可以将新的 vnode 和旧 vnode 的头部和尾部比较即可找到匹配的 e 。

找到 e 之后,因为当前 e 是新的 vnode 头部、旧 vnode 的尾部,我们需要把 e 对应的 dom 移动到 oldStartIdx 对应的 a 的 dom 的前边。

移动一次,dom 的顺序就和 newVnode 一致了。
除了头尾进行了交换,尾头也可能发生交换,下边我们讨论头头比较、尾尾比较、头尾比较、尾头比较不同情况的处理方式即可。

# 头头比较
如果 newStartIdx 和 oldStartIdx 对应的 vnode 相同:

dom 顺序无需改变,此时只需要将 oldStartIdx 和 newStartIdx 都后移即可:

如果不相同,我们就进行尾尾比较。
# 尾尾比较
如果 newEndIdx 和 oldEndIdx 对应的 vnode 相同:

dom 顺序无需改变,此时只需要将 newEndIdx 和 oldEndIdx 都前移即可:

如果不相同,我们就进行头尾比较。
# 头尾比较
如果 oldStartIdx 和 newEndIdx 对应的 vnode 相同:

此时 e 对应的 dom 节点从第一个位置移动到了最后一个,oldVnode 的顺序对应的就是实际 dom 的顺序,我们需要将 e 对应的 dom 接到 oldEndIdx 对应 dom 的后边。
然后将 oldStartIdx 后移,newEndIdx 前移。

如果不相同,我们就进行尾头比较。
# 尾头比较
如果 oldEndIdx 和 newStartIdx 对应的 vnode 相同:

此时说明 e 对应的 dom 节点从最后一个位置移动到了第一个,oldVnode 的顺序对应的就是实际 dom 的顺序,我们需要将 e 对应的 dom 移动到 oldStartIdx 对应 dom 的前边。
然后将 newStartIdx 后移,oldEndIdx 前移。

如果不相同,我们就循环查找。
# 循环查找
如果上边四种情况都没有匹配,我们就回到 虚拟dom之移动 (opens new window) 介绍的,通过 for 循环从 oldVnode 中依次查找 newVnode。

因为我们假设了没有增删节点,所以到这步一定会找到一个 oldVnode 匹配当前 newVnode ,当找到后,因为此时的 newVnode 是在最前边,我需要将 oldVnode 对应的 dom 移动到 oldStartIdx 对应 dom 的前边,这样就可以和 newVnode 的顺序匹配了。
同时我们把匹配到的 vnode 标记为 undefind ,双指针移到到这里的时候,因为已经匹配过了,直接跳过就好。
最后,将 newStartIdx 后移。
# 小结
接下来只需要不停的重复上边的比较过程,我们只需要关心新旧双指针内的节点,节点外已经排好序了,当 oldStartIdx 和 oldEndIdx 相遇即说明所有节点判断完毕了。
# 代码实现
只需要按照上边的逻辑编写即可,调用 nodeOps.insertBefore 来实现移动 dom 。
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx, idxInOld, vnodeToMove;
while (oldStartIdx <= oldEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // 已经被置为 undefined 跳过
} else if (isUndef(oldEndVnode)) { // 已经被置为 undefined 跳过
oldEndVnode = oldCh[--oldEndIdx];
// 头头比较
} 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)) {
// Vnode moved right
patchVnode(oldStartVnode, newEndVnode);
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVnode.elm)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
// 尾头比较
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(oldEndVnode, newStartVnode);
nodeOps.insertBefore(
parentElm,
oldEndVnode.elm,
oldStartVnode.elm
);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
// 上边都无匹配,进入 for 循环查找
} else {
if (isUndef(oldKeyToIdx))
// 利用 key 映射查找
oldKeyToIdx = createKeyToOldIdx(
oldCh,
oldStartIdx,
oldEndIdx
);
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(
newStartVnode,
oldCh,
oldStartIdx,
oldEndIdx
);
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode);
oldCh[idxInOld] = undefined;
nodeOps.insertBefore(
parentElm,
vnodeToMove.elm,
oldStartVnode.elm
);
}
newStartVnode = newCh[++newStartIdx];
}
}
}
function findIdxInOld(node, oldCh, start, end) {
for (let i = start; i < end; i++) {
const c = oldCh[i];
if (isDef(c) && sameVnode(node, c)) return i;
}
}
# 测试
测试程序不需要变化,还是虚拟 dom 之移动 (opens new window) 中的测试代码:
import * as nodeOps from "./node-ops";
import modules from "./modules";
import { createPatchFunction } from "./patch";
import { createElement } from "./create-element";
import { observe } from "./observer/reactive";
import Watcher from "./observer/watcher";
const options = {
el: "#root",
data: {
list: ["a", "b", "c"],
},
render(createElement) {
const children = [];
for (const item of this.list) {
children.push(createElement("div", { key: item }, item));
}
const vnode = createElement(
"div",
{
on: {
click: () => {
console.log(1);
this.list = ["c", "a", "b"];
},
},
},
children
);
return vnode;
},
};
const _render = function () {
const vnode = options.render.call(options.data, createElement);
return vnode;
};
const __patch__ = createPatchFunction({ nodeOps, modules });
const vm = {};
vm.$el = document.querySelector(options.el);
const _update = (vnode) => {
const prevVnode = vm._vnode;
vm._vnode = vnode;
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) {
// initial render
vm.$el = __patch__(vm.$el, vnode);
} else {
// updates
vm.$el = __patch__(prevVnode, vnode);
}
};
observe(options.data);
new Watcher(options.data, () => _update(_render()));
通过优化,由 a,b,c 变为 c,a,b ,只需要移动一次 dom ,将 c 移动到 a 前边即可。
# 总
这篇文章主要是在 虚拟 dom 之移动 (opens new window) 基础上针对一些特定情况进行了优化,尽可能的保持原来的相对顺序,这样可以减少未来 dom 的移动。
当然,目前为止我们还是假设前后 dom 结构没有发生变化,下篇文章我们会考虑 dom 增减场景下的处理。