从Snabbdom了解Diff算法

一、前言

闲着没事逛B站,发现了尚硅谷推出的关于Diff的课程,正好面试种也常问到,就学习了一下。这个课程主要围绕Snabbdom这个库,贴一下视频地址【Vue源码解析之虚拟DOM和diff算法】

这篇文章我会根据视频结合自己的理解对Diff过程进行讲解

关于代码还是建议看Snabbdomtypescript版本的源码,地址

首先要明确,Diff过程就是对新旧两个虚拟节点(VNode)进行比较,从而最小代价对DOM进行修补(patch)和更新

二、概述

1. 概念

  • 上树:在全文和流程图中都表示将虚拟节点转变为真实DOM节点并追加到整个DOM树中

2. 整个过程

直接都源码或者直接上手实现难免思路不清,绘制流程图是一个梳理思路很好的办法,因为在手动实现整个过程后回过头来梳理了这一过程,方便日后复习或者方便其他人学习。

首先上一个Diff整体流程图(ProcessOn画的),这个过程对应patch函数,这个函数后面介绍,接收两个参数,分别对应新旧虚拟节点

未命名文件 (1)

整个过程流程如下:

  • 首先通过isVNode判断旧节点是否是虚拟节点,如果不是则调用vnode函数将其转变为虚拟节点
  • 根据key(vue循环中的key)和sel判断两个节点是否是同一个节点
  • 如果不是同一个节点证明这个节点是新增节点,通过createElement函数创建对应DOM,移除原来节点
  • 如果是同一个节点,则深入比较两个节点的属性以及递归比较子节点(后面会深入说)

上面提到的isVNodevnodecreateElement函数都不是关键,在附录中会贴响应代码。

在课程中是搭建了一个webpack的开发环境,如果只是学习diff,个人感觉一个html,一个js就足够,将精力放在算法本身。

三、结合代码

1. patch函数

整个Diff的入口函数,主要完成上述流程图的部分,就不再多废话了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* patch函数,比较新旧节点,并更新节点
* 扮演一个类似controller的角色
* @param {Object} oldVNode
* @param {Object} newVNode
*/
export default function patch(oldVNode, newVNode) {
// 不是虚拟节点,将老节点转为虚拟节点
if (!isVNode(oldVNode)) {
oldVNode = vnode(oldVNode.tagName.toLowerCase(), {}, [], undefined, oldVNode)
}
// 同一个虚拟节点,更新
if (sameVNode(oldVNode, newVNode)) {
console.log("是同一个节点,需要递归比较每一个子节点")
patchVNode(oldVNode, newVNode);
} else {
console.log("非同一个节点,强制更新节点")
const dom = createElement(newVNode);
oldVNode.elm.parentNode.insertBefore(dom, oldVNode.elm);
// 移除原来旧节点
oldVNode.elm.remove()
}
}

2. patchVNode函数

看了上面是不是觉得diff其实也不是很难,其实核心在这里,patchVNode会具体比较两个虚拟节点,如果存在子节点,则会递归的比较子节点

进入这个函数后,就会具体比较两个虚拟节点的差异,分成四种情况(比较过程精简,只探究思路)

2.1 旧节点无子节点,新节点有子节点

需要将追加所有新节点的子节点

1
2
3
4
5
6
7
8
9
10
11
else if (oldVNode.text !== "" && oldVNode.text !== undefined) {
if (newVNode.children && newVNode.children.length > 0) {
console.log("新节点有children, 直接删除老节点内容, 递归追加新节点即可");
oldVNode.elm.innerHTML = "";
for (let i = 0; i < newVNode.children.length; i++) {
// 将虚拟节点转为dom, 追加到elm中即可
let vDom = createElement(newVNode.children[i]);
oldVNode.elm.appendChild(vDom)
}
}
}

2.2 新旧节点都没有子节点

只需要更新节点内容即可

