Diff算法 -- 快速Diff算法

186 阅读2分钟

Vue3使用该算法实现,快速Diff算法包含预处理步骤,借鉴了纯文本Diff算法的思路

在纯文本Diff算法中,首先进行全等比较,若文本全等,则无需进入核心Diff算法步骤,另外会处理两段文本相同的前缀和后缀,如下所示:

TEXT1: I use vue
TEXT2: I use react

则真正需要进行diff操作的部分是

TEXT1: vue
TEXT2: react

快速Diff算法借鉴了纯文本Diff算法

前置节点处理

image.png 对于相同的前置和后置节点,由于位置不变,所以无需移动,只需要在他们之间打补丁,对于前置节点,建立索引j,开启while循环,让索引j递增,直到遇到不相同的节点

function diff2(n1, n2, container) {
      const newChildren = n2.children
      const oldChildren = n1.children
      //更新相同的前置节点
      //索引j指向新旧两组子节点的开头
      let j = 0
      let oldVNode = oldChildren[0]
      let newVNode = newChildren[0]
      while (oldVNode.key === newVNode.key) {
        patch(oldVNode, newVNode, container)
        j++
        oldVNode = oldChildren[j]
        newVNode = newChildren[j]
      }
 }

后置节点处理

由于新旧两组子节点的数量可能不同·,所以需要两个索引newEnd和oldEnd,分别指向新旧两组子节点中的最后一个节点,开启while循环,从后向前遍历,递减索引值,直到遇到key值不同的节点

image.png

//更新相同的后置节点
      let oldEnd = oldChildren.length - 1
      let newEnd = newChildren.length - 1
      oldVNode = oldChildren[oldEnd]
      newVNode = newChildren[newEnd]
      while (oldVNode.key === newVNode.key) {
        patch(newVNode, oldVNode, container)
        oldEnd--
        newEnd--
        oldVNode = oldChildren[oldEnd]
        newVNode = newChildren[oldEnd]
      }

根据j,newEnd,oldEnd来判断是否有新增节点,删除节点

若 oldEnd < j 成立,并且 newEnd >= j 成立代表有新增节点
若 newEnd < j 成立,并且 oldEnd >= j 成立代表有待删除节点
//更新相同的后置节点
      let oldEnd = oldChildren.length - 1
      let newEnd = newChildren.length - 1
      oldVNode = oldChildren[oldEnd]
      newVNode = newChildren[newEnd]
      while (oldVNode.key === newVNode.key) {
        patch(newVNode, oldVNode, container)
        oldEnd--
        newEnd--
        oldVNode = oldChildren[oldEnd]
        newVNode = newChildren[oldEnd]
      }
      //代表有新节点
      if (j > oldEnd && j <= newEnd) {
        //锚点索引
        const anchorIndex = newEnd + 1
        const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
        while (j <= newEnd) {
          patch(null, newChildren[j++], container, anchor)
        }
      } else if (j > newEnd && j <= oldEnd) {
        //代表需要卸载旧结点
        while (j <= oldEnd) {
          unmount(oldChildren[j++])
        }
      }

判断是否需要DOM移动操作

上述例子较理想化,若存在较复杂的情况时 创建source数组,用来存储新的一组子节点在旧的一组子节点中的位置索引,将由此计算出最长递增子序列,辅助DOM移动 可以使用两层for循环来完成source数组的填充,外层循环用于遍历旧的一组子节点,内层循环用于遍历新的一组子节点,但此时时间复杂度为O(n*),当节点数量较多时,会带来性能问题,因此为新的子节点构建一张索引表,用来存储key和节点位置索引间的映射,能将时间复杂度降至O(n)

image.png

image.png

//处理非理想情况
        const count = newEnd - j + 1
        //存储新的一组子节点在旧的一组子节点中的位置索引,据其计算出最长递增子序列
        const source = new Array(count)
        source.fill(-1)

        const oldStart = j
        const newStart = j
        //moved为true时,代表需要移动
        let moved = false
        let pos = 0
        //建立索引表, 使用一层for循环,降低时间复杂度
        const keyIndex = {}
        for (let i = newStart; i <= newEnd; i++) {
          keyIndex[newChildren[i].key] = i
        }
        for (let i = oldStart; i <= oldEnd; i++) {
            oldVNode = oldChildren[i]
            const k = keyIndex[oldVNode.key]
            if (typeof k !== 'undefined') {
              newVNode = newChildren[k]
              patch(oldVNode, newVNode, container)
              source(k - newStart) = i
            } else {
              unmount(oldVNode)
            }
          } 

快速Diff算法判断节点是否需要移动与简单Diff算法类似,如果在遍历过程中遇到的索引值呈递增趋势,则说明不需要移动节点,反之则需要。另外增加一个数量标识,代表已更新过的节点数量,若更新过的节点数量超过新的一组的节点数量,说明有多余节点,应卸载

//处理非理想情况
        const count = newEnd - j + 1
        //存储新的一组子节点在旧的一组子节点中的位置索引,据其计算出最长递增子序列
        const source = new Array(count)
        source.fill(-1)

        const oldStart = j
        const newStart = j
        //moved为true时,代表需要移动
        let moved = false
        let pos = 0
        //建立索引表, 使用一层for循环,降低时间复杂度
        const keyIndex = {}
        for (let i = newStart; i <= newEnd; i++) {
          keyIndex[newChildren[i].key] = i
        }
        //代表更新过的节点数量
        let patched = 0
        for (let i = oldStart; i <= oldEnd; i++) {
          oldVNode = oldChildren[i]
          //若更新过的节点数量小于等于需要更新的节点数量,执行更新
          if (patched <= count) {
            const k = keyIndex[oldVNode.key]
            if (typeof k !== 'undefined') {
              newVNode = newChildren[k]
              patch(oldVNode, newVNode, container)
              patched++
              source(k - newStart) = i
              //逻辑与简单diff算法类似
              if (k < pos) {
                moved = true
              } else {
                pos = k
              }
            } else {
              unmount(oldVNode)
            }
          } else {
            unmount(oldVNode)
          }
        }

如何移动元素

根据source数组计算最长递增子序列,返回最长递增子序列中元素在source数组中的位置索引,为完成移动,还需创建两个索引值i和s

索引i指向新的一组子节点中的最后一个节点
索引s指向最长递增子序列中的最后一个元素

用for循环让i和s往上移动

  if (moved) {
      //创建最长递增子序列
      const seq = lis(source)
      //指向最长递增子序列的最后一个元素
      let s = seq.length - 1
      //指向新的一组子节点的最后一个元素
      let i = count - 1
      for (i; i >= 0; i--) {
        //说明该节点是全新节点,应将其挂载
        if (source[i] === -1) {
          //该节点在新children中的真实位置索引
          const pos = i + newStart
          const newVNode = newChildren[pos]
          const nextPos = pos + 1
          const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
          patch(null, newVNode, container, anchor)
        } else if (i !== seq[s]) {
          //证明该节点需要移动
          const pos = i + newStart
          const newVNode = newChildren[pos]
          const nextPos = pos + 1
          const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null
          insert(newVNode.el, container, anchor)
        } else {
          //该节点不需要移动,只需要让s指向下一个位置
          s--
        }
      }
    }

总结

快速Diff算法在实测中性能最优,借鉴了文本Diff算法的与处理思路,先处理新旧两组子节点中的相同前置节点相同后置节点,当前置节点和后置节点全部处理完毕后,如果无法简单地通过挂载新节点或卸载旧节点来完成更新,则需要根据节点的索引关系,构造出一个最长递增子序列,最长递增子序列所指向的节点即为不需要移动的节点