Vue3 Virtual DOM diff 算法

前言

Vue3 beta 版本已经发布,新版本对 Virtual DOM diff 算法作了改进,性能提高了 1.3 - 2 倍。本文跟你们一块儿学习一下新版 Virtual DOM diff 算法。node

本文将会讲述如下内容算法

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

Vue3 中的 diff

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

传统的作法bash

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

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

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

// c2 新数据 
["a","e","b","d","c","f"]
复制代码

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

原理大体以下:oop

  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"]复制代码


一、从前日后比较

从第一个节点开始比较优化

  • 遇到相同的节点,执行 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++
    }
  }复制代码


二、从后往前比较

从最后一个节点开始比较

  • 遇到相同节点,执行 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--
    }复制代码

三、旧数据是否比较完了

若是 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++
        }
      }
    }复制代码


四、新数据是否比较完了

若是 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++
      }
    }复制代码


五、处理剩余的节点

上面四个步骤已经能知足页面上常见的新增和删除操做,若是是排序或者数据被从新赋值,上面四个步骤是不知足的。

//  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.一、存储剩余新数据的 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.二、剩余新、旧数据对比

  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.三、最长递增子序列


// 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…

相关文章
相关标签/搜索