vue3.0 diff算法詳解(超詳細)

前言:隨之vue3.0beta版本的發佈,vue3.0正式版本相信不久就會與我們相遇。尤玉溪在直播中也說了vue3.0的新特性typescript強烈支持,proxy響應式原理,重新虛擬dom,優化diff算法性能提升等等。小編在這裏仔細研究了vue3.0beta版本diff算法的源碼,並希望把其中的細節和奧妙和大家一起分享。

在這裏插入圖片描述
首先我們來思考一些大中廠面試中,很容易問到的問題:

1 什麼時候用到diff算法,diff算法作用域在哪裏?
2 diff算法是怎麼運作的,到底有什麼作用?
3 在v-for 循環列表 key 的作用是什麼
4 用索引index做key真的有用? 到底用什麼做key纔是最佳方案。

如果遇到這些問題,大家是怎麼回答的呢?我相信當你讀完這篇文章,這些問題也會迎刃而解。

一 什麼時候用到了diff算法,diff算法作用域?

1.1diff算法的作用域

patch概念引入

在vue update過程中在遍歷子代vnode的過程中,會用不同的patch方法來patch新老vnode,如果找到對應的 newVnode 和 oldVnode,就可以複用利用裏面的真實dom節點。避免了重複創建元素帶來的性能開銷。畢竟瀏覽器創造真實的dom,操縱真實的dom,性能代價是昂貴的。

patch過程中,如果面對當前vnode存在有很多chidren的情況,那麼需要分別遍歷patch新的children Vnode和老的 children vnode。

存在chidren的vnode類型

首先思考一下什麼類型的vnode會存在children。

①element元素類型vnode

第一中情況就是element類型vnode 會存在 children vode,此時的三個span標籤就是chidren vnode情況

<div>
   <span> 蘋果🍎 </span> 
   <span> 香蕉🍌 </span>
   <span> 鴨梨🍐 </span>
</div>

在vue3.0源碼中 ,patchElement用於處理element類型的vnode

②flagment碎片類型vnode

在Vue3.0中,引入了一個fragment碎片概念。
你可能會問,什麼是碎片?如果你創建一個Vue組件,那麼它只能有一個根節點。

<template>
   <span> 蘋果🍎 </span> 
   <span> 香蕉🍌 </span>
   <span> 鴨梨🍐 </span>
</template>

這樣可能會報出警告,原因是代表任何Vue組件的Vue實例需要綁定到一個單一的DOM元素中。唯一可以創建一個具有多個DOM節點的組件的方法就是創建一個沒有底層Vue實例的功能組件。

flagment出現就是用看起來像一個普通的DOM元素,但它是虛擬的,根本不會在DOM樹中呈現。這樣我們可以將組件功能綁定到一個單一的元素中,而不需要創建一個多餘的DOM節點。

 <Fragment>
   <span> 蘋果🍎 </span> 
   <span> 香蕉🍌 </span>
   <span> 鴨梨🍐 </span>
</Fragment>

在vue3.0源碼中 ,processFragment用於處理Fragment類型的vnode

1.2 patchChildren

從上文中我們得知了存在children的vnode類型,那麼存在children就需要patch每一個
children vnode依次向下遍歷。那麼就需要一個patchChildren方法,依次patch子類vnode。

patchChildren

vue3.0中 在patchChildren方法中有這麼一段源碼

