从0写一下diff算法,我是一边写代码,一边写文章,整理一下思路。
注:这里只讨论tag属性相同并且多个children的情况,
不相同的tag直接替换,删除,这没啥好写的。
用这个例子来说明
export const Hello = {
name: 'Hello',
data() {
return {
childList: ['a', 'b', 'c'],
};
},
mounted() {
setTimeout(() => {
this.childList = this.childList.reverse();
}, 1000);
},
render: function(h) {
return h('ul', {
}, this.childList.map((child) => h('li', {}, child)));
},
};
简单diff,把原有的删掉,把更新后的插入
function updateChildren (elm, prevChildren, nextChildren) {
// 删除旧节点
for (const child of prevChildren) {
elm.removeChild(child.elm);
}
// 把新的vnode插入
for (const child of nextChildren) {
createElm(child, elm); //vnode生成正式dom
}
}
变化前后的标签都是li,所以只用比对vnodeData和children即可,复用原有的DOM。
先只从这个例子出发
我只用遍历旧的vnode,然后把旧的vnode和新的vnode patch就行
function updateChildren (elm, prevChildren, nextChildren) {
for (let i = 0; i < prevChildren.length; i++) {
patchVnode(nextChildren[i], prevChildren[i]);
}
}
这样就省掉移除和新增dom的开销
现在的问题是,我的例子刚好是新旧vnode数量一样,如果不一样就有问题
示例改成这样:
// 新的children vnode增加了一个d
export const Hello1 = {
name: 'Hello1',
data() {
return {
childList: ['a', 'b', 'c'],
};
},
mounted() {
setTimeout(() => {
this.childList.reverse().push('d');
}, 1000);
},
render: function(h) {
return h('ul', {
}, this.childList.map((child) => h('li', {}, child)));
},
};
// 新的的children vnode删除了一个元素
export const Hello2 = {
name: 'Hello2',
data() {
return {
childList: ['a', 'b', 'c'],
};
},
mounted() {
setTimeout(() => {
this.childList.reverse().shift();
}, 1000);
},
render: function(h) {
return h('ul', {
}, this.childList.map((child) => h('li', {}, child)));
},
};
实现思路改成:先看看是旧的长度长,还是新的长,如果旧的长,我就遍历新的,然后把多出来的旧节点删掉,如果新的长,我就遍历旧的,然后多出来的新vnode加上。
function updateChildren (elm, prevChildren, nextChildren) {
const oldLen = prevChildren.length;
const newLen = nextChildren.length;
const commonLen = oldLen > newLen ? newLen : oldLen;
for (let i; i < commonLen.length; i++) {
patchVnode(nextChildren[i], prevChildren[i]);
}
if (oldLen < newLen) {
createElm(child, [], elm);
}
} else {
for (const child of prevChildren.slice(oldLen - (oldLen - newLen))) {
elm.removeChild(child.elm);
}
}
}
仍然有可优化的空间,还是下面这幅图
通过我们上面的diff算法,实现的过程会比对 preve vnode和next vnode,标签相同,则只用比对vnodedata和children。发现
怎么移动?我们使用key来将新旧vnode做一次映射。
首先我们找到可以复用的vnode
可以做两次遍历,外层遍历next vnode,内层遍历prev vnode
for (let i = 0; i < nextChildren.length; i++) {
const nextVnode = nextChildren[i];
for (let j = 0; j < prevChildren.length; j++) {
const prevVnode = prevChildren[j];
if (nextVnode.key === prevVnode.key) {
// 说明可以复用
patchVnode(nextVnode, prevVnode);
}
}
}
如果next vnode和prev vnode只是位置移动,vnodedata和children没有任何变动,调用patchVnode之后不会有任何dom操作。
接下来只需要把这个key相同的vnode移动到正确的位置即可。我们的问题变成了怎么移动。
首先需要知道两个事情:
- 每一个prev vnode都引用了一个真实dom节点,每个next vnode这个时候都没有真实dom节点。
- 调用patchVnode的时候会把prevVnode引用的真实Dom的引用赋值给nextVnode,就像这样
const elm = vnode.elm = oldVnode.elm;
还是拿上面的例子,外层遍历next vnode,
遍历第一个元素的时候, 第一个vnode是li(c),然后去prev vnode里找,在最后一个节点找到了,这里外层是第一个元素,不做任何移动的操作,我们记录一下这个vnode在prevVnode中的索引位置lastIndex,接下来在遍历的时候,如果j
也就是说,只把prevVnode中后面的元素往前移动,原本顺序是正确的就不变。
现在我们的diff的代码变成了这样
function updateChildren (elm, prevChildren, nextChildren) {
let lastIndex = 0;
for (let i = 0; i < nextChildren.length; i++) {
const nextVnode = nextChildren[i];
console.log('nextVnode: ', nextVnode);
for (let j = 0; j < prevChildren.length; j++) {
const prevVnode = prevChildren[j];
if (nextVnode.key === prevVnode.key) {
patchVnode(nextVnode, prevVnode);
if (j < lastIndex) {
elm.insertBefore(prevVnode.elm, nextChildren[i-1].elm.nextSibling);
} else {
lastIndex = j;
}
}
}
}
}
同样的问题,如果新旧vnode的元素数量一样,那就已经可以工作了。接下来要做的就是新增节点和删除节点。
新增节点:
整个框架中将vnode挂载到真实dom上都调用patch函数,patch里调用createElm来生成真实dom。按照上面的实现,如果nextVnode中有一个节点是prevVnode中没有的,就有问题
在prevVnode中找不到li(d),那我们需要调用createElm挂在这个新的节点,因为这里的节点需要超入到li(b)和li(c)之间,所以需要用insertBefore()。在每次遍历nextVnode的时候用一个变量find=false表示是否能够在prevVnode中找到节点,如果找到了就find=true。如果内层遍历后find是false,那说明这是一个新的节点
function updateChildren (elm, prevChildren, nextChildren) {
let lastIndex = 0;
for (let i = 0; i < nextChildren.length; i++) {
let find = false;
const nextVnode = nextChildren[i];
for (let j = 0; j < prevChildren.length; j++) {
const prevVnode = prevChildren[j];
if (nextVnode.key === prevVnode.key) {
find = true;
patchVnode(nextVnode, prevVnode);
if (j < lastIndex) {
elm.insertBefore(prevVnode.elm, nextChildren[i-1].elm.nextSibling);
} else {
lastIndex = j;
}
}
}
if (!find) {
createElm(nextChildren[i], elm, nextChildren[i-1].elm.nextSibling);
}
}
}
我们的createElm函数需要判断一下第四个参数,如果没有就是用appendChild直接把元素放到父节点的最后,如果有第四个参数,则需要调用insertBefore来插入到正确的位置。
接下来要做的是删除prevVnode多余节点:
在nextVnode中已经没有li(d)了,我们需要在执行完上面所讲的所有流程后在遍历一次prevVnode,然后拿到nextVnode里去找,如果找不到相同key的节点,那就说明这个节点已经被删除了,我们直接用removeChild方法删除Dom
function updateChildren (elm, prevChildren, nextChildren) {
let lastIndex = 0;
for (let i = 0; i < nextChildren.length; i++) {
let find = false;
const nextVnode = nextChildren[i];
for (let j = 0; j < prevChildren.length; j++) {
const prevVnode = prevChildren[j];
if (nextVnode.key === prevVnode.key) {
find = true;
patchVnode(nextVnode, prevVnode);
if (j < lastIndex) {
elm.insertBefore(prevVnode.elm, nextChildren[i-1].elm.nextSibling);
} else {
lastIndex = j;
}
}
}
if (!find) {
createElm(nextChildren[i], [], elm, nextChildren[i-1].elm.nextSibling);
}
}
for (const vnode of prevChildren) {
const has = nextChildren.find(child => child.key === vnode.key);
if (!has) {
elm.removeChild(vnode.elm);
}
}
}
完整的代码:https://github.com/TingYinHelen/tempo/blob/main/src/platforms/web/patch.js
在react-diff分支(目前有可能代码仓库还没有开源,等我实现更完善的时候会开源出来,项目结构可能有变化,看tempo仓库就行)
这里我的代码实现的diff算法很明显看出来时间复杂度是O(n2)。那么这里在算法上依然又可以优化的空间,这里我把nextChildren和prevChildren都设计成了数组的类型,这里可以把nextChildren、prevChildren设计成对象类型,用户传入的key作为对象的key,把vnode作为对象的value,这样就可以只循环nextChildren,然后通过prevChildren[key]的方式找到prevChidren中可复用的dom。这样就可以把时间复杂度降到O(n)。
以上就是react的diff算法的实现
下面来讲一下vue2的diff算法
先说一下上面代码的问题,举个例子,下面这个情况
如果按照react的方法,整个过程会移动2次: 但是通过肉眼来看,其实只用把li(c)移动到第一个就行,只需要移动1一次。 首先找到四个节点vnode:prev的第一个,next的第一个,prev的最后一个,next的最后一个,然后分别把这四个节点作比对:1. 把prev的第一个节点和next的第一个比对;2. 把prev的最后一个和next的最后一个比对;3.prev的第一个和next的最后一个;4. next的第一个和prev的最后一个。如果找到相同key的vnode,就做移动,移动后把前面的指针往后移动,后面的指针往前移动,直到前后的指针重合,如果key不相同就只patch更新vnodedata和children。下面来走一下流程 li(d)和li(d),key相同,先patch,需要移动移动,移动的方法就是把prev的li(d)移动到li(a)的前面。然后移动指针,因为prev的最后一个做了移动,所以把prev的指向后面的指针往前移动一个,因为next的第一个vnode已经找到了对应的dom,所以next的前面的指针往后移动一个。现在比对的图变成了下面这样: 这个时候的真实DOM 继续比对 li(c)和li(c),相同相同,先patch,因为next的最后一个元素也刚好是prev的最后一个,所以不移动,prev和next都往前移动指针 现在最新的比对图: li(a) 和li (a),key相同,patch,把prev的li(a)移动到next的后面指针的元素的后面 比对的图变成这样 继续比对: 这就完成了常规的比对,还有不常规的,如下图: 经过1,2,3,4次比对后发现,没有相同的key值能够移动。 接下来就是新增节点 这种排列方法,按照上面的方法,经过1,2,3,4比对后找不到相同key,然后然后用newStartIndex到老的vnode中去找,仍然找不着,这个时候说明是一个新节点,把它插入到oldStartIndex前面 最后一步是移除多余dom 也就是说当newStartIndex > newEndIndex的时候就说明有dom需要删除,删除的就是oldStartIndex 到 oldEndIndex。 完整代码 下面来讲vue3的diff算法 通过上图可以看出,前面a是相同的,后面e,f是相同的,去除前后相同的我们只用把中间新增的vnode insert进来就行。在预处理阶段需要4个指针,分别指向prev的第一个和next的第一个,在前缀比较的时候,如果key相同,则只patch,然后把这两个指针依次向后移动。同样需要两个指针分别指向prev和next的最后一个vnode,如果两个vnode key相同,只patch,指针向前移动。 前缀a相同,j++,j=1的时候prev的元素是e,next的c这时key不同,这个循环结束。 接着循环遍历后缀,第一次prevEnd=2,nextEnd=3相同,指针向前移动,prevEnd=1,nextEnd=2,key还是相同,指向向前移动,prevEnd=0,nextEnd=1,key不同,循环结束 当prevEnd < j,说明next有元素需要新增,当nextEnd == j,说明next的j需要被新增 来看一下删除 首先遍历前缀,a的key相同,j++,j=1,b和c的key不同,前缀循环结束。后缀c的key相同,向前移动指针,这个时候的图变成这样 如果j>nextEnd,就说明有元素需要删除 还有一种情况,如果在第一次循环的阶段,j大于了nextEnd,那说明nextChildren整个都已经patch完,就可以不用在家进行后缀的遍历,如果j大于了prevEnd,说明prevChildren整个都patch完成,也不用在继续第二次循环;同样,在第二次循环的时候,有上面说的情况也不用再继续执行。出于性能考虑,我们应该避免没有必要的代码执行。 以上讨论的是预处理时新增和删除的特殊情况。大多数情况并不能在预处理就结束比对,所以下面来讨论常规情况。 看一下下面这种情况 通过预处理之后,a和e已经被比对,这个时候j 这个source数组的作用是用来存储,新的children中的元素在老的chidren中的位置: k代表的是在新的children中,遇到的节点的位置索引,用一个pos变量用来存储当遇到key相同的vnode的时候,遇到的索引的最大值,一旦发现后面遇到的索引值比之前的要小,则说明需要移动,这时我们用一个变量move来记录是否需要移动move=true,如果后面遇到的k比pos更大,则把k赋值给pos。这里的思路和react类似。 Index Map中的键是节点的key,值是节点是在新的children中的位置索引,由于数据结构带来的优势,使我们可以快速的定位旧的children中的节点在新的children中的位置。 现在的时间复杂度就是O(n)了。接下来可以做操作Dom的操作了。 接下来做移动操作 首先要做的是根据source计算一个最长递增子序列。 什么是最长递增子序列: 我们调用lis函数后,求出数组source的最长递增子序列是[0,1]。注:这里lis函数求的是source中最长递增子序列的索引值。 [0,1]告诉我们nextChildren的未处理节点中,位于位置0和位置1的节点,与他们在prevChildren中的先后顺序是没变的,在位置0 和位置1的节点是不需要移动的。 i和j分别指向新的children中剩余未处理节点的最后一个节点,和最长子序列的的最后一个,并从后往前遍历。 i的值的范围是0到nextLeft-1,等价于我们队剩余节点重新编号,接着看当前节点的索引是否与子序列中j相等。同时还需要注意source[i]是否为-1,是-1的节点需要新增。
li(c)是第一个节点,不需要移动,lastIndex=2
li(b), j=1, j
于是vue2这么来设计的
这个时候真实DOM:
继续比对
真实的DOM变成了这样
li(b)和li(b)的key相同,patch,都是前指针相同所以不移动,移动指针
这个时候前指针就在后指针后面了,这个比对就结束了。function updateChildren (elm, prevChildren, nextChildren) {
let oldStartIndex = 0;
let oldEndIndex = prevChildren.length - 1;
let newStartIndex = 0;
let newEndIndex = nextChildren.length - 1;
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
const oldStartVnode = prevChildren[oldStartIndex];
const oldEndVnode = prevChildren[oldEndIndex];
const newStartVnode = nextChildren[newStartIndex];
const newEndVnode = nextChildren[newEndIndex];
if (oldStartVnode.key === newStartVnode.key) {
patchVnode(newStartVnode, oldStartVnode);
oldStartIndex++;
newStartIndex++;
} else if (oldEndVnode.key === newEndVnode.key) {
patchVnode(newEndVnode, oldEndVnode);
oldEndIndex--;
newEndIndex--;
} else if (oldStartVnode.key === newEndVnode.key) {
patchVnode(newEndVnode, oldStartVnode);
elm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
newEndIndex--;
oldStartIndex++;
} else if (oldEndVnode.key === newStartVnode.key) {
patchVnode(newStartVnode, oldEndVnode);
elm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndIndex--;
newStartIndex++;
}
}
}
这种情况我们没有办法,只有用老办法,用newStartIndex的key拿去依次到prev里的vnode,直到找到相同key值的老的vnode,先patch,然后获取真实dom移动到正确的位置(放到oldStartIndex前面),然后在prevChildren中把移动过后的vnode设置为undefined,在下次指针移动到这里的时候直接跳过,并且next的start指针向右移动。function updateChildren (elm, prevChildren, nextChildren) {
let oldStartIndex = 0;
let oldEndIndex = prevChildren.length - 1;
let newStartIndex = 0;
let newEndIndex = nextChildren.length - 1;
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
let oldStartVnode = prevChildren[oldStartIndex];
let oldEndVnode = prevChildren[oldEndIndex];
let newStartVnode = nextChildren[newStartIndex];
let newEndVnode = nextChildren[newEndIndex];
if (oldStartVnode === undefined) {
oldStartVnode = prevChildren[++oldStartIndex];
}
if (oldEndVnode === undefined) {
oldEndVnode = prevChildren[--oldEndIndex];
}
if (oldStartVnode.key === newStartVnode.key) {
patchVnode(newStartVnode, oldStartVnode);
oldStartIndex++;
newStartIndex++;
} else if (oldEndVnode.key === newEndVnode.key) {
patchVnode(newEndVnode, oldEndVnode);
oldEndIndex--;
newEndIndex--;
} else if (oldStartVnode.key === newEndVnode.key) {
patchVnode(newEndVnode, oldStartVnode);
elm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
newEndIndex--;
oldStartIndex++;
} else if (oldEndVnode.key === newStartVnode.key) {
patchVnode(newStartVnode, oldEndVnode);
elm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndIndex--;
newStartIndex++;
} else {
const idxInOld = prevChildren.findIndex(child => child.key === newStartVnode.key);
if (idxInOld >= 0) {
elm.insertBefore(prevChildren[idxInOld].elm, oldStartVnode.elm);
prevChildren[idxInOld] = undefined;
newStartIndex++;
}
}
}
}
const idxInOld = prevChildren.findIndex(child => child.key === newStartVnode.key);
if (idxInOld >= 0) {
elm.insertBefore(prevChildren[idxInOld].elm, oldStartVnode.elm);
prevChildren[idxInOld] = undefined;
} else {
createElm(newStartVnode, [], elm, oldStartVnode.elm);
}
newStartIndex++;
还是按照原来步骤进行比对
if (oldEndIndex < oldStartIndex) {
for (let i = newStartIndex; i <= newEndIndex; i++) {
createElm(nextChildren[i], elm, oldStartVnode.elm);
}
} else if (newEndIndex < newStartIndex) {
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
elm.removeChild(prevChildren[i].elm);
}
}
完整的代码:https://github.com/TingYinHelen/tempo/blob/main/src/platforms/web/patch.js
vue2-diff分支
vue3首先对chidren做了预处理,预处理其实就是去除相同的前缀和后缀,之比较不相同的部分
例如字符串:
abcd
abed
去除前面相同部分和后面相同部分,剩下不同的部分就是
c
e
应用到我们的children上也是同样的,我们先实现最简单情况
我们需要3个变量:j(指向前缀,因为prev和next前缀比较的时候索引值相同,所以只需要一个变量),prevEnd(指向prev的后缀),nextEnd(指向next的后缀)。
function updateChildren (elm, prevChildren, nextChildren) {
let j = 0;
let prevEnd = prevChildren.length - 1;
let nextEnd = nextChildren.length - 1;
while (prevChildren[j].key === nextChildren[j].key) {
patchVnode(nextChildren[j], prevChildren[j]);
j++;
}
while (prevChildren[prevEnd].key === nextChildren[nextEnd].key) {
patchVnode(nextChildren[nextEnd--], prevChildren[prevEnd--]);
}
if (prevEnd < j || nextEnd === j) {
while (j <= nextEnd) {
createElm (nextChildren[j--], elm, prevChildren[prevEnd + 1].elm);
}
} else if (j > nextEnd) {
while (j <= prevEnd) {
elm.removeChild(prevChildren[j++].elm);
}
}
}
outer: {
while (prevChildren[j].key === nextChildren[j].key) {
patchVnode(nextChildren[j], prevChildren[j]);
j++;
if (j > nextEnd || j > prevEnd) {
break outer;
}
}
while (prevChildren[prevEnd].key === nextChildren[nextEnd].key) {
patchVnode(nextChildren[nextEnd--], prevChildren[prevEnd--]);
if (j > nextEnd || j > prevEnd) {
break outer;
}
}
}
通过上面的分析,可以知道无论什么框架,diff算法的比对步骤是:先看有哪些vnode需要移动,然后考虑怎么移动,最后考虑新增和删除的情况。
const prevStart = j;
const nextStart = j;
// 遍历旧的children
for (let i = prevStart; i <= prevEnd; i++) {
const prevVnode = prevChildren[i];
// 遍历新的children
for (let k = nextStart; k <= nextEnd; k++) {
const nextVnode = nextChildren[k];
// 找到拥有相同key值的vnode
if (prevVnode.key === nextVnode.key) {
// patch更新
patch(nextVnode, prevVnode);
source[k - nextStart] = i;
}
}
}
这里之前已经说过,这里的时间复杂度是O(n2)。这里来优化一下
我们可以为新的children节点,构建一个key到位置索引索引表。
const prevStart = j;
const nextStart = j;
let pos = 0;
let move = false;
const keyIndex = {};
for (let i = nextStart; i < nextStart; i++) {
keyIndex[nextChildren[i].key] = i;
}
// 遍历旧的children的剩余未处理节点
for (let i = prevStart; i <= prevEnd; i++) {
const prevVnode = prevChildren[i];
// 通过索引表快速找到新的children中key值相等的vnode的位置
const k = keyIndex[prevVnode.key]
if (k !== undefined) {
patch(nextChildren[k], prevVnode);
// 更新source数组
source[k - nextStart] = i;
if (pos > k) {
move = true;
} else {
pos = k;
}
} else {
// 删除节点
}
}
上面的逻辑中如果k是undefined,这里我们用prevChildren中的vnode在新的children中找,找不着,那么就说明这是一个多余的vnode,需要删除 const prevStart = j;
const nextStart = j;
let pos = 0;
let move = false;
const keyIndex = {};
for (let i = nextStart; i < nextStart; i++) {
keyIndex[nextChildren[i].key] = i;
}
// 遍历旧的children的剩余未处理节点
for (let i = prevStart; i <= prevEnd; i++) {
const prevVnode = prevChildren[i];
// 通过索引表快速找到新的children中key值相等的vnode的位置
const k = keyIndex[prevVnode.key]
if (k !== undefined) {
patch(nextChildren[k], prevVnode);
// 更新source数组
source[k - nextStart] = i;
if (pos > k) {
move = true;
} else {
pos = k;
}
} else {
elm.removeChild(prevChildren[i]);
}
}
if (move) {
...
}
if (move) {
const seq = lis(source) //
}
给定一个数值序列,找到他的一个子序列,并且子序列中的值是递增的,子序列中的元素在原序列中不一定连续。
比如给定序列:[0,8,4,12]
那么他的最长递增子序列:[0,8,12]
当然答案可能有多种:[0,4,12]
if (move) {
const seq = lis(source);
// j指向最长递增子序列的最后一个
let j = seq.length - 1;
// 从后往前遍历新children中的剩余未处理节点
for (let i = nextLeft - 1; i > 0; i--) {
if (i !== seq[j]) {
// 移动
} else {
j--;
}
}
}
if (move) {
const seq = lis(source);
// j指向最长递增子序列的最后一个
let j = seq.length - 1;
// 从后往前遍历新children中的剩余未处理节点
for (let i = nextLeft - 1; i > 0; i--) {
if (source[i] === -1) {
// 该节点在新 children 中的真实位置索引
const pos = i + nextStart;
const nextVNode = nextChildren[pos]
const nextPos = pos + 1;
createElm(nextVNode, [], elm, nextChildren[nextPos].elm);
} else if (i !== seq[j]) {
// 该节点在新 children 中的真实位置索引
const pos = i + nextStart;
const nextVNode = nextChildren[pos];
// 该节点下一个节点的位置索引
const nextPos = pos + 1;
// 移动
elm.insertBefore(
nextVNode.el,
nextPos < nextChildren.length
? nextChildren[nextPos].elm
: null
)
} else {
j--;
}
}
}