渲染器的diff算法是为了解决当新旧vnode的子节点都为一组节点时,达到相对最小性能开销并实现更新的效果,其本质上还是为了减少操作DOM元素。
这篇文章将介绍vue2使用的双端diff以及vue3使用的快速diff,具体代码可见我的mini-vue3项目commit记录( github.com/4noth1ng/my… )
前置
最简单的更新
如果新旧vnode的子节点都是一组节点,最简单的更新方式是什么? 我们只需要卸载所有旧节点,挂载新节点即可。那么为了减少性能开销,可以如何优化呢?
优化思路
事实上,如果新旧节点的子节点全部不同(包括tag和content),这就是最坏情况,我们只能进行卸载全部旧节点,挂载新节点的操作。但如果存在可复用节点,我们就无需进行卸载挂载操作,而是直接进行复用,并将其移动到正确的位置。
那么我们优化的方向就有两个:
- 找到可以复用的vnode
- 移动尽可能少的次数达到更新效果
可复用的dom元素
首先我们要知道vnode上的三个属性:
type: 可以是标签的tag(即element类型),也可以是一个componentchildren: 子节点key:也就是我们使用v-for时绑定的key
我们将两个type以及key相同的vnode定义为相同的虚拟节点,这样的两个虚拟节点可以进行复用。
对dom元素的操作
我们对dom元素的操作基本为:移动、添加、删除,我们会引入一个锚点节点anchor,作为dom元素移动的相对坐标。
对于不可复用的dom元素,我们需要卸载这些旧的dom元素,并将新的dom元素挂载上。
双端diff
双端diff指的是:在新旧两组子节点的四个端点之间分别进行比较,并试图找到可复用的节点。下面我们假设所有节点的type相同
双端比较流程
我们定义四个索引:oldStartIdx, oldEndIdx, newStartIdx, newEndIdx,分别对应四个节点oldStartVNode, oldEndVNode, newStartVNode, newEndVNode,并进行四步的循环,循环条件为oldStartIdx < = oldEndIdx && newStartIdx <= newEndIdx
-
比较
oldStartVNode和newStartVNode的key是否相同,如果相同,即可复用,进行patch打补丁,并将oldStartIdx和newStartIdx向后移, 否则进行第2步 -
比较
oldEndVNode和newEndVNode的key是否相同,如果相同,即可复用,进行patch打补丁,并将oldEndVNode和newEndVNode向后移, 否则进行第3步 -
比较
oldStartVNode和newEndVNode的key是否相同,如果相同,即可复用,进行patch打补丁, 并将oldStartVNode对应的dom元素移动到oldEndVNode对应dom元素之后(因为这一步可复用意味着oldStartVNode为当前遍历过的节点里的newEnd), 并相应移动指针, 否则进行第4步 -
比较
oldEndVNode和newStartVNode的key是否相同,如果相同,即可复用,进行patch打补丁, 并将oldEndVNode对应的dom元素移动到oldStartVNode对应dom元素之前(因为这一步可复用意味着oldEndVNode为当前遍历过的节点里的newStart), 并相应移动指针
非理想情况
在一部分情况下,可能出现上述四种都没有命中的情况,我们将这些剩余的情况称为非理想情况。对于非理想情况,我们采用如下方法: 拿着新子节点的头部节点去旧的一组子节点中寻找可复用元素。如果找到了,意味着找到的这个节点应该是当前的头节点,即将这个旧子节点中的节点oldNode移动到oldStartVNode之前(当然也要patch),并且由于操作后该节点在oldChildren属于被遍历过,所以oldchidren内该索引设为undefined, 并将newStartIdx往后移动。
当然,上述方法也不一定就能在oldChildren内找到可复用节点,如果找不到意味着什么呢?说明newStartVNode为新增节点,那么我们就需要将newStartVNode对应dom元素添加到oldStartVNode对应dom元素前
循环结束后的剩余节点
我们循环的条件是oldStartIdx < = oldEndIdx && newStartIdx <= newEndIdx,那么在退出上述循环后,并非所有节点都会被遍历,如果oldChildren内还有剩余节点,即当循环结束后oldStartIdx <= oldEndIdx, 则将这些节点全部卸载, 同理,如果newChildren内有剩余节点,即当循环结束后newStartIdx <= newEndIdx,则将这些节点全部添加,对应的锚点为newChildren[newEndIdx + 1]。至此,diff结束。下面贴出关键部分代码
function patchKeyedChildren(oldChildren, newChildren, container, parentComponent) {
// 新旧CHILDREN首尾指针
let oldStartIdx = 0;
let oldEndIdx = oldChildren.length - 1;
let newStartIdx = 0;
let newEndIdx = newChildren.length - 1;
// 对应VNode
let oldStartVNode = oldChildren[oldStartIdx];
let oldEndVNode = oldChildren[oldEndIdx];
let newStartVNode = newChildren[newStartIdx];
let newEndVNode = newChildren[newEndIdx];
debugger;
// 新旧Children diff操作
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (!oldStartVNode) {
oldStartVNode = oldChildren[++oldStartIdx];
}
else if (!oldEndVNode) {
oldEndVNode = oldChildren[--oldEndIdx];
}
else if (oldStartVNode.key === newStartVNode.key) {
// 第一步:新旧头节点比较
patch(oldStartVNode, newStartVNode, container, parentComponent);
oldStartVNode = oldChildren[++oldStartIdx];
newStartVNode = newChildren[++newStartIdx];
}
else if (oldEndVNode.key === newEndVNode.key) {
// 第二步:新旧尾节点比较
patch(oldEndVNode, newEndVNode, container, parentComponent);
oldEndVNode = oldChildren[--oldEndIdx];
newEndVNode = newChildren[--newEndIdx];
}
else if (oldEndVNode.key === newStartVNode.key) {
// 第三步:旧尾节点与新头节点比较
patch(oldEndVNode, newStartVNode, container, parentComponent);
// oldEndVNode 应该移动到 oldStartVNode 前
const anchor = oldStartVNode.el;
hostInsert(oldEndVNode.el, container, anchor);
oldEndVNode = oldChildren[--oldEndIdx];
newStartVNode = newChildren[++newStartIdx];
}
else if (oldStartVNode.key === newEndVNode.key) {
// 第四步:旧头节点与新尾节点比较
patch(oldStartVNode, newEndVNode, container, parentComponent);
// oldStartVNode 应该移动到 oldEndVNode 后
const anchor = oldEndVNode.el.nextSibling;
hostInsert(oldStartVNode.el, container, anchor);
oldStartVNode = oldChildren[++oldStartIdx];
newEndVNode = newChildren[--newEndIdx];
}
else {
// 第五步:前四种都未匹配
// 遍历OldChildren,寻找与 newStartVNode 具有相同 key的节点, 找到这个节点,就意味着这个节点需要移到当前oldStartVNode之前,找不到,就将它作为新的头节点
// idxInOld 就是newStartVNode在OldChildren内的索引值
const idxInOld = oldChildren.findIndex((node) => node && node.key === newStartVNode.key); // 如果没有,会返回 -1
if (idxInOld > 0) {
const vnodeToMove = oldChildren[idxInOld];
// 移动前先递归进行patch
patch(vnodeToMove, newStartVNode, container, parentComponent);
// 移到oldStartVNode 之前
const anchor = oldStartVNode.el;
hostInsert(vnodeToMove.el, container, anchor);
// 由于该节点已经被移动,所以此处设为undefined,并在最前增加两个对于oldChildren内undefined节点的判断
oldChildren[idxInOld] = undefined;
newStartVNode = newChildren[++newStartIdx];
}
else {
// 旧节点中不存在
patch(null, newStartVNode, container, parentComponent);
hostInsert(newStartVNode.el, container, oldStartVNode.el);
}
newStartVNode = newChildren[++newStartIdx];
}
}
// 如果newChildren内有遗留的节点,进行添加
if (oldStartIdx > oldEndIdx && newStartIdx <= newEndIdx) {
for (let i = newStartIdx; i <= newEndIdx; i++) {
const anchor = newChildren[newEndIdx + 1]
? newChildren[newEndIdx + 1].el
: null;
// [newStartIdx, newEndIdx]内为遗留的节点, 按照在 newChildren内的顺序, 我们需要将这些节点全部插入到newChildren[newEndIdx + 1]前
// 如果newChildren[newEndIdx + 1]不存在, 说明全部插入至结尾
patch(null, newChildren[i], container, parentComponent);
hostInsert(newChildren[i].el, container, anchor);
}
}
// 如果oldChildren内有遗留的节点,进行删除
if (newStartIdx > newEndIdx && oldStartIdx <= oldEndIdx) {
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
hostRemove(oldChildren[i].el);
}
}
}
快速diff
快速diff是vuejs3采用的diff方法,测试表明其效率会比双端diff要高,实质上是一个最长递增子序列算法。
比较流程
-
我们定义索引
j, 并开启一个while循环,用于处理oldChildren和newChildren的相同前置节点,对于相同的前置节点,我们无需移动节点,但仍需patch打补丁并向前移动j,当oldChildren[j]与newChildren[j]不同时退出循环。 -
由于新旧两组子节点的数量可能不同,我们定义两个索引
oldEnd和newEnd指向末尾节点,从后往前遍历两组节点,同上我们不需要移动节点但需要patch,并移动索引,不同时退出 -
上述两步进行后,我们已经将相同的前置后置节点处理完毕。那么接下来有三种情况:1. 旧节点处理完毕、新节点有剩余;2. 旧节点有剩余,新节点处理完毕;3. 旧节点新节点都有剩余。我们分情况讨论。
-
旧节点处理完毕,新节点有剩余。即当
j > oldEnd && j <= newEnd时,这时候newChildren内[j, newEnd]为需要处理的节点,我们只需要以newChildren[newEnd+1]为锚点将这些节点插入即可。 -
旧节点有剩余,新节点处理完毕。即当
j <= oldEnd && j > newEnd时,这时候oldChildren内[j, newEnd]为需要处理的节点,我们只需要卸载这些节点即可 -
新旧节点都有剩余
新旧节点都有剩余
新旧节点都有剩余的情况,我们就需要计算出一个最长递增子序列,也就是一个相对位置不变的最长旧虚拟节点序列,用于辅助完成DOM移动的操作。
首先,我们定义一个source数组,这个数组的长度为newEnd-j+1,即新的一组子节点中未处理的节点数量,source数组用来存储新的一组子节点中的节点 在旧的一组子节点中的 位置索引。之后,我们进行填充source数组操作,我们将所有位置初始化为-1,并用新的子节点在旧的子节点中寻找,如果找到了就存放对应的索引值。
填充后,对于值为-1的虚拟节点,我们需要进行卸载,那么对于其他有对应可复用节点的元素,我们就需要考虑:1. 如何判断这个节点需要移动?2. 如何移动元素?
如何判断这个节点需要移动
我们定义pos=0, 代表遍历旧节点时遇到的最大索引值,当pos呈现递增时,说明无需移动节点, 我们定义k为当前newVNode在oldChildren对应的索引,如果k < pos,说明当前索引比最大索引要小,即在oldChildren中当前newVNode靠前, 需要移动。 除此之外,我们还要维护一个变量patched用于表示已经更新的节点数量,patched的值应该小于等于新的一组子节点需要更新节点的数量。
如何移动节点
首先,我们根据source数组计算出其最长递增子序列,定义为seq,最长递增子序列对应的元素意味着,这些元素的相对位置是无需改变的,对于这些节点,我们只需要进行patch打补丁即可,而无需移动元素;对于剩下的元素,我们则需要按照一定位置进行移动。
为了完成节点的移动,我们定义两个索引值i和s,i指向新的一组子节点中最后一个元素,s指向最长递增子序列的最后一个元素。
我们开启一个for循环,使得i从后往前遍历,当source[i] === -1时,说明该节点为新节点,直接挂载;当节点索引i等于seq[s]的值时,只需要s--即可;当二者不等时,说明该节点需要移动。如何进行移动呢?我们先获取i对应节点的真实索引i+newStart,也就得到了newVNode,那么我们将newVNode插入到i+newStart+1前即可,其实挂载新节点也是相似的思路。下面贴出具体代码:
function patchKeyedChildren(
oldChildren,
newChildren,
container,
parentComponent
) {
// 1. 处理相同前缀 定义索引j指向新旧两组子节点的开头
let j = 0;
let oldVNode = oldChildren[j];
let newVNode = newChildren[j];
while (oldVNode.key === newVNode.key) {
patch(oldVNode, newVNode, container, parentComponent);
j++;
oldVNode = oldChildren[j];
newVNode = newChildren[j];
}
// 2. 处理相同后缀,由于新旧两组子节点不同,所以定义两个指针
let oldEnd = oldChildren.length - 1;
let newEnd = newChildren.length - 1;
oldVNode = oldChildren[oldEnd];
newVNode = newChildren[newEnd];
while (oldVNode.key === newVNode.key) {
patch(oldVNode, newVNode, container, parentComponent);
oldVNode = oldChildren[--oldEnd];
newVNode = newChildren[--newEnd];
}
//3. 处理完前缀后缀,如果新节点数组仍有剩余,则需插入, 如何判断有剩余? 易得:j > oldEnd说明旧节点处理完毕, j <= newEnd 说明新节点未处理完毕,则当二者符合时,满足条件
if (j > oldEnd && j <= newEnd) {
// 将[j, newEnd]内的所有节点插入到 newEnd的后一个节点之前
const anchorIdx = newEnd + 1;
const anchor =
anchorIdx < newChildren.length ? newChildren[anchorIdx].el : null;
while (j <= newEnd) {
patch(null, newChildren[j], container, parentComponent);
hostInsert(newChildren[j++].el, container, anchor);
}
}
// 4. 如果旧节点数组仍有剩余,则需卸载,同上,当 j > newEnd说明新节点处理完毕, 当 j <= oldEnd 说明旧节点未处理完毕
else if (j > newEnd && j <= oldEnd) {
// 卸载 [j, oldEnd] 之间的节点
while (j <= oldEnd) {
hostRemove(oldChildren[j++].el);
}
}
// 5. 新旧都有剩余
else {
// 构建source数组,用于存放新的一组子节点在旧的一组子节点的索引
const count = newEnd - j + 1; // 需要更新的新节点数量
const source = Array(count).fill(0);
source.fill(-1);
// oldStart 和 newStart 分别为起始索引,即j
const oldStart = j;
const newStart = j;
let moved = false; // 代表是否需要移动节点
let pos = 0; // 代表遍历旧节点时遇到的最大索引值,当pos呈现递增时,说明无需移动节点
// 构建索引表, key为新节点VNode的key,value为下标索引值, 用来寻找具有相同key的可复用节点
const keyIndex = {};
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i;
}
// 代表更新过的节点数量
let patched = 0;
// 遍历旧的一组子节点中剩余未处理的节点
for (let i = oldStart; i <= oldEnd; i++) {
oldVNode = oldChildren[i];
if (patched <= count) {
const k = keyIndex[oldVNode.key];
if (typeof k !== "undefined") {
// 存在可复用节点
newVNode = newChildren[k];
patch(oldVNode, newVNode, container, parentComponent);
patched++;
source[k - newStart] = i;
if (k < pos) {
// 当前索引比最大索引要小,即在oldChildren中当前newVNode靠前, 需要移动
moved = true;
} else {
pos = k;
}
} else {
// 该旧节点不存在于新节点数组中,则直接卸载
hostRemove(oldVNode.el);
}
} else {
// patched > count 即新节点已经更新完毕, 剩余旧节点需要进行卸载
hostRemove(oldVNode.el);
}
}
if (moved) {
const seq = getSequence(source);
let s = seq.length; // s 指向递增子序列的最后一个元素
let i = count - 1; // i + newStart 指向需要更新的新节点序列最后一个元素
for (i; i >= 0; i--) {
if (source[i] === -1) {
// 旧节点数组中不存在该元素,直接进行挂载
const pos = i + newStart;
const newVNode = newChildren[pos];
const nextPos = pos + 1;
const anchor =
nextPos < newChildren.length ? newChildren[nextPos].el : null;
patch(null, newVNode, container, parentComponent);
hostInsert(newVNode.el, container, anchor);
} else if (i !== seq[s]) {
// 当前新节点不属于递增子序列的部分,所以该节点需要进行移动
const pos = i + newStart;
const newVNode = newChildren[pos];
const nextPos = pos + 1;
// 移动到他在newChildren的后一个节点之前
const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null;
hostInsert(newVNode.el, container, anchor);
} else {
// 存在于递增子序列中,无需移动, s向前移动
s--;
}
}
}
}
}
last
需要具体实现的朋友可以看我的mini-vue3项目~( github.com/4noth1ng/my… )