if (patchFlag > 0) {
      if (patchFlag & PatchFlags.KEYED_FRAGMENT) { 
         /* 對於存在key的情況用於diff算法 */
        patchKeyedChildren(
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
        return
      } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
         /* 對於不存在key的情況,直接patch  */
        patchUnkeyedChildren( 
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
        return
      }
    }

patchChildren根據是否存在key進行真正的diff或者直接patch。

既然diff算法存在patchChildren方法中,而patchChildren方法用在Fragment類型和element類型的vnode中,這樣也就解釋了diff算法的作用域是什麼。

1.3 diff算法作用?

通過前言我們知道,存在這children的情況的vnode,需要通過patchChildren遍歷children依次進行patch操作,如果在patch期間,再發現存在vnode情況,那麼會遞歸的方式依次向下patch,那麼找到與新的vnode對應的vnode顯的如此重要。

我們用兩幅圖來向大家展示vnode變化。

在這裏插入圖片描述
在這裏插入圖片描述
如上兩幅圖表示在一次更新中新老dom樹變化情況。

假設不存在diff算法,依次按照先後順序patch會發生什麼

如果不存在diff算法,而是直接patchchildren 就會出現如下圖的邏輯。

在這裏插入圖片描述

第一次patchChidren
在這裏插入圖片描述
第二次patchChidren
在這裏插入圖片描述
第三次patchChidren
在這裏插入圖片描述

第四次patchChidren

在這裏插入圖片描述
如果沒有用到diff算法,而是依次patch虛擬dom樹,那麼如上稍微修改dom順序,就會在patch過程中沒有一對正確的新老vnode,所以老vnode的節點沒有一個可以複用,這樣就需要重新創造新的節點,浪費了性能開銷,這顯然不是我們需要的。

那麼diff算法的作用就來了。

diff作用就是在patch子vnode過程中,找到與新vnode對應的老vnode,複用真實的dom節點,避免不必要的性能開銷

二 diff算法具體做了什麼(重點)?

在正式講diff算法之前,在patchChildren的過程中,存在 patchKeyedChildren
patchUnkeyedChildren

patchKeyedChildren 是正式的開啓diff的流程,那麼patchUnkeyedChildren的作用是什麼呢? 我們來看看針對沒有key的情況patchUnkeyedChildren會做什麼。


 c1 = c1 || EMPTY_ARR
    c2 = c2 || EMPTY_ARR
    const oldLength = c1.length
    const newLength = c2.length
    const commonLength = Math.min(oldLength, newLength)
    let i
    for (i = 0; i < commonLength; i++) { /* 依次遍歷新老vnode進行patch */
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      patch(
        c1[i],
        nextChild,
        container,
        null,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized
      )
    }
    if (oldLength > newLength) { /* 老vnode 數量大於新的vnode,刪除多餘的節點 */
      unmountChildren(c1, parentComponent, parentSuspense, true, commonLength)
    } else { /* /* 老vnode 數量小於於新的vnode,創造新的即誒安 */
      mountChildren(
        c2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized,
        commonLength
      )
    }

我們可以得到結論,對於不存在key情況
① 比較新老children的length獲取最小值 然後對於公共部分,進行從新patch工作。
② 如果老節點數量大於新的節點數量 ,移除多出來的節點。
③ 如果新的節點數量大於老節點的數量,從新 mountChildren新增的節點。

那麼對於存在key情況呢? 會用到diff算法 , diff算法做了什麼呢?

patchKeyedChildren方法究竟做了什麼?
我們先來看看一些聲明的變量。

    /*  c1 老的vnode c2 新的vnode  */
    let i = 0              /* 記錄索引 */
    const l2 = c2.length   /* 新vnode的數量 */
    let e1 = c1.length - 1 /* 老vnode 最後一個節點的索引 */
    let e2 = l2 - 1        /* 新節點最後一個節點的索引 */

①第一步從頭開始向尾尋找

(a b) c
(a b) d e

 /* 從頭對比找到有相同的節點 patch ,發現不同,立即跳出*/
    while (i <= e1 && i <= e2) {
      const n1 = c1[i]
      const n2 = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
        /* 判斷key ,type是否相等 */
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container, 
          parentAnchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      } else {
        break
      }
      i++
    }

第一步的事情就是從頭開始尋找相同的vnode,然後進行patch,如果發現不是相同的節點,那麼立即跳出循環。

具體流程如圖所示
在這裏插入圖片描述

isSameVNodeType

export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && n1.key === n2.key
}

isSameVNodeType 作用就是判斷當前vnode類型 和 vnode的 key是否相等

②第二步從尾開始同前diff

a (b c)
d e (b c)

 /* 如果第一步沒有patch完,立即,從後往前開始patch ,如果發現不同立即跳出循環 */
    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--
    }

經歷第一步操作之後,如果發現沒有patch完,那麼立即進行第二部,從尾部開始遍歷依次向前diff。

如果發現不是相同的節點,那麼立即跳出循環。

具體流程如圖所示
在這裏插入圖片描述

③④主要針對新增和刪除元素的情況,前提是元素沒有發生移動, 如果有元素髮生移動就要走⑤邏輯。

③ 如果老節點是否全部patch,新節點沒有被patch完,創建新的vnode

(a b)
(a b) c
i = 2, e1 = 1, e2 = 2
(a b)
c (a b)
i = 0, e1 = -1, e2 = 0

/* 如果新的節點大於老的節點數 ,對於剩下的節點全部以新的vnode處理( 這種情況說明已經patch完相同的vnode  ) */
    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,
            (c2[i] = optimized
              ? cloneIfMounted(c2[i] as VNode)
              : normalizeVNode(c2[i])),
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG
          )
          i++
        }
      }
    }

i > e1

如果新的節點大於老的節點數 ,對於剩下的節點全部以新的vnode處理( 這種情況說明已經patch完相同的vnode ),也就是要全部create新的vnode.