1
2
3
4
5
6
7
8
else if (oldVNode.text !== "" && oldVNode.text !== undefined) {
else {
console.log("老节点和新节点都是文本节点")
if (oldVNode.text && newVNode.text && oldVNode.text !== newVNode.text) {
oldVNode.elm.innerHTML = newVNode.text;
}
}
}

2.3 旧节点有子节点,新节点无子节点

旧节点有子节点,新节点没有,证明节点需要被删除

1
2
3
4
5
6
7
if (oldVNode.children && oldVNode.children.length > 0) {
if ((!newVNode.children || newVNode.children.length == 0) && newVNode.text) {
console.log("新节点无children,删除老节点所有子元素")
oldVNode.elm.innerHTML = "";
oldVNode.elm.innerHTML = newVNode.text;
}
}

2.4 新旧节点都有子节点

新旧节点都有子节点,需要递归比较两个节点的所有子节点(递归的条件),这部分是难点,也是Diff的核心

这部分单独拆出来分一个章节分析

四、PatchVNode

踩过的坑

曾经尝试过自己实现这部分,因为只听说过diff是同层比较,因此对于新旧节点长度相同的部分,逐项比较并更新,对于长度不同的部分,如果新节点子节点长度更长,则超出部分追加节点;如果旧节点子节点长度更长,则超出部分删除

但是经过思考,好像没有用到key,也就没有实现复用。如果在头节点追加一个节点,因为逐项比较都不是同一个节点,都需要更新,代价是非常大的。

正统Diff思想,主要分成五部分,也可以说是四部分。会定义四个指针,四个指针分别指向新旧节点子节点的头和尾(分别称为:旧前、旧后、新前、新后),通过指针的移动完成对所有子节点的遍历过程,这个过程更符合常用操作,比如头插,尾插等。

这里为了方便操作,又定义了四个节点分别指向指针指向的节点,循环结束的条件是旧节点两指针相交或者新节点两指针相交。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
if (oldVNode.children && oldVNode.children.length > 0) {
if ((!newVNode.children || newVNode.children.length == 0) && newVNode.text) {
// 上面提到过,这里省略
} else {
console.log("新老节点都有children,需要diff");
const oldCh = oldVNode.children,
newCh = newVNode.children;
// 定义指针
let oldPreIdx = 0;
let newPreIdx = 0;
let oldLastIdx = oldCh.length - 1;
let newLastIdx = newCh.length - 1;

// 四个节点
let oldPreVNode = oldCh[oldPreIdx];
let oldLastVNode = oldCh[oldLastIdx];
let newPreVNode = newCh[newPreIdx];
let newLastVNode = newCh[newLastIdx];

// 下面是比较过程
while (oldPreIdx <= oldLastIdx && newPreIdx <= newLastIdx) {

}
}
}

比较过程如下:

4.1 旧前和新前

递归调用patchVNode对节点进行更新,并将两个头指针后移

1
2
3
4
5
6
7
if (sameVNode(oldPreVNode, newPreVNode)) {
// 新前和旧前命中, patchVNode并后移两个指针
console.log("一命中")
patchVNode(oldPreVNode, newPreVNode);
oldPreVNode = oldCh[++oldPreIdx];
newPreVNode = newCh[++newPreIdx];
}

4.2 旧后和新后

递归调用patchVNode对节点进行更新,并将两个尾指针前移

1
2
3
4
5
6
7
else if (sameVNode(oldLastVNode, newLastVNode)) {
// 新后和旧后命中, patchVNode并前移两个指针
console.log("二命中")
patchVNode(oldLastVNode, newLastVNode);
oldLastVNode = oldCh[--oldLastIdx];
newLastVNode = newCh[--newLastIdx];
}

4.3 新后和旧前

同理需要调用patchVNode函数进行更新,且新后和旧前命中,证明旧节点需要后移,移动到oldLastIdx之后

这里不太好思考的就是到底应该移动到哪里的问题

举个例子

旧节点为A、D、C、B、E,新节点为A、B、C、D、E

image-20211024182934087

比较过程如下:

