Vue3 Virtual DOM diff 算法

2,342 阅读11分钟

前言

Vue3 beta 版本已经发布,新版本对 Virtual DOM diff 算法做了改进,性能提升了 1.3 - 2 倍。本文跟大家一起学习一下新版 Virtual DOM diff 算法。

本文将会讲述以下内容

  • Vue3VDOM diff 算法
  • 最长递增子序列的概念
  • Vue3 是如何利用最长递增子序列优化 diff 算法的
  • 回顾 Vue2 中的 VDOM diff 算法,分析 Vue2 diff 算法的不足,以及 Vue3 中是如何优化从而提升性能的

Vue3 中的 diff

假设有以下新、旧两组数据,我们如何找出哪些数据是新增、哪些如要删除和移动?

传统的做法

  • 遍历旧数据去挨个去新数据中查找,哪些被删除了,哪些被移动了
  • 然后在遍历新数据,去旧数据中查找哪些数据是新增的

这种做法实现没有问题,但是效率却很低。大佬当然不屑于这种 low 的做法,我们先看 Vue3 中是如何做的,最后再回顾一下 Vue2 中的做法

//  c1 旧数据 
["a","b","c","d","g","f"]

// c2 新数据 
["a","e","b","d","c","f"]

Web 中对数组的操作大致有新增、删除、排序。所以算法针对这几种操作做了优化。

原理大致如下:

  1. 从前往后比较,相同节点 ["a"] 进行 patch,遇到不相同的节点停止比较
  2. 从后往前比较,相同节点 ["f"] 进行 patch,遇到不相同的节点停止比较
  3. 如果 c1 中的所有节点都已经比较完了,c2 中剩余没有比较的节点都是新数据,执行 mount
  4. 如果 c2 中的所有节点都已经比较完了,c1 中剩余没有比较的节点都是需要删除的,执行 unmount
  5. 如果
    c1
     和 c2 中都有剩余节点,对剩余节点进行比较
    1. 找出需要删除的节点,执行 unmount
    2. 找出新、旧节点的对应关系,利用 “最长递增子序列” 优化节点的移动、新增。这一步是 diff 算法的核心,也是比较难理解的部分


前四步针对新增、删除,第五步针对排序。

经过前面四步后,会得到如下数据。第五步是最剩余的数据进行比较。

//  c1 剩余 
["b","c","d","g"]

// c2 剩余 
["e","b","d","c"]


1、从前往后比较

从第一个节点开始比较

  • 遇到相同的节点,执行 patch
  • 遇到不同的节点,停止比较
  • 每次比较 i 自增一次
  const patchKeyedChildren = (
    c1: VNode[],
    c2: VNodeArrayChildren,
    container: RendererElement,
    parentAnchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean
  ) => {
    let i = 0 //从左往右开始位置
    const l2 = c2.length
    let e1 = c1.length - 1 // 旧节点结束位置
    let e2 = l2 - 1 // 新节点结束位置

    // 1. sync from start
    // (a b) c
    // (a b) d e
    while (i <= e1 && i <= e2) {
      const n1 = c1[i]
      const n2 = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container,
          parentAnchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      } else {
        break
      }
      i++
    }
  }


2、从后往前比较

从最后一个节点开始比较

  • 遇到相同节点,执行 patch
  • 遇到不同节点,停止
  • 每次比较新、旧节点的结束位置 e1, e2 往前移动一次
    // a (b c)
    // d e (b c)
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1]
      const n2 = (c2[e2] = optimized
        ? cloneIfMounted(c2[e2] as VNode)
        : normalizeVNode(c2[e2]))
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container,
          parentAnchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      } else {
        break
      }
      e1--
      e2--
    }

3、旧数据是否比较完了

如果 i > e1 说明旧数据已经比较完了,那么新数据中剩余没有比较的节点(ie2 之间的节点)都是新增的

    // (a b)
    // (a b) c
    // i = 2, e1 = 1, e2 = 2
    // (a b)
    // c (a b)
    // i = 0, e1 = -1, e2 = 0
    if (i > e1) {
      if (i <= e2) {
        const nextPos = e2 + 1
        const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
        while (i <= e2) {
          patch(
            null,// 旧元素为 null,此时为新增
            (c2[i] = optimized
              ? cloneIfMounted(c2[i] as VNode)
              : normalizeVNode(c2[i])),
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG
          )
          i++
        }
      }
    }


4、新数据是否比较完了

如果 i > e2 说明新数据已经全都比较完了,旧数据中没有比较的节点( ie1 之间的节点)都是需要删除的。

    // (a b) c
    // (a b)
    // i = 2, e1 = 2, e2 = 1
    // a (b c)
    // (b c)
    // i = 0, e1 = 0, e2 = -1
    else if (i > e2) {
      while (i <= e1) {
        unmount(c1[i], parentComponent, parentSuspense, true)
        i++
      }
    }