具體邏輯如圖所示
在這裏插入圖片描述

④ 如果新節點全部被patch,老節點有剩餘,那麼卸載所有老節點

i > e2
(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++
   }
}

對於老的節點大於新的節點的情況 ,對於超出的節點全部卸載 ( 這種情況說明已經patch完相同的vnode )

具體邏輯如圖所示
在這裏插入圖片描述

⑤ 不確定的元素 ( 這種情況說明沒有patch完相同的vnode ),我們可以接着①②的邏輯繼續往下看

diff核心

在①②情況下沒有遍歷完的節點如下圖所示。
在這裏插入圖片描述

剩下的節點。
在這裏插入圖片描述

      const s1 = i  //第一步遍歷到的index
      const s2 = i 
      const keyToNewIndexMap: Map<string | number, number> = new Map()
      /* 把沒有比較過的新的vnode節點,通過map保存 */
      for (i = s2; i <= e2; i++) {
        if (nextChild.key != null) {
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }
      let j
      let patched = 0 
      const toBePatched = e2 - s2 + 1 /* 沒有經過 path 新的節點的數量 */
      let moved = false /* 證明是否 */
      let maxNewIndexSoFar = 0 
      const newIndexToOldIndexMap = new Array(toBePatched)
       for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
      /* 建立一個數組,每個子元素都是0 [ 0, 0, 0, 0, 0, 0, ] */ 

遍歷所有新節點把索引和對應的key,存入map keyToNewIndexMap中

keyToNewIndexMap 存放 key -> index 的map

D : 2
E : 3
C : 4
I : 5

接下來聲明一個新的指針 j,記錄剩下新的節點的索引。
patched ,記錄在第⑤步patched新節點過的數量
toBePatched 記錄⑤步之前,沒有經過patched 新的節點的數量。
moved代表是否發生過移動,咱們的demo是已經發生過移動的。

newIndexToOldIndexMap 用來存放新節點索引和老節點索引的數組。
newIndexToOldIndexMap 數組的index是新vnode的索引 , value是老vnode的索引。

接下來

 for (i = s1; i <= e1; i++) { /* 開始遍歷老節點 */
        const prevChild = c1[i]
        if (patched >= toBePatched) { /* 已經patch數量大於等於, */
          /* ① 如果 toBePatched新的節點數量爲0 ,那麼統一卸載老的節點 */
          unmount(prevChild, parentComponent, parentSuspense, true)
          continue
        }
        let newIndex
         /* ② 如果,老節點的key存在 ,通過key找到對應的index */
        if (prevChild.key != null) {
          newIndex = keyToNewIndexMap.get(prevChild.key)
        } else { /*  ③ 如果,老節點的key不存在 */
          for (j = s2; j <= e2; j++) { /* 遍歷剩下的所有新節點 */
            if (
              newIndexToOldIndexMap[j - s2] === 0 && /* newIndexToOldIndexMap[j - s2] === 0 新節點沒有被patch */
              isSameVNodeType(prevChild, c2[j] as VNode)
            ) { /* 如果找到與當前老節點對應的新節點那麼 ,將新節點的索引,賦值給newIndex  */
              newIndex = j
              break
            }
          }
        }
        if (newIndex === undefined) { /* ①沒有找到與老節點對應的新節點,刪除當前節點,卸載所有的節點 */
          unmount(prevChild, parentComponent, parentSuspense, true)
        } else {
          /* ②把老節點的索引,記錄在存放新節點的數組中, */
          newIndexToOldIndexMap[newIndex - s2] = i + 1
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex
          } else {
            /* 證明有節點已經移動了   */
            moved = true
          }
          /* 找到新的節點進行patch節點 */
          patch(
            prevChild,
            c2[newIndex] as VNode,
            container,
            null,
            parentComponent,
            parentSuspense,
            isSVG,
            optimized
          )
          patched++
        }
 }

這段代碼算是diff算法的核心。

第一步: 通過老節點的key找到對應新節點的index:開始遍歷老的節點,判斷有沒有key, 如果存在key通過新節點的keyToNewIndexMap找到與新節點index,如果不存在key那麼會遍歷剩下來的新節點試圖找到對應index。

第二步:如果存在index證明有對應的老節點,那麼直接複用老節點進行patch,沒有找到與老節點對應的新節點,刪除當前老節點。

第三步:newIndexToOldIndexMap找到對應新老節點關係。

到這裏,我們patch了一遍,把所有的老vnode都patch了一遍。

如圖所示
在這裏插入圖片描述
但是接下來的問題。

