一,前言
上篇,diff 算法-比对优化(上),主要涉及以下几个点:
- 介绍了如何对儿子节点进行比对;
- 新老儿子节点可能存在的 3 种情况及代码实现;
- 新老节点都有儿子时,diff 的方案介绍与处理逻辑分析;
本篇,diff 算法-比对优化(下)
二,比对优化
1,前文回顾
上一篇提到,新老儿子节点比对可能存在的 3 种情况及对应的处理方法:
-
情况 1:老的有儿子,新的没有儿子
处理方法:直接将多余的老
dom元素删除即可; -
情况 2:老的没有儿子,新的有儿子
处理方法:直接将新的儿子节点放入对应的老节点中即可;
-
情况 3:新老都有儿子
处理方法:执行
diff比对,即:乱序比对;
针对情况 3 新老儿子节点的比对,采用了“头尾双指针”的方法,如图所示:
优先对新老儿子节点的“头头、尾尾、头尾、尾头”节点进行比对,若均未能命中,最后再执行乱序比对;
2,节点比对的结束条件
结束条件:直至新老节点一方遍历完成,比对才结束;
即:"老的头指针和尾指针重合"或"新的头指针和尾指针重合";
此时,由于发生"老的头指针和尾指针重合",比对结束,图上状态便是循环中的最后一次比对;
新老节点比对完成后,可复用节点已识别完成,老节点中A、B、C、D节点被复用;将新增节点E添加到老的节点中即可;
将上述逻辑框架转化为代码实现,如下:
// src/vdom/patch.js
/**
* 新老都有儿子时,执行乱序比对,即 diff 算法的核心逻辑
* 备注:采用头尾双指针的方式;对头头、尾尾、头尾、尾头 4 种特殊情况做优化;
*
* @param {*} el
* @param {*} oldChildren 老的儿子节点
* @param {*} newChildren 新的儿子节点
*/
function updateChildren(el, oldChildren, newChildren) {
// 声明头尾指针(老)
let oldStartIndex = 0;
let oldStartVnode = oldChildren[0];
let oldEndIndex = oldChildren.length - 1;
let oldEndVnode = oldChildren[oldEndIndex];
// 声明头尾指针(新)
let newStartIndex = 0;
let newStartVnode = newChildren[0];
let newEndIndex = newChildren.length - 1;
let newEndVnode = newChildren[newEndIndex];
// while 循环的中止条件:新老其中一方遍历完成即为结束;
// 即"老的头指针和尾指针重合"或"新的头指针和尾指针重合"
while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex){
// 1,优先做4种特殊情况比对:头头、尾尾、头尾、尾头
// 2,若未能命中,则采用 diff 乱序比对
// 3,比对完成后移动指针,继续下一轮比对,直至比对完成
}
// 比对完成后,继续处理剩余节点...
}
备注:由于
diff算法采用了while循环进行处理,所以复杂度为O(n);
3,情况 1:新儿子比老儿子多,插入新增的
情况 1:新儿子比老儿子多,又分为“从头部开始移动指针”和“从尾部部开始移动指针”两种情况;
从头部开始移动指针
头头比对:
第一次匹配,匹配后移动新老头指针:
第二次匹配,匹配后移动新老头指针:
通过多次比对后,直至老节点的头尾指针发生重合,此时,D节点就是while循环的最后一次比对:
本次比对完成之后,指针会继续向后移动一次,将导致老节点的头指针越过尾指针,此时,while循环结束;
while循环结束时的指针状态如下:
此时,新节点的头指针指向的节点E为新增节点,后面可能还存在F、G、H等其它新增节点,需要将它们(即从newStartIndex到newEndIndex之间的所有节点),全部添加到老节点的儿子集合中;
代码实现:
while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex){
// 头头比对:
if(isSameVnode(oldStartVnode, newStartVnode)){
// ******* 1,比对新老虚拟节点 *******
// isSameVnode 只能判断标签和 key 一样,但属性可能还有不同;
// 因此,继续调用 patch 方法,对新老虚拟节点中的属性做递归更新操作;
patch(oldStartVnode, newStartVnode);
// ******* 2,比对完成后,更新指针和节点位置 *******
oldStartVnode = oldStartVnode[++oldStartIndex]; // 更新老的头指针和头节点
newStartVnode = newStartVnode[++newStartIndex]; // 更新新的头指针和头节点
}
}
// 新的节点有多余时,追加到 dom 中
if(newStartIndex <= newEndIndex){
// 遍历多余节点:新的开始指针和新的结束指针之间的节点
for(let i = newStartIndex; i <= newEndIndex; i++){
// 获取虚拟节点并生成真实节点,添加到 dom 中
el.appendChild(createElm(newChildren[i]))
}
}
isSameVnode方法,只能判断标签和 key 是否完全一样,无法判断属性变化;因此,需要继续通过 patch 方法,对新老虚拟节点中的属性进行递归更新操作;
测试效果:对复用节点做属性更新,并添加新节点
let render1 = compileToFunction(`<div>
<li key="A">A</li>
<li key="B">B</li>
<li key="C">C</li>
<li key="D">D</li>
</div>`);
// 对复用节点做属性更新,并添加新节点
let render2 = compileToFunction(`<div>
<li key="A" style="color:red">A</li>
<li key="B" style="color:blue">B</li>
<li key="C" style="color:yellow">C</li>
<li key="D" style="color:pink">D</li>
<li key="E">E</li>
<li key="F">F</li>
</div>`);
更新前:
更新后:
执行结果:A、B、C、D节点被复用并更新了样式属性,且继续添加了新节点;
即:尽可能复用原有节点,仅更新需要更新的部分;
问题:
- 将新儿子中的新增节点直接追加到老儿子集合中,使用
appendChild即可;- 但是,如果新增的节点在头部位置,就不能用
appendChild了,看下面的尾尾比对分析;
从尾部开始移动指针
尾尾比对:
尾指针向前移动,当老节点的头尾指针重合,即while循环的最后一次比对:
比对完成指针向前移动后,循环结束时的指针状态如下:
while比对完成后,需要将剩余新节点(E、F)添加到老儿子中的对应位置(当前应添加到老儿子集合的头部)
问题:如何向头部位置新增节点
问题:如何将新增节点E、F放置到A前面?
分析:
- 首先,想要添加到
A节点的前面,就不能再使用appendChild做向后追加操作了; - 前面的代码是指“从新的头指针到新的尾指针”这一区间的节点,即
for (let i = newStartIndex; i <= newEndIndex; i++)所以,从处理顺序上,是先处理E节点,再处理F节点
先处理E节点:将E节点放置到A节点前的位置:
再处理F节点:将F节点插入到A节点与E节点之间的位置:
这样,当新增区域的头尾指针重合,即为最后一次比对;
方案设计:两种比对方式的合并处理
新增的节点的两种情况:有可能被追加到后面,也有可能被插入到前面:
- 头头比较时,将新增节点追加到老儿子集合的尾部;
- 尾尾比较时,将新增加点添加到老儿子集合的头部;
综合以上两种情况,如何确定向前 or 向后添加节点呢?
这取决于while循环结束时,新儿子集合的尾指针newChildren[newEndIndex + 1]上是否存在节点:
- 如果无节点:说明是从头向尾进行比对的,新增节点需要被追加到老儿子集合后面,使用
appendChild直接追加即可; - 如果有节点:说明是从尾向头进行比对的,新增节点需要被添加到老儿子集合前面,使用
insertBefore插入指定到位置;
以上分析对应的代码实现,如下:
// 1,新的多(以新指针为参照),插入新增节点
if (newStartIndex <= newEndIndex) {
// ****** 先获取参照物 ******
// 判断当前尾节点的下一个元素是否存在:
// 1,如果存在:说明是尾尾比对,插入到当前尾节点下一个元素前面;
// 2,如果不存在(下一个是 null):说明是头头比对,追加即可
// 取参考节点 anchor:决定新节点放置到前边还是后边
// 逻辑:取 newChildren 的尾部 +1,判断是否为 null
// 解释:若有值则说明是向前移动,取出当前虚拟元素的真实节点 el,并将新节点添加到此真实节点之前
// (由于是向前移动比对,故此虚拟元素在前一次比对中,已经复用了老节点 el,所以直接取新的虚拟节点上的 el 即可)
let anchor = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].el
// ****** 再根据参照物进行处理 ******
// 遍历多出的节点:新的开始指针和新的结束指针之间的节点
for (let i = newStartIndex; i <= newEndIndex; i++) {
// 获取对应的虚拟节点,并生成真实节点,添加到 dom 中
// el.appendChild(createElm(newChildren[i]))
// 逻辑合并:将 appendChild 更换为 insertBefore 处理
// 效果:既有 appendChild 又有 insertBefore 功能,直接放入参考节点即可;
// 解释:对于 insertBefore 方法,如果 anchor=null,就等同于appendChild;如果有值,则是 insertBefore;
el.insertBefore(createElm(newChildren[i]), anchor)
}
}
这里非常重要的一个思想,就是找到参考节点anchor;之后,再将新的真实节点放置于参考节点之前即可;
注意此处
insertBefore方法的妙用:
- 当第二个参数为
null时,效果等同于appendChild追加;- 当第二个参数不为
null时,效果是insertBefore插入;所以,同时具备了
appendChild追加和insertBefore插入的效果;
以上,对新节点比老节点多的两种情况,分别进行了处理;
除此之外,还可能存在老节点比新节点多的情况,那么,该如何处理呢?
4,情况 2:老儿子比新儿子多,删除多余
代码示例: 老儿子比新儿子多
let render1 = compileToFunction(`<div>
<li key="A" style="color:red">A</li>
<li key="B" style="color:blue">B</li>
<li key="C" style="color:yellow">C</li>
<li key="D" style="color:pink">D</li>
</div>`);
let render2 = compileToFunction(`<div>
<li key="A" style="color:red">A</li>
<li key="B" style="color:blue">B</li>
<li key="C" style="color:yellow">C</li>
</div>`);
如图:
老的比新的多,在移动过程中就会出现:新的已经到头了,但老的还有;
所以,当移动结束时:老的头指针会和尾指针重合,新的头指针会越过新的尾指针;
代码实现:
将老儿子集合“从头指针到尾指针”区域中,多余的真实节点删除:
// 2,老儿子比新儿子多,(以旧指针为参照)删除多余的真实节点
if(oldStartIndex <= oldEndIndex){
for(let i = oldStartIndex; i <= oldEndIndex; i++){
let child = oldChildren[i];
el.removeChild(child.el);
}
}
5,情况 3:反序情况(头尾、尾头)
反序情况:如图,新老儿子集合中的节点顺序是完全相反的;
这种情况下,可以使用“老的头指针”和“新的尾指针”进行比较,即“头尾比对”
每次比较完成后,“老的头指针”向后移动,“新的尾指针”向前移动;并在比对完成后,直接将老节点A放置到老节点集合的最后:
更确切的说,应该是插入到尾指针的下一个节点(null)之前;
(在移动前,想象尾指针指向的D节点后面,还存在着下一个节点为null)
说明:
js本身是无法做到“向一个元素之后添加一个元素”的;比如:appendChild是向最后进行追加;因此,在逻辑上,只能是先找到目标元素的下一个元素,再向下一个元素之前添加一个新的元素;
继续比对B,比对完成后继续移动指针,并移动B,插入到尾指针的下一个节点之前(这时尾指针D的下一个节点,边是上一次移动过来的A,所以B就插入到了D和A之间)
继续C和C比,比对完成后继续移动指针,并移动C,插入到尾指针的下一个节点之前(这时,尾指针D的下一个是上一次移动过来的B)
接下来继续比对D,此时,就会发现“旧的头指针”和“新的头指针”都指向了D;
这时,就比对完成了,D无需再移动,结果就是D C B A;
(整个反序过程,共移动了3 次,只对节点进行了移动操作,并没有创建新节点)
结论:对于反序操作来说,需要去比对头尾指针(老的头和新的尾),每次比对完成之后,头指针向右移动,尾指针向左移动;
代码实现,添加“头尾比较”逻辑:
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if (isSameVnode(oldStartVnode, newStartVnode)) {
patch(oldStartVnode, newStartVnode);
oldStartVnode = oldChildren[++oldStartIndex];
newStartVnode = newChildren[++newStartIndex];
}else if(isSameVnode(oldEndVnode, newEndVnode)){
patch(oldEndVnode, newEndVnode);
oldEndVnode = oldChildren[--oldEndIndex];
newEndVnode = newChildren[--newEndIndex];
// 头尾比较:老的头节点和新的尾节点做对比
}else if(isSameVnode(oldStartVnode, newEndVnode)){
// patch 方法只会 diff 比较并更新属性,但元素的位置不会变化
patch(oldStartVnode, newEndVnode); // diff:会递归比对儿子
// 移动节点:将当前的节点插入到最后一个节点的下一个节点的前面去
el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
// 指针置位--下一次循环继续处理
oldStartVnode = oldChildren[++oldStartIndex];
newEndVnode = newChildren[--newEndIndex];
}
}
注意:
由于
dom具有移动性,appendChild、insertBefore操作都会使dom产生移动效果;在做指针置位前,必须先完成节点的插入操作,之后才能移动指针,否则原来的
dom就会被移走;
测试效果:
更新前:老节点ABCD均没有样式属性;
更新后:老节点ABCD被复用,并添加了对应的样式属性;
同理,尾头比对的代码实现:
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if (isSameVnode(oldStartVnode, newStartVnode)) {
patch(oldStartVnode, newStartVnode);
oldStartVnode = oldChildren[++oldStartIndex];
newStartVnode = newChildren[++newStartIndex];
}else if(isSameVnode(oldEndVnode, newEndVnode)){
patch(oldEndVnode, newEndVnode);
oldEndVnode = oldChildren[--oldEndIndex];
newEndVnode = newChildren[--newEndIndex];
}else if(isSameVnode(oldStartVnode, newEndVnode)){
patch(oldStartVnode, newEndVnode);
el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
oldStartVnode = oldChildren[++oldStartIndex];
newEndVnode = newChildren[--newEndIndex];
// 尾头比较
}else if(isSameVnode(oldEndVnode, newStartVnode)){
patch(oldEndVnode, newStartVnode); // patch方法只会更新属性,元素的位置不会变化
// 移动节点:将老的尾节点移动到老的头节点前面去
el.insertBefore(oldEndVnode.el, oldStartVnode.el);// 将尾部插入到头部
// 移动指针
oldEndVnode = oldChildren[--oldEndIndex];
newStartVnode = newChildren[++newStartIndex];
}
}
测试效果:
let render1 = compileToFunction(`<div>
<li key="E">E</li>
<li key="A">A</li>
<li key="B">B</li>
<li key="C">C</li>
<li key="D">D</li>
</div>`);
let render2 = compileToFunction(`<div>
<li key="D" style="color:pink">D</li>
<li key="C" style="color:yellow">C</li>
<li key="B" style="color:blue">B</li>
<li key="A" style="color:red">A</li>
</div>`);
更新前:老节点ABCD均没有样式属性;
更新后:老节点ABCD被复用,并添加了对应的样式属性,老节点E被删除
三,结尾
本篇,diff 算法 - 比对优化(下),主要涉及以下几个点:
- 介绍了儿子节点比较的流程;
- 介绍并实现了头头、尾尾、头尾、尾头4种特殊情况比对;
下篇,diff算法 - 乱序比对;
维护日志
- 20210805:
- 添加了“从尾部开始移动指针”的图示
- 添加了“问题:如何向头部位置新增节点”
- 修改了部分有问题的图示
- 修改了几处语义表达不够准确的地方
- 20230219:
- 更新了文章目录结构;
- 添加了大量内容说明和代码注释,使比对过程清晰易懂;
- 添加了文章内容中的代码和关键字高亮;
- 更新了文章摘要;
- 20230221:
- 调整部分描述,使表达更加准确易懂;