5、处理剩余的节点

上面四个步骤已经能满足页面上常见的新增和删除操作,如果是排序或者数据被重新赋值,上面四个步骤是不满足的。

//  c1 旧数据 
["a","b","c","d","e","f","g"]

// c2 新数据 
["a","c","d","b","f","i","g"]

经过上述四个步骤处理之后,剩余的节点如下

//  c1 剩余 
["b","c","d","e","f"]

// c2 剩余 
["c","d","b","f","i"]

可以看出

  • bcdf 都被移动了
  • e 被删除了
  • i 是新增的


想要知道 c1 中剩余的节点是被删除还是移动了。肯定要遍历 c1 中的剩余数据去 c2 中查找。

  • 如果能找到对应的 key 说明该节点被移动了,要记录下新的 index 方便后续移动
  • 如果找不到说明被删除了


5.1、存储剩余新数据的 key => index 关系


遍历 c2 中剩余的数据,存储 key => index 关系,方便后续 c1 遍历的时候进行查找

      const s1 = i // 旧数据开始位置
      const s2 = i // 新数据开始位置

      // 5.1 build key:index map for newChildren
      const keyToNewIndexMap: Map<string | number, number> = new Map()
      for (i = s2; i <= e2; i++) {
        const nextChild = (c2[i] = optimized
          ? cloneIfMounted(c2[i] as VNode)
          : normalizeVNode(c2[i]))
        if (nextChild.key != null) {
          if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
            warn(
              `Duplicate keys found during update:`,
              JSON.stringify(nextChild.key),
              `Make sure keys are unique.`
            )
          }
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }


此时的 keyToNewIndexMap 如下

{"c" => 1}
{"d" => 2}
{"b" => 3}
{"f" => 4}
{"i" => 5}


5.2、剩余新、旧数据对比

  1. 为了确认旧节点被移动到哪个位置,需要创建 newIndexToOldIndexMap 数组,用于记录新元素位置=>旧元素位置的对应关系。
  1. 数组长度为剩余新数据的长度
  2. 每一项默认值都是 0
  1. 遍历剩余的旧数据,从 keyToNewIndexMap 中查找对应的 newIndex
  1. 不存在,节点被删除,执行 unmount
  2. 存在
  1. 保存新、旧节点位置(oldIndex+1)关系,
  2. 执行 patch
  1. 如果每次遍历找到的 newIndex 不是趋势递增的,说明有节点需要移动
  2. 如果剩余的旧数据全都遍历完了 newIndexToOldIndexMapoldIndex0 的就是新增的节点


思考:为什么要 oldIndex +1


假如新、旧数据如下

//  c1 旧数据 
["c","a","b","d","e","f"]

// c2 新数据 
["a","c","d","b","f","i"]

c2 中的 cc1 中对应的 oldIndex 就是 0,因为 0newIndexToOldIndexMap 是特殊值,代表新增的节点。所以不能将 0 存入 newIndexToOldIndexMap,因此 oldIndex + 1 了。这里是为了计算最长递增子序列。


// c1 旧数据

["a","b","c","d","e","f","g"]

// c2 新数据

["a","c","d","b","f","i","g"]


c1 中剩余数据遍历结束,newIndexToOldIndexMap 如下

//newIndexToOldIndexMap 
[3,4,2,6,0]

//对应
["c","d","b","f","i"]

可以看出 c, d, b, f 需要移动,i 需要新增。


这时只需要遍历 newIndexToOldIndexMap

0 代表是新增的数据,执行 mount

• 非 0 的数据,从 c1 中找到并移动到对应的 newIndex 前面即可


如下:

  1. c 移动到 b
  2. d 移动到 b
  3. i 增加


      let j
      let patched = 0
      const toBePatched = e2 - s2 + 1// 剩余新节点长度
      let moved = false //是否需要移动
      // used to track whether any node has moved
      let maxNewIndexSoFar = 0
      // works as Map<newIndex, oldIndex>
      // Note that oldIndex is offset by +1
      // and oldIndex = 0 is a special value indicating the new node has
      // no corresponding old node.
      // used for determining longest stable subsequence
      const newIndexToOldIndexMap = new Array(toBePatched)
      for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0

      for (i = s1; i <= e1; i++) {
        const prevChild = c1[i]
        // 剩余新节点已经处理完,剩余的旧节点都需要 unmount
        if (patched >= toBePatched) {
          // all new children have been patched so this can only be a removal
          unmount(prevChild, parentComponent, parentSuspense, true)
          continue
        }
        let newIndex
        if (prevChild.key != null) {
          newIndex = keyToNewIndexMap.get(prevChild.key)
        } else {
          // key-less node, try to locate a key-less node of the same type
          for (j = s2; j <= e2; j++) {
            if (
              newIndexToOldIndexMap[j - s2] === 0 &&
              isSameVNodeType(prevChild, c2[j] as VNode)
            ) {
              newIndex = j
              break
            }
          }
        }
        
        if (newIndex === undefined) {
          unmount(prevChild, parentComponent, parentSuspense, true)
        } else {
          newIndexToOldIndexMap[newIndex - s2] = i + 1 // oldIndex + 1
          // maxNewIndexSoFar 是不是递增的趋势,说明有节点需要移动
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex
          } else {
            moved = true
          }
          patch(
            prevChild,
            c2[newIndex] as VNode,
            container,
            null,
            parentComponent,
            parentSuspense,
            isSVG,
            optimized
          )
          patched++
        }
      }


