双端比较的原理 & 双端比较的优势
双端diff算法: 同时对子节点的两个端点(oldStartIdx和oldEndIdx、newStartIdx和newEndIdx)进行比较.
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string'){
// ...
} else if (Array.isArray(n2.children)){
// ...
} else {
// ...
}
}
function patchKeyedChildren(n1, n2, container){
const oldC = n1.children, newC = n2.children;
// 定义四个端点
let oldStartIdx = 0,
oldEndIdx = oldC.length - 1,
newStartIdx = 0,
newEndIdx = newC.length - 1;
let oldStartVnode = oldC[oldStartIdx],
oldEndVnode = oldC[oldEndIdx],
newStartVnode = newC[newStartIdx],
newEndVnode = newC[newEndIdx];
// 开始进行比较
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
// 比较 新子节点中的头节点 与 旧子节点中的头节点
if(oldStartVnode.key === newStartVnode.key){
patch(oldStartVnode, newStartVnode, container)
oldStartVnode = oldC[++oldStartIdx]
newStartVnode = newC[++newStartIdx]
// 比较 新子节点中的尾节点 与 旧子节点中的尾节点
} else if (oldEndVnode.key === newEndVnode.key){
patch(oldStartVnode, newEndVnode, container)
oldEndVnode = oldC[--oldEndIdx]
newEndVnode = newC[--newEndIdx]
// 比较 新子节点中的尾节点 与 旧子节点中的头节点
} else if(oldStartVnode.key === newEndVnode.key){
patch(oldStartVnode, newEndVnode, container)
insert(oldStartVnode.el, container, oldEndVnode.el.nextSibling)
oldStartVnode = oldC[++oldStartIdx]
newEndVnode = newC[--newEndIdx]
// 比较 新子节点中的头节点 与 旧子节点中的尾节点
} else if(oldEndVnode.key === newStartVnode.key){
patch(oldEndVnode, newStartVnode, container)
insert(oldEndVnode.el, container, oldStartVnode.el)
oldEndVnode = oldC[--oldEndIdx]
newStartVnode = newC[++newStartIdx]
}
}
}
简单diff算法的问题在于对dom的移动操作不是最优的.
| 新子节点 | 旧子节点 | 索引 |
|---|---|---|
| p-3 | p-1 | 0 |
| p-1 | p-2 | 1 |
| p-2 | p-3 | 2 |
简单diff算法需要对p-1和p-2移动两次才能完成. 实际上我们只需要移动一次p-3即可完成更新.
- 比较
oldStartVnodep-1与newStartVnodep-3, 两者key不同无法复用 - 比较
oldEndVnodep-3与newEndVnodep-2, 两者key不同无法复用 - 比较
oldStartVnodep-1与newEndVnodep-2, 两者key不同无法复用 - 比较
oldEndVnodep-3与newStartVnodep-3, 两者key相同可以复用 p-3原本处于末尾, 在新子节点中是第一位, 移动其至首位.- 此时
oldStartIdx = 0; oldEndIdx = 1; newStartIdx = 1; newEndIdx = 2 - 比较
oldStartVnodep-1和newStartVnodep-1(newStartInd = 1), 两者key相同可以复用. 但都处于头部无需移动, 打补丁即可. - 此时
oldStartIdx = 1; oldEndIdx = 1; newStartIdx = 2; newEndIdx = 2 - 比较
oldStartVnodep-2和newStartVnodep-2, 两者key相同可以复用. 但都处于头部, 打补丁即可. - 此时
oldStartIdx = 2; oldEndIdx = 1; newStartIdx = 3; newEndIdx = 2 oldStartInd > oldEndInd&&newStartInd > newEndInd跳出while循环, 更新结束.
整个过程只移动一次p-3
非理性状况的处理方式
| 新子节点 | 旧子节点 | 索引 |
|---|---|---|
| p-2 | p-1 | 0 |
| p-4 | p-2 | 1 |
| p-1 | p-3 | 2 |
| p-3 | p-4 | 3 |
- 比较
p-1与p-2、p-4与p-3、p-1与p-3、p-4与p-2均无法复用 - 接下来尝试寻找旧子节点中有无key值与
newStartVnodep-2相同的节点 - 找到了旧子节点中可复用的
p-2, 记录idxInOld = 1. 调用patch并移动至oldStartVnodep-1前面完成更新. - 此时
oldC[idxInOld] = undefined(对应的真实dom已经移动至它处),newStartIdx = 1. 真实dom顺序为p-2、p-1、p-3、p-4. - 比较
p-1与p-4、p-4与p-3、p-1与p-3、p-4与p-4, key值相同可以复用 - 把旧节点中可复用的
p-4, 移动至oldStartVnodep-1前面, 此时newStartIdx = 2; oldEndIdx = 2. 真实dom顺序为p-2、p-4、p-1、p-3. - 比较
p-1与p-1, 两者key相同可以复用. 但都处于头部无需移动. 此时oldStartIdx = 1; newStartIdx = 3 - 发现
oldStartVnode为空, 说明已经处理过. 直接跳过, 此时oldStartIdx = 2 - 比较
p-3与p-3, 两者key相同可以复用. 但都处于头部无需移动. 此时oldStartIdx = 3; newStartIdx = 4 - 由于
oldStartIdx(3) > oldEndIdx(2) && newStartIdx(4) > newEndIdx(3), 所以退出循环更新结束
// 开始进行比较
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
// 比较 新子节点中的头节点 与 旧子节点中的头节点
if(oldStartVnode.key === newStartVnode.key){
// ...
// 比较 新子节点中的尾节点 与 旧子节点中的尾节点
} else if (oldEndVnode.key === newEndVnode.key){
// ...
// 比较 新子节点中的尾节点 与 旧子节点中的头节点
} else if(oldStartVnode.key === newEndVnode.key){
// ...
// 比较 新子节点中的头节点 与 旧子节点中的尾节点
} else if(oldEndVnode.key === newStartVnode.key){
// ...
} else {
// 遍历旧子节点, 尝试寻找与 newStartVnode 的key相同的节点
const idxInOld = oldC.findIndex(node => node.key === newStartVnode.key)
if(inxInOld > 0) {
const vnodeToMove = oldC[idxInOld]
patch(vnodeToMove, newStartVnode, container)
insert(vnodeToMove.el, container, oldStartVnode.el)
// 已经移动至它处, 设置为undefined
oldC[idxInOld] = undefined
newStartVnode = newC[++newStartIdx]
}
}
}
添加新元素和移除不存在的元素
| 新子节点 | 旧子节点 | 索引 |
|---|---|---|
| p-1 | p-1 | 0 |
| p-3 | p-2 | 1 |
| p-4 | p-3 | 2 |
| p-5 | 3 |
- 新子节点
p-1、p-3、p-4、p-5;newStartIdx = 0; newEndIdx = 3 - 旧子节点
p-1、p-2、p-3;oldStartIdx = 0; oldEndIdx = 2 - 新旧头节点可复用, 无需移动(需要
patch); - 此时
oldStartIdx = newStartIdx = 1; newEndIdx = 3; oldEndIdx = 2 - 比较
p-2与p-3、p-3与p-5、p-2与p-5、p-3与p-3可复用 - 移动
p-3至p-2前面 - 此时
newStartIdx = 2; newEndIdx = 3; oldStartIdx = oldEndIdx = 1 - 比较
p-2与p-4、p-2与p-5不可复用 p-4在旧子节点中没有可复用的节点, 直接插入.newStartIdx = 3p-5在旧子节点中没有可复用的节点, 直接插入.newStartIdx = 4- 跳出
while循环, 并将p-2卸载.
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
if(!oldStartVnode){
oldStartVnode = oldC[++oldStartIdx]
} else if (!oldEndVnode){
oldEndVnode = oldC[--oldEndIdx]
} else if (oldStartVnode.key === newStartVnode.key){
// ...
} else if (oldEndVnode.key === newEndVnode.key){
// ...
} else if(oldStartVnode.key === newEndVnode.key){
// ...
} else if(oldEndVnode.key === newStartVnode.key){
// ...
} else {
const idxInOld = oldC.findIndex(node => node.key === newStartVnode.key)
if(inxInOld > 0) {
const vnodeToMove = oldC[idxInOld]
patch(vnodeToMove, newStartVnode, container)
insert(vnodeToMove.el, container, oldStartVnode.el)
oldC[idxInOld] = undefined
// newStartVnode = newC[++newStartIdx]
} else {
// 没有找到可复用的节点, 则新建并插入头部
patch(null, newStartVnode, container, oldStartVnode.el)
}
newStartVnode = newC[++newStartIdx]
}
}
// 若 newEndVnode 与 oldEndVnode 可复用, 需要处理新增操作(即新子节点头部新增)
if(oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx){
for(let i = newStartIdx; i <= newEndIdx; i++){
const anchor = newC[newEndIdx + 1] ? newC[newEndIdx + 1].el : null
patch(null, newC[i], container, anchor)
}
// 移除操作
} else if(newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx){
for(let i = oldStartIdx; i <= oldEndIdx; i++){
unmount(oldC[i])
}
}
总结
双端Diff算法从新旧子节点两端同时开始进行比较, 试图找到可复用的节点. 对于简单diff算法, 同样的更新场景下执行的dom移动操作次数更少.