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…

相關文章
相關標籤/搜索