①第一次命中第一种情况,两个A匹配,两个头指针后移

②第二次命中第二种情况,两个E匹配,两个尾指针前移

第三种情况命中三,旧前和新后命中,此时证明D应该向后移动。但是应该移动到哪里呢?经过观察旧前之前和旧后之后的节点都是已经处理过的元素,此时处理D,证明D应该放在后面,且在所有已经处理过节点的最前面,也就是E之前,因此可以将旧后指针作为标杆,插入到旧后节点之后,也就是旧后节点下一个节点之前

1
2
3
4
5
6
else if (sameVNode(oldPreVNode, newLastVNode)) {    
console.log("三命中");
oldPreVNode.elm.parentNode.insertBefore(oldPreVNode.elm, oldCh[oldLastIdx].elm.nextSibling);
oldPreVNode = oldCh[++oldPreIdx];
newLastVNode = newCh[--newLastIdx];
}

4.4 新前和旧后

和4.3类似,新前和旧后匹配,证明节点需要移动到旧前指针之前,所有已经处理过节点之后,不在给具体例子

1
2
3
4
5
6
7
else if (sameVNode(oldLastVNode, newPreVNode)) {
console.log("四命中");
// 新前和旧后命中, 移动到旧前节点之前
oldLastVNode.elm.parentNode.insertBefore(oldLastVNode.elm, oldCh[oldPreIdx].elm);
oldLastVNode = oldCh[--oldLastIdx];
newPreVNode = newCh[++newPreIdx];
}

4.5 都未匹配

如果四种情况都没匹配到,这个情况就比较特殊了,需要单独处理,首先针对旧节点所有未匹配的节点建立一个对象和key的哈希表(我习惯称为路由表)

1
2
3
4
5
6
7
8
9
// 在while之前定义一个对象map
// 在都没匹配到进行赋值
// 给旧节点的所有key做一个Map映射
if (!map) {
map = {};
for (let i = oldPreIdx; i <= oldLastIdx; i++) {
map[oldCh[i].key] = i;
}
}

这种情况下,每次都是处理新前指针对应的节点,也就是遵循从上到下的顺序(处理完符合DOM的顺序),然后去路由表中进行匹配。

4.5.1 没有在哈希表中匹配到

如果匹配不到证明是新增的节点,由于都为匹配到是从新前节点开始处理,先处理的节点应该位置更靠前,所以应该放到旧前之前和已处理完节点之后。

1
2
3
4
5
6
7
8
9
10
// 在哈希表中匹配
let index = map[newPreVNode.key];
if (index != undefined) {
// 4.5.2和4.5.3讨论
} else {
console.log('没有在路由表中找到匹配关系')
// 旧节点中没有匹配到, 证明是新节点, 通过createElement生成节点并追加
const node = createElement(newPreVNode);
oldLastVNode.elm.parentNode.insertBefore(node, oldPreVNode.elm);
}

4.5.2 哈希表中匹配到但是sel不同

能匹配到证明key是相同的,但是sel不同,可能是改变了标签,如从<li>换成了<p>标签的情况。

因此不能复用当前节点,需要创建新节点,并拆除之前的(拆除部分在4.6部分会处理)

插入节点的位置和4.5.1没匹配到情况一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let index = map[newPreVNode.key];
if (index != undefined) {
// 匹配成功, 继续比较sel
if (oldCh[index].sel == processedVNode.sel) {
// 4.5.3解析
} else {
// 将新节点追加到旧前之前
console.log('在路由表中找到匹配关系但sel不相同')
const node = createElement(processedVNode);
oldCh[oldPreIdx].elm.parentNode.insertBefore(node, oldCh[oldPreIdx].elm);
}
} else {
// 见4.5.1
}

4.5.3 哈希表中匹配到但且sel相同

这是最理想的一种情况,key和sel均相同,证明可以复用节点,只是节点位置不对。因此我们需要更新当前节点,并将节点移动到正确位置。

