diff算法 -- 双端diff算法

189 阅读3分钟

简单Diff算法对于DOM的移动操作并不是最优的

image.png 若使用简单Diff算法,则会发生两次DOM操作,第一次DOM操作将真实DOM节点p-1移动到真实DOM节点p-3后面,第二次移动操作将真实DOM节点p-2移动到真实DOM节点p-1后面,上述过程并非最优解,在这个例子中,只需一步操作即可完成更新,把真实DOM节点p-3移动到p-1前面,本文介绍的双端Diff算法可以做到

双端比较原理

双端Diff算法是一种同时对新旧两组子节点的两个端点进行比较的算法,因此需要四个索引值,分别指向新旧两组子节点的端点,有了索引后,就可以找到它所指向的虚拟节点

image.png

function patchKeyedChildren(n1, n2, container) {
  const oldChildren = n1.children
  const newChildren = n2.children
  //四个索引值
  let oldStartIdx = 0
  let oldEndIdx = oldChildren.length - 1
  let newStartIdx = 0
  let newEndIdx = newChildren.length - 1
  //四个索引指向的vnode节点
  let oldStartVNode = oldChildren[oldStartIdx]
  let oldEndVNode = oldChildren[oldEndIdx]
  let newStartVNode = newChildren[newStartIdx]
  let newEndVNode = newChildren[newEndIdx]
}

在双端比较中,每一轮比较都分为四个步骤

理想情况

image.png

image.png

image.png

  1. 比较旧的一组子节点的第一个子节点p-1与新的一组子节点中的第一个子节点p-4,看是否相同,若key值相同则进行DOM复用,否则什么都不做
  2. 比较旧的一组子节点的最后一个子节点p-4与新的一组子节点中的最后一个子节点p-3,看是否相同,若key值相同则进行DOM复用,否则什么都不做
  3. 比较旧的一组子节点的第一个子节点p-1与新的一组子节点中的最后一个子节点p-3,看是否相同,若key值相同则进行DOM复用,否则什么都不做
  4. 比较旧的一组子节点的最后一个子节点p-4与新的一组子节点中的第一个子节点p-4,看是否相同,若key值相同则进行DOM复用,否则什么都不做

第一步key值相同,可以复用,由于两者在新旧两组子节点中都是头部节点,因此不需要移动,只需调用patch函数进行打补丁,同时更新相关索引,指向下一个位置

第二步key值相同,可以复用,由于两者都处于尾部,因此不需要对真实DOM进行移动操作,只需打补丁即可,同时更新索引到下一个位置

第三步key值相同,可以复用,可以看出,p-1本身是头部节点,在新的顺序中,变为了尾部节点,所以要将尾部节点p-1对应的真实DOM移动到旧的一组子节点的尾部节点所对应的真实DOM后面,同时更新索引到下一个位置

第四步key值相同,可以复用,p-4原本是最后一个子节点,在新的顺序中,变成了第一个子节点,将索引oldEndIdx所指向的真实DOM移动到索引oldStartIdx指向的虚拟节点所对应的真实DOM前面,同时更新索引到下一个位置

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVNode.key === newStartVNode.key) {
      //打补丁,具体逻辑不赘述
      patch(oldStartVNode, newStartVNode, container)
      //更新索引指向下一个位置
      oldStartVNode = oldChildren[++oldStartIdx]
      newStartVNode = newChildren[++newStartIdx]
    } else if (oldEndVNode.key === newEndVNode.key) {
      patch(oldEndVNode, newEndVNode, container)
      oldEndVNode = oldChildren[--oldEndIdx]
      newEndVNode = newChildren[--newEndIdx]
    } else if (oldStartVNode.key === newEndVNode.key) {
      patch(oldStartVNode, newEndVNode, container)
      insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
      oldStartVNode = oldChildren[++oldStartIdx]
      newEndVNode = newChildren[--newEndIdx]
    } else if (oldEndVNode.key === newStartVNode.key) {
      //调用patch函数进行打补丁
      patch(oldEndVNode, newStartVNode, container)
      //把oldEndVNode.el 移动到 oldStartVNode.el 前面
      insert(oldEndVNode.el, container, oldStartVNode.el)
      //移动DOM后,更新索引值,并指向下一个位置
      oldEndVNode = oldChildren[--oldEndIdx]
      newStartIdx = newChildren[++newStartIdx]
    }
}

非理想情况

无法命中四个步骤中的任何一步

image.png 尝试看非头部,非尾部的节点能否复用,拿新的一组子节点中的头部节点去旧的一组子节点中寻找,并将该节点在旧的一组子节点中的索引存储到变量idInOld中,若找到可复用的节点,则将其对应的真实DOM移动到头部,并将其设置为undefined,同时更新newStartIdx到下一个位置,若找不到具有相同key值的节点,说明是新增节点,将其挂载到正确的位置

image.png

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (!oldStartVNode) {
      oldStartVNode = oldChildren[++oldStartIdx]
    } else if (!oldEndVNode) {
      oldEndVNode = oldChildren[--oldEndIdx]
    } else if (oldStartVNode.key === newStartVNode.key) {
      patch(oldStartVNode, newStartVNode, container)
      oldStartVNode = oldChildren[++oldStartIdx]
      newStartVNode = newChildren[++newStartIdx]
    } else if (oldEndVNode.key === newEndVNode.key) {
      patch(oldEndVNode, newEndVNode, container)
      oldEndVNode = oldChildren[--oldEndIdx]
      newEndVNode = newChildren[--newEndIdx]
    } else if (oldStartVNode.key === newEndVNode.key) {
      patch(oldStartVNode, newEndVNode, container)
      insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
      oldStartVNode = oldChildren[++oldStartIdx]
      newEndVNode = newChildren[--newEndIdx]
    } else if (oldEndVNode.key === newStartVNode.key) {
      //调用patch函数进行打补丁
      patch(oldEndVNode, newStartVNode, container)
      //把oldEndVNode.el 移动到 oldStartVNode.el 前面
      insert(oldEndVNode.el, container, oldStartVNode.el)
      //移动DOM后,更新索引值,并指向下一个位置
      oldEndVNode = oldChildren[--oldEndIdx]
      newStartIdx = newChildren[++newStartIdx]
    } else {
      //当上述四个比较过程都无法找到可复用节点
      const idxInOld = oldChildren.findIndex(
        node => node.key == newStartVNode.key
      )
      //若能找到可复用节点,将其挪至尾部
      if (idxInOld > 0) {
        const vnodeToMove = oldChildren[idxInOld]
        patch(vnodeToMove, newStartVNode, container)
        insert(vnodeToMove.el, container, oldStartVNode.el)
        oldChildren[idxInOld] = undefined
        newStartVNode = newChildren[++newStartIdx]
      } else {
        //将newStartVNode作为新节点挂载到头部,并使用oldStartVNode.el作为锚点
        patch(null, newStartVNode, container, oldStartVNode.el)
      }
    }
  }

当一轮更新完毕后,若有节点在整个更新过程中被遗漏了,说明该节点只在旧节点中出现,在新节点中不存在,将其进行移除

if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      patch(null, newChildren[i], container, oldStartVNode.el)
    }
  } else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
    //移除不存在元素
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      unmount(oldChildren[i])
    }
  }