vuejs设计与实现-双端diff算法

118 阅读3分钟

双端比较的原理 & 双端比较的优势

双端diff算法: 同时对子节点的两个端点(oldStartIdxoldEndIdxnewStartIdxnewEndIdx)进行比较.

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-3p-10
p-1p-21
p-2p-32

简单diff算法需要对p-1p-2移动两次才能完成. 实际上我们只需要移动一次p-3即可完成更新.

  • 比较oldStartVnodep-1newStartVnodep-3, 两者key不同无法复用
  • 比较oldEndVnodep-3newEndVnodep-2, 两者key不同无法复用
  • 比较oldStartVnodep-1newEndVnodep-2, 两者key不同无法复用
  • 比较oldEndVnodep-3newStartVnodep-3, 两者key相同可以复用
  • p-3原本处于末尾, 在新子节点中是第一位, 移动其至首位.
  • 此时oldStartIdx = 0; oldEndIdx = 1; newStartIdx = 1; newEndIdx = 2
  • 比较oldStartVnodep-1newStartVnodep-1(newStartInd = 1), 两者key相同可以复用. 但都处于头部无需移动, 打补丁即可.
  • 此时oldStartIdx = 1; oldEndIdx = 1; newStartIdx = 2; newEndIdx = 2
  • 比较oldStartVnodep-2newStartVnodep-2, 两者key相同可以复用. 但都处于头部, 打补丁即可.
  • 此时oldStartIdx = 2; oldEndIdx = 1; newStartIdx = 3; newEndIdx = 2
  • oldStartInd > oldEndInd && newStartInd > newEndInd跳出while循环, 更新结束.

整个过程只移动一次p-3

非理性状况的处理方式

新子节点旧子节点索引
p-2p-10
p-4p-21
p-1p-32
p-3p-43
  • 比较p-1p-2p-4p-3p-1p-3p-4p-2均无法复用
  • 接下来尝试寻找旧子节点中有无key值newStartVnodep-2相同的节点
  • 找到了旧子节点中可复用的p-2, 记录idxInOld = 1. 调用patch并移动至oldStartVnodep-1前面完成更新.
  • 此时oldC[idxInOld] = undefined(对应的真实dom已经移动至它处), newStartIdx = 1. 真实dom顺序为 p-2p-1p-3p-4.
  • 比较p-1p-4p-4p-3p-1p-3p-4p-4, key值相同可以复用
  • 把旧节点中可复用的p-4, 移动至oldStartVnodep-1前面, 此时newStartIdx = 2; oldEndIdx = 2. 真实dom顺序为 p-2p-4p-1p-3.
  • 比较p-1p-1, 两者key相同可以复用. 但都处于头部无需移动. 此时oldStartIdx = 1; newStartIdx = 3
  • 发现oldStartVnode为空, 说明已经处理过. 直接跳过, 此时oldStartIdx = 2
  • 比较p-3p-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-1p-10
p-3p-21
p-4p-32
p-53
  • 新子节点 p-1p-3p-4p-5; newStartIdx = 0; newEndIdx = 3
  • 旧子节点 p-1p-2p-3; oldStartIdx = 0; oldEndIdx = 2
  • 新旧头节点可复用, 无需移动(需要patch);
  • 此时oldStartIdx = newStartIdx = 1; newEndIdx = 3; oldEndIdx = 2
  • 比较p-2p-3p-3p-5p-2p-5p-3p-3可复用
  • 移动p-3p-2前面
  • 此时newStartIdx = 2; newEndIdx = 3; oldStartIdx = oldEndIdx = 1
  • 比较p-2p-4p-2p-5不可复用
  • p-4在旧子节点中没有可复用的节点, 直接插入. newStartIdx = 3
  • p-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移动操作次数更少.