4.5.14.5.2情况类似,因为是在新节点中取得第一位没处理的节点,因此位置靠前,即旧前之前,不明确的自己举例子里模拟整个Diff过程就明白了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let index = map[newPreVNode.key];
if (index != undefined) {
// 匹配成功, 继续比较sel
if (oldCh[index].sel == processedVNode.sel) {
console.log('在路由表中找到匹配关系且sel相同')
patchVNode(oldCh[index], processedVNode);
// 移动到旧前节点之前
oldCh[index].elm.parentNode.insertBefore(oldCh[index].elm, oldCh[oldPreIdx].elm);
} else {
// 见4.5.2
}
} else {
// 见4.5.1
}

4.5.4 更新指针

在整个4.5情况下,也就是diff四种过程都没匹配的情况下,是优先处理新前指针指向的节点(新节点子节点未处理节点的第一位)。因此这部分处理完需要将新前指针向后移动,开始下一次匹配过程。

1
2
// 无论什么情况下都要移动新前指针
newPreVNode = newCh[++newPreIdx];

4.6 处理匹配结束后的情况

到这一步战斗已经接近尾声了,因此此时整个while循环已经退出了,只需要处理一些善后工作,也就是对新旧子节点的善后工作。

此时分两种情况

4.6.1 旧节点还剩余数据

循环结束后,旧节点还剩余数据,而新节点一定没有剩余数据(这两个互斥,因为循环结束的条件就是满足其中之一)

也就是说明旧节点中剩余未处理的节点就是多出来需要被删除的节点只需要获取到老节点任意节点的父节点(parentNode)然后removeChild即可

举个例子

旧节点的Key分别为A、B、C、D、E,新节点的Key为A、E,while退出是因为新节点都处理完了,旧节点还剩余BCD三个节点,因此证明这三个节点需要删除

1
2
3
for(let i=oldPreIdx; i<=oldLastIdx; i++){
oldCh[oldPreIdx].elm.parentNode.removeChild(oldCh[i].elm);
}

4.6.2 新节点还剩余数据

遍历结束后如果新节点中还剩余节点,证明是经过操作后新增的节点,直接创建对应dom并上树即可。唯一需要考虑的问题就是将这些节点插入到什么DOM树的什么位置,之前的操作都是对旧节点进行操作,因此可以以旧前节点作为标杆,插入到旧前之前,其实旧后指针的后一位也可以。

1
2
3
4
5
6
7
8
// 追加新的
if (newPreIdx <= newLastIdx) {
console.log('新节点数组中还有剩余,追加到旧前之前')
for (let i = newPreIdx; i <= newLastIdx; i++) {
// parent在while循环中实例化,可以是旧节点中任意一个节点的父节点
parent.insertBefore(createElement(newCh[i]), oldCh[oldLastIdx + 1].elm);
}
}

看了Snabbdom的实现发现是以新后指针的下一位之前,感觉不太好理解。

五、实例

这么多文字说完你可能懂了,也可能没懂,我直接举一个例子,完整分析整个Diff过程,这里只解释新旧节点都有子节点的情况。旧节点为A、B、W、C,新节点为C、E、F、B、A

比较过程如下:

5.1 第一步

image-20211026104338879

  • 比较旧前和新前,A和C不同,继续下一步比较
  • 比较旧后和新后,C和A不同,继续下一步比较
  • 比较新后与旧前,A和A相同,调用patchVNode方法更新两个节点,并将新后指针前移,旧前指针后移

5.2 第二步

image-20211026104704455

  • 比较旧前和新前,B和C不同,继续下一步比较
  • 比较旧后和新后,C和B不同,继续下一步比较
  • 比较新后于旧前,B和B相同,调用patchVNode方法更新两个节点,并将新后指针前移,旧前指针后移

5.3 第三步

image-20211026104931510

  • 比较旧前和新前,W和C不同,继续下一步比较
  • 比较旧后和新后,C和F不同,继续下一步比较
  • 比较新后和旧前,W和F不同, 继续下一步比较
  • 比较新前和旧后,C和C相同,调用patchVNode方法更新两个节点,并将旧后指针前移,新前指针后移

