全面解析 vue3.0 diff算法

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

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

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

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

一 何時用到了diff算法,diff算法做用域?

1.1diff算法的做用域

patch概念引入算法

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

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算法,你學會了嗎? 在這裏插入圖片描述 微信掃碼關注公衆號,按期分享技術文章

在這裏插入圖片描述

相關文章
相關標籤/搜索