5.3、最长递增子序列


// c1 旧数据

["a","b","c","d","e","f","g"]

// c2 新数据

["a","c","d","b","f","i","g"]


要将 ["b", "c" ,"d"] 变成 ["c", "d", "b"]c , d 不用动,只需要将 b 移动到 d 之后就可以了,不需要将 cd 分别移动到 b 之前。


如何找到不需要移动的元素,减少移动次数?


在计算机科学中,最长递增子序列(longest increasing subsequence)问题是指,在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大。最长递增子序列中的元素在原序列中不一定是连续的。


对于以下的原始序列
0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15

最长递增子序列为
0, 2, 6, 9, 11, 15

值得注意的是原始序列的最长递增子序列并不一定唯一
对于该原始序列,实际上还有以下两个最长递增子序列
0, 4, 6, 9, 11, 15
0, 4, 6, 9, 13, 15
0, 2, 6, 9, 13, 15


newIndexToOldIndexMap [3, 4, 2, 6, 0] 中递增的子序列为 3, 4, 6。对应的索引为[0, 1, 3] ,分别对应对应 c, d, f。也就是说 c, d, f 是不需要移动的。

遍历 c2 中剩余的节点

  1. 如果 newIndexToOldIndexMap 中对应的 oldIndex0 新增
  2. 如果不在最长递增子序列中,进行移动操作
      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : EMPTY_ARR
      
      j = increasingNewIndexSequence.length - 1
      // looping backwards so that we can use last patched node as anchor
      for (i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = s2 + i
        const nextChild = c2[nextIndex] as VNode
        const anchor =
          nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
        // 新增
        if (newIndexToOldIndexMap[i] === 0) {
          // mount new
          patch(
            null,
            nextChild,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG
          )
        } else if (moved) {// 移动
          // move if:
          // There is no stable subsequence (e.g. a reverse)
          // OR current node is not among the stable sequence
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
            move(nextChild, container, anchor, MoveType.REORDER)
          } else {
            j--
          }
        }
      }

// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
function getSequence(arr: number[]): number[] {
  const p = arr.slice()
  const result = [0]
  let i, j, u, v, c
  const len = arr.length
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    if (arrI !== 0) {
      j = result[result.length - 1]
      if (arr[j] < arrI) {
        p[i] = j
        result.push(i)
        continue
      }
      u = 0
      v = result.length - 1
      while (u < v) {
        c = ((u + v) / 2) | 0
        if (arr[result[c]] < arrI) {
          u = c + 1
        } else {
          v = c
        }
      }
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1]
        }
        result[u] = i
      }
    }
  }
  u = result.length
  v = result[u - 1]
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  return result
}


Vue2 中的 diff

  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }
//  c1 旧数据 
["a","b","c","d","g","f"]

// c2 新数据 
["a","e","b","d","c","f"]

Vue2 中的 diff 是个双指针循环


  1. 从前往后比,如果相同执行 patchVnode
  2. 从后往前比,如果相同执行 patchVnode
  3. 旧开头和新结尾比较,考虑右移的情况
  4. 旧结尾和新开头比较,考虑左移的情况
  5. 去旧数据中查找对应 index 找不到就新增,找到就移动


新数据先比较完,剩余的未比较的旧数据都需要删除

旧数据先比较完,剩余的未比较的新数据都需要新增


前面四步比较结束之后,剩余未比较的新数据如下

["e","b","d","c"]

Vue3 中前四步结束后得到的结果是一样的。


Vue2 中遍历剩余的新数据去旧数据中查找是在循环的最后,也就是说每一次遍历上面的 if 都会执行。

Vue3 中利用最长递增子序列优化了这一点,直接找到需要移动的节点进行移动操作。

因为首尾的比较是为了对应节点移动的情况,通过最长递增子序列直接找到需要移动的节点,也就不再需要首、尾的对比了。

总结

理解了 Vue3diff 算法

  • 新、旧数据是如何比较的
  • 什么是最长递增子序列
  • 如何通过最长递增子序列优化 diff 算法


对比了 Vue2diff 算法

  • 新、老算法的差异
  • 就算法的不足之处,以及新算法是有何优化的
  • 为什么不再需要首、尾的对比了


原文地址:www.yuque.com/daiwei-wszh…