5.4 第四步

image-20211026105251371

此时旧前和旧后指针都指向W节点,由于还满足while条件,因此继续下一轮比较

  • 比较旧前和新前,W和E不同,继续下一步比较
  • 比较旧后和新后,W和F不同,继续下一步比较
  • 比较新后和旧前,W和F不同, 继续下一步比较
  • 比较新前和旧后,W和E不同,四次Diff都未命中,哈希表未初始化,对旧节点中未处理节点建立哈希表,{ “W” :2 } (以节点的key为哈希表的key,以指针作为哈希表的value)
  • 处理新节点未处理节点的第一位,也就是新前节点E,去哈希表中进行匹配
  • 未匹配到,证明是新增节点,调用createElement方法创建DOM并插入到旧前之前,新前指针后移,旧节点指针不动

5.5 第五步

image-20211026105841930

还满足while条件,继续比较

  • 比较旧前和新前,W和F不同,继续下一步比较
  • 比较旧后和新后,W和F不同,继续下一步比较
  • 比较新后和旧前,W和F不同, 继续下一步比较
  • 比较新前和旧后,W和F不同,四次Diff都未命中,哈希表已经初始化
  • 处理新节点未处理节点的第一位,也就是新前节点F,去哈希表中进行匹配
  • 未匹配到,证明是新增节点,调用createElement方法创建DOM并插入到旧前之前,新前指针后移,旧节点指针不动

至此,新节点中所有元素处理结束,旧节点中剩余一个W节点

5.6 第六步

image-20211026110129105

此时不满足while条件,开始进行4.6中善后工作,

  • 删除旧前指针和旧后指针之间的元素(可以看出来,这个W元素应该被删除),因此通过循环删除这个W节点
  • 新后和旧后节点之间没有元素,因此不需要新增

六、总结

至此,Diff过程完成,我测试过很多用例,都能实现,不为生产使用,只为学习,因此难免有小BUG,只需要抓住Diff的核心思想即可,至于实现上,就像vue一样,结合自己业务实现即可。

受篇幅影响,不单独贴代码,贴个地址,大家有需要可以看看。不过还是更推荐看Snabbdom的源码,在前言中贴了源码地址

Gitee地址

七、附录

6.1 isVNode函数

1
2
3
4
5
6
7
/**
* 判断元素是不是虚拟节点
* @param {Object} oldVNode
*/
export function isVNode(oldVNode) {
return oldVNode.sel !== "" && oldVNode.sel !== undefined;
}

6.2 vnode函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 返回虚拟节点
* @param {Object} sel css选择器, 可以认为是html标签
* @param {Object} data 配置项(可能含有key)
* @param {Object} children 子节点
* @param {Object} text 文本节点
* @param {Object} elm 挂载到哪一个dom节点上
*/
export default function(sel, data, children, text, elm) {
// ?.短路运算符,如果data不为空则访问data.key
const key = data?.key;
return {
sel,
data,
children,
text,
elm,
key
}
}

6.3 createElement函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 将虚拟节点转化为DOM节点
* @param {Object} vnode 新节点
*/
export default function createElement(vnode) {
let box = document.createElement(vnode.sel);
vnode.elm = box;
// 处理最简单的文本节点,递归的出口
if (vnode.text !== "" && (vnode.children == undefined || vnode.children.length == 0)) {
box.innerText = vnode.text;
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
// 如果存在子节点
for (let i = 0; i < vnode.children.length; i++) {
// 递归创建子节点
let child = createElement(vnode.children[i]);
box.appendChild(child);
}
}

return box;
}

6.4 sameVNode函数

1
2
3
4
5
6
7
8
/**
* 判断是否是VNode
* @param {Object} oldVNode
* @param {Object} newVNode
*/
export function sameVNode(oldVNode, newVNode) {
return oldVNode.key == newVNode.key && oldVNode.sel == newVNode.sel;
}