1 雖然已經patch過所有的老節點。可以對於已經發生移動的節點,要怎麼真正移動dom元素。
2 對於新增的節點,(圖中節點I)並沒有處理,應該怎麼處理。

      /*移動老節點創建新節點*/
     /* 根據最長穩定序列移動相對應的節點 */
      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : EMPTY_ARR
      j = increasingNewIndexSequence.length - 1
      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) { /* 沒有老的節點與新的節點對應,則創建一個新的vnode */
          patch(
            null,
            nextChild,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG
          )
        } else if (moved) {
          if (j < 0 || i !== increasingNewIndexSequence[j]) { /*如果沒有在長*/
            /* 需要移動的vnode */
            move(nextChild, container, anchor, MoveType.REORDER)
          } else {
            j--
          }    

⑥最長穩定序列

首選通過getSequence得到一個最長穩定序列,對於index === 0 的情況也就是新增節點(圖中I) 需要從新mount一個新的vnode,然後對於發生移動的節點進行統一的移動操作

什麼叫做最長穩定序列

對於以下的原始序列
0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15
最長遞增子序列爲
0, 2, 6, 9, 11, 15.

爲什麼要得到最長穩定序列

因爲我們需要一個序列作爲基礎的參照序列,其他未在穩定序列的節點,進行移動。

總結

經過上述我們大致知道了diff算法的流程
1 從頭對比找到有相同的節點 patch ,發現不同,立即跳出。

2如果第一步沒有patch完,立即,從後往前開始patch ,如果發現不同立即跳出循環。

3如果新的節點大於老的節點數 ,對於剩下的節點全部以新的vnode處理( 這種情況說明已經patch完相同的vnode )。

4 對於老的節點大於新的節點的情況 , 對於超出的節點全部卸載 ( 這種情況說明已經patch完相同的vnode )。

5不確定的元素( 這種情況說明沒有patch完相同的vnode ) 與 3 ,4對立關係。

1 把沒有比較過的新的vnode節點,通過map保存
記錄已經patch的新節點的數量 patched
沒有經過 path 新的節點的數量 toBePatched
建立一個數組newIndexToOldIndexMap,每個子元素都是[ 0, 0, 0, 0, 0, 0, ] 裏面的數字記錄老節點的索引 ,數組索引就是新節點的索引
開始遍歷老節點
① 如果 toBePatched新的節點數量爲0 ,那麼統一卸載老的節點
② 如果,老節點的key存在 ,通過key找到對應的index
③ 如果,老節點的key不存在
1 遍歷剩下的所有新節點
2 如果找到與當前老節點對應的新節點那麼 ,將新節點的索引,賦值給newIndex
④ 沒有找到與老節點對應的新節點,卸載當前老節點。
⑤ 如果找到與老節點對應的新節點,把老節點的索引,記錄在存放新節點的數組中,
1 如果節點發生移動 記錄已經移動了
2 patch新老節點 找到新的節點進行patch節點
遍歷結束

如果發生移動
① 根據 newIndexToOldIndexMap 新老節點索引列表找到最長穩定序列
② 對於 newIndexToOldIndexMap -item =0 證明不存在老節點 ,從新形成新的vnode
③ 對於發生移動的節點進行移動處理。

三 key的作用,如何正確key。

1key的作用

在我們上述diff算法中,通過isSameVNodeType方法判斷,來判斷key是否相等判斷新老節點。
那麼由此我們可以總結出?

在v-for循環中,key的作用是:通過判斷newVnode和OldVnode的key是否相等,從而複用與新節點對應的老節點,節約性能的開銷。

2如何正確使用key

①錯誤用法 1:用index做key。

用index做key的效果實際和沒有用diff算法是一樣的,爲什麼這麼說呢,下面我就用一幅圖來說明:

在這裏插入圖片描述

如果所示當我們用index作爲key的時候,無論我們怎麼樣移動刪除節點,到了diff算法中都會從頭到尾依次patch(圖中:所有節點均未有效的複用)

②錯誤用法2 :用index拼接其他值作爲key。

當已用index拼接其他值作爲索引的時候,因爲每一個節點都找不到對應的key,導致所有的節點都不能複用,所有的新vnode都需要重新創建。都需要重新create

如圖所示。
在這裏插入圖片描述

③正確用法 :用唯一值id做key(我們可以用前後端交互的數據源的id爲key)。

如圖所示。每一個節點都做到了複用。起到了diff算法的真正作用。

在這裏插入圖片描述

四 總結

我們在上面,已經把剛開始的問題統統解決了,最後用一張思維腦圖來從新整理一下整個流程。diff算法,你學會了嗎?
在這裏插入圖片描述
微信掃碼關注公衆號,定期分享技術文章

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章