在之前的render渲染器实现中,当新旧节点都是数组的时候,如果想提升更新的性能,那就需要对新旧节点做对比,这个对比方法就是Diff算法
前置知识
key属性
在判断VNode是否相同的isSameVNodeType方法中,除了对比节点的type,还对比了key这个属性
function isSameVNodeType(oldVNode: VNode, newVNode: VNode) {
return oldVNode.type === newVNode.type && oldVNode.key === newVNode.key;
}
我们知道,在使用Vue做列表渲染的时候,会给节点添加一个key属性,这个属性就是用来标记VNode的唯一性的
const vnode = h("li", { key: 1 }, "a");
源码中需要在创建VNode的时候添加这个key属性,来自于props
function createBaseVNode(type, props, children, shapeFlag) {
const vnode = {
......
key: props?.key || null,
} as VNode;
......
}
最长递增子序列
即:一个数组中可以找到的最长的递增序列,这个序列在原数组中不要求连续
const arr = [1, 3, 2, 4, 5, 6]
// 递增子序列包括[1, 2, 4, 5, 6] [1, 3, 4, 5, 6]等等
// 最长递增子序列为[1, 3, 4, 5, 6](不唯一)
意义
最长递增子序列的意义在于:找到一个最长的“不变序列”,其他的新节点围绕这个“不变序列”做移动即可,保证了我们移动次数的最小化
求解算法
最长递增子序列的求解算法用到了贪心算法和二分查找
需要注意的是,这个算法最后获取到的是最长递增子序列的下标,而非原数组
/**
* 1.先拿到当前元素
* 2.看当前元素是否比之前结果的最后一个大
* 2.1 是,存储
* 2.2 不是,用当前的替换刚才的(用二分查找实现)
*/
// 获取最长递增子序列的下标
function getSequence(arr: number[]): number[] {
// 生成arr的浅拷贝
const p = arr.slice();
// 最长递增子序列下标
const result = [0]; // 暂时把第一项存入最后结果
let i, j, u, v, c;
for (i = 0; i < arr.length; i++) {
// 拿到每一个元素
const arrI = arr[i];
// 这里不为零是因为会不停改变数组的值,0表示的是下标,不具备比较的意义
if (arrI !== 0) {
// j是目前拿到的最长递增子序列最后一项的值(即原数组中下标)
j = result[result.length - 1];
// 如果result中最后一项比当前元素值小,则应该把当前值存起来
if (arr[j] < arrI) {
// result变化前,记录result更新前最后一个索引的值是多少
p[i] = j;
result.push(i);
continue;
}
// 针对result开始二分查找,目的是找到需要变更的result的下标
u = 0;
v = result.length - 1;
while (u < v) {
// 平分,向下取整,拿到对比位置的中间位置(例如0和1拿到0)
c = (u + v) >> 1;
// 看当前中间位的arr值是否小于当前值,是的话,向右侧继续去比对
if (arr[result[c]] < arrI) {
u = c + 1;
}
// 如果不是,右侧缩窄到中间位置,再去做二分(说明右侧数字都比arrI大)
else {
v = c;
}
}
// 在result中,用更大值的下标,替换原来较小值的下标
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
// 回溯
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
Diff算法
Diff算法在源码中用的是patchKeyedChildren方法,和其他的patch方法类似,也接收四个参数:新节点、旧节点、容器、锚点
const patchChildren = (oldVNode, newVNode, container, anchor) => {
......
if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
......
}
else {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
if (newShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// diff计算
patchKeyedChildren(c1, c2, container, anchor);
}
......
}
else {
......
}
}
};
Diff算法的对比方法
阅读源码可以知道,Diff算法大致分了3种对比方式,从中细分为5种,包括:
- 新旧节点数量一致
- 自前向后比对
- 自后向前比对
- 新旧节点数量不一致
- 新节点多于旧节点比对
- 旧节点多于新节点比对
- 乱序比对
节点数量一致时的对比方法
细分为自前向后比对和自后向前比对
两者的目的在于从头尾两侧发现相同节点,执行打补丁操作,剩下不一样的节点后面再处理
自前向后比对
const vnode = h("ul", [
h("li", { key: 1 }, "a"),
h("li", { key: 2 }, "b"),
h("li", { key: 3 }, "c"),
]);
render(vnode, document.querySelector("#app"));
setTimeout(() => {
const vnode2 = h("ul", [
h("li", { key: 1 }, "a"),
h("li", { key: 2 }, "b"),
h("li", { key: 3 }, "d"),
]);
render(vnode2, document.querySelector("#app"));
}, 2000);
对比的逻辑简单,我们从前往后去拿到新旧子节点,如果是同一个节点(type和key相同),则打补丁,如果不同,则跳出循环不再比对
const patchKeyedChildren = (
oldChildren,
newChildren,
container,
parentAnchor
) => {
let i = 0;
const newChildrenLength = newChildren.length;
let oldChildrenEnd = oldChildren.length - 1;
let newChildrenEnd = newChildrenLength - 1;
// 场景1: 自前向后
while (i <= oldChildrenEnd && i <= newChildrenEnd) {
const oldVNode = oldChildren[i];
const newVNode = normalizeVNode(newChildren[i]);
if (isSameVNodeType(oldVNode, newVNode)) {
patch(oldVNode, newVNode, container, null);
} else {
break;
}
i++;
}
};
自后向前比对
下面的示例之所以对第一个节点的key修改,是为了跳出第一个自前向后的比对,进入自后向前比对中
const vnode = h("ul", [
h("li", { key: 4 }, "a"),
h("li", { key: 2 }, "b"),
h("li", { key: 3 }, "c"),
]);
render(vnode, document.querySelector("#app"));
setTimeout(() => {
const vnode2 = h("ul", [
h("li", { key: 1 }, "a"),
h("li", { key: 2 }, "b"),
h("li", { key: 3 }, "d"),
]);
render(vnode2, document.querySelector("#app"));
}, 2000);
自后向前比对的逻辑也类似,即从后往前拿到新旧子节点,如果是同一个节点(type和key相同),则打补丁,否则跳出循环不再比对
const patchKeyedChildren = (
oldChildren,
newChildren,
container,
parentAnchor
) => {
let i = 0;
const newChildrenLength = newChildren.length;
let oldChildrenEnd = oldChildren.length - 1;
let newChildrenEnd = newChildrenLength - 1;
......
// 场景2: 自后向前
while (i <= oldChildrenEnd && i <= newChildrenEnd) {
const oldVNode = oldChildren[oldChildrenEnd];
const newVNode = normalizeVNode(newChildren[newChildrenEnd]);
if (isSameVNodeType(oldVNode, newVNode)) {
patch(oldVNode, newVNode, container, null);
} else {
break;
}
oldChildrenEnd--;
newChildrenEnd--;
}
};
节点数量不一致时的对比方法
新节点多于旧节点
这里的多可能在前,也可能在后
const vnode1 = h("ul", [
h("li", { key: 1 }, "a"),
h("li", { key: 2 }, "b"),
]);
render(vnode1, document.querySelector("#app"));
// 新节点在后面
setTimeout(() => {
const vnode2 = h("ul", [
h("li", { key: 1 }, "a"),
h("li", { key: 2 }, "b"),
h("li", { key: 3 }, "c"),
]);
render(vnode2, document.querySelector("#app"));
}, 2000);
// 新节点在前面
setTimeout(() => {
const vnode3 = h("ul", [
h("li", { key: 3 }, "c"),
h("li", { key: 1 }, "a"),
h("li", { key: 2 }, "b"),
]);
render(vnode3, document.querySelector("#app"));
}, 2000);
我们知道,按照前面的从前到后/从后到前比对,原来一致的节点能够正常渲染,现在唯一要处理的就是这个多出来的节点,难点只是要知道这个节点插入在哪里
在源码中,新节点多于旧节点的逻辑是在自前向后/自后向前逻辑之后的,也就是说,这里会把首尾已经验证一致的节点渲染好,剩下的就是找到新增节点的位置即可
const patchKeyedChildren = (
oldChildren,
newChildren,
container,
parentAnchor
) => {
let i = 0;
const newChildrenLength = newChildren.length;
let oldChildrenEnd = oldChildren.length - 1;
let newChildrenEnd = newChildrenLength - 1;
......
// 场景3:新节点多于旧节点
// i移动到了最后一个位置,如果两条都通过,说明旧节点数量少于新节点
if (i > oldChildrenEnd) {
if (i <= newChildrenEnd) {
const nextPos = newChildrenEnd + 1;
// 下一个插入的位置
// 1. 插入在后,则新节点的末尾下标+1 >= 新节点数量,插入位置应该是父节点anchor(最后一个)
// 2. 插入在前,因为场景2,新节点末尾下标挪到0了,所以新节点末尾下标+1 < 新节点数量,插入的位置就应该是新节点末尾下标+1这个地方之前
const anchor = nextPos < newChildrenLength ? newChildren[nextPos].el : parentAnchor;
while (i <= newChildrenEnd) {
patch(null, normalizeVNode(newChildren[i]), container, anchor);
i++;
}
}
}
};
旧节点多于新节点
这里的多,同样可能在前,也可能在后
const vnode1 = h("ul", [
h("li", { key: 1 }, "a"),
h("li", { key: 2 }, "b"),
h("li", { key: 3 }, "c"),
]);
render(vnode1, document.querySelector("#app"));
// 删除的是最后一个节点
setTimeout(() => {
const vnode2 = h("ul", [
h("li", { key: 1 }, "a"),
h("li", { key: 2 }, "b"),
]);
render(vnode2, document.querySelector("#app"));
}, 2000);
// 删除的是第一个节点
setTimeout(() => {
const vnode3 = h("ul", [
h("li", { key: 2 }, "b"),
h("li", { key: 3 }, "c"),
]);
render(vnode3, document.querySelector("#app"));
}, 2000);
同样,按照前面的从前到后/从后到前比对,原来一致的节点能够正常渲染,现在要新处理的只是把这些不用的节点卸载,核心是找对卸载的节点的位置
const patchKeyedChildren = (
oldChildren,
newChildren,
container,
parentAnchor
) => {
let i = 0;
const newChildrenLength = newChildren.length;
let oldChildrenEnd = oldChildren.length - 1;
let newChildrenEnd = newChildrenLength - 1;
......
// 场景4:旧节点多于新节点
else if (i > newChildrenEnd) {
while (i <= oldChildrenEnd) {
unmount(oldChildren[i]);
i++;
}
}
};
乱序比对
实际开发中,以上两大种情况并不能真实涵盖所有场景,例如下面的这个例子
可以发现,开头和结尾的两个节点是更新,2和3是交换,C4删除了,C6新增了,这种情况下之前四种方法不够用了
但是如果先按照之前四种方法做对比,我们最后可以得到这样的结果
所以最后要对剩下的节点做四种处理,这其实也是通用的节点处理,包括:
- 更新
- 交换
- 挂载
- 删除
这些处理我们可以分步骤进行,阅读源码可以发现,整个处理过程包括
- 新节点key和下标的映射创建
- 为了后续知道哪些节点还在,好给旧节点打补丁/删除
- 为了知道哪些节点发生了移动/挂载,好给新节点做挂载/移动
- 旧节点的打补丁/删除
- 新节点的挂载/移动位置
新节点的key和下标映射
这一步就是遍历新节点,创建好map映射
const oldStartIndex = i;
const newStartIndex = i;
// 第一部分:创建新节点的key->index的map映射
const keyToNewIndexMap = new Map();
for (i = newStartIndex; i <= newChildrenEnd; i++) {
const nextChild = normalizeVNode(newChildren[i]);
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i);
}
}
旧节点的打补丁&删除
这一步对旧节点做遍历,使用了刚才创建好的新节点key和下标映射,把相同key的新旧节点关联起来(注意这里会把已经打补丁完成的节点给忽略掉)
- 去看哪些旧节点还存在,存在的话打补丁
- 不存在的旧节点,删除
这一步处理完成后,节点完成更新/卸载操作
// 第二部分:循环旧节点,完成打补丁/删除(不移动)
let j;
let patched = 0; // 已经打补丁的数量
const toBePatched = newChildrenEnd - newStartIndex + 1; // 需要打补丁的数量
let moved = false; // 标记当前节点是否需要移动
let maxNewIndexSoFar = 0; // 配合moved使用,保存当前最大新节点的index
// 新节点下标到旧节点下标的map(实际使用数组,是为了后续的最长递增子序列的求解)
// 这个数组的下标是新节点的下标,每个下标的值是旧节点的对应key的元素的index+1
// 例如新节点0对应的旧节点是在1,则记录为[2]
const newIndexToOldIndexMap = new Array(toBePatched);
// 给这个map添加占位赋值0
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;
// 循环旧节点
for (i = oldStartIndex; i <= oldChildrenEnd; i++) {
const prevChild = oldChildren[i];
// 如果已经打补丁的数量超过了需要打补丁的数量,开始卸载
if (patched >= toBePatched) {
unmount(prevChild);
continue;
}
// 新节点要存放的位置
let newIndex;
if (prevChild.key != null) {
// 从之前新节点key->index的map中拿到新节点位置(注意这个位置是包括了已处理节点的)
newIndex = keyToNewIndexMap.get(prevChild.key);
}
// !这里源码里面有个else,是处理那些没有key的节点的
// 没找到新节点索引,说明旧节点应该删除了
if (newIndex === undefined) {
unmount(prevChild);
}
// 找到新节点索引,应该打补丁(先不考虑移动的事情)
else {
newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1;
// 新节点index和当前最大新节点index比较,如果不比它大,则应该触发移动
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex;
} else {
moved = true;
}
patch(prevChild, newChildren[newIndex], container, null);
patched++;
}
}
新节点的挂载&移动
移动就需要获取最长递增子序列了,如果旧节点中没有对应的key,则直接挂载
// 第三部分:移动和挂载
// 拿到newIndex到oldIndex这个映射数组的最长递增子序列
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];
j = increasingNewIndexSequence.length - 1;
// 循环倒序,把需要patch的节点做一遍处理
for (i = toBePatched - 1; i >= 0; i--) {
// 拿到新节点
const nextIndex = newStartIndex + i;
const nextChild = newChildren[nextIndex];
// 类似场景四,做插入处理
const anchor = nextIndex + 1 < newChildrenLength ? newChildren[nextIndex + 1].el : parentAnchor;
// 新节点没有找到旧节点,插入
if (newIndexToOldIndexMap[i] === 0) {
patch(null, nextChild, container, anchor);
}
// 如果需要移动,根据最长递增子序列做处理
else if (moved) {
// 如果不存在最长递增子序列/当前index不是最长递增子序列的最后一个元素,做移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor);
} else {
j--;
}
}
}
总结
归纳上面三个步骤,最后的乱序比对代码整理如下
const patchKeyedChildren = (
oldChildren,
newChildren,
container,
parentAnchor
) => {
let i = 0;
const newChildrenLength = newChildren.length;
let oldChildrenEnd = oldChildren.length - 1;
let newChildrenEnd = newChildrenLength - 1;
......
// 场景5:乱序比对
else {
const oldStartIndex = i;
const newStartIndex = i;
// 第一部分:创建新节点的key->index的map映射
const keyToNewIndexMap = new Map();
for (i = newStartIndex; i <= newChildrenEnd; i++) {
const nextChild = normalizeVNode(newChildren[i]);
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i);
}
}
// 第二部分:循环旧节点,完成打补丁/删除(不移动)
let j;
let patched = 0; // 已经打补丁的数量
const toBePatched = newChildrenEnd - newStartIndex + 1; // 需要打补丁的数量
let moved = false; // 标记当前节点是否需要移动
let maxNewIndexSoFar = 0; // 配合moved使用,保存当前最大新节点的index
// 新节点下标到旧节点下标的map(实际使用数组,是为了后续的最长递增子序列的求解)
// 这个数组的下标是新节点的下标,每个下标的值是旧节点的对应key的元素的index+1
// 例如新节点0对应的旧节点是在1,则记录为[2]
const newIndexToOldIndexMap = new Array(toBePatched);
// 给这个map添加占位赋值0
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;
// 循环旧节点
for (i = oldStartIndex; i <= oldChildrenEnd; i++) {
const prevChild = oldChildren[i];
// 如果已经打补丁的数量超过了需要打补丁的数量,开始卸载
if (patched >= toBePatched) {
unmount(prevChild);
continue;
}
// 新节点要存放的位置
let newIndex;
if (prevChild.key != null) {
// 从之前新节点key->index的map中拿到新节点位置(注意这个位置是包括了已处理节点的)
newIndex = keyToNewIndexMap.get(prevChild.key);
}
// !这里源码里面有个else,是处理那些没有key的节点的
// 没找到新节点索引,说明旧节点应该删除了
if (newIndex === undefined) {
unmount(prevChild);
}
// 找到新节点索引,应该打补丁(先不考虑移动的事情)
else {
newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1;
// 新节点index和当前最大新节点index比较,如果不比它大,则应该触发移动
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex;
} else {
moved = true;
}
patch(prevChild, newChildren[newIndex], container, null);
patched++;
}
}
// 第三部分:移动和挂载
// 拿到newIndex到oldIndex这个映射数组的最长递增子序列
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];
j = increasingNewIndexSequence.length - 1;
// 循环倒序,把需要patch的节点做一遍处理
for (i = toBePatched - 1; i >= 0; i--) {
// 拿到新节点
const nextIndex = newStartIndex + i;
const nextChild = newChildren[nextIndex];
// 类似场景四,做插入处理
const anchor = nextIndex + 1 < newChildrenLength ? newChildren[nextIndex + 1].el : parentAnchor;
// 新节点没有找到旧节点,插入
if (newIndexToOldIndexMap[i] === 0) {
patch(null, nextChild, container, anchor);
}
// 如果需要移动,根据最长递增子序列做处理
else if (moved) {
// 如果不存在最长递增子序列/当前index不是最长递增子序列的最后一个元素,做移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor);
} else {
j--;
}
}
}
}
};