Vue 3 Virtual Dom Diff源碼閱讀

前言

學完了React、Vue2的diff算法,又到了學Vue3的時候了,Vue3出來了一段時間,不瞭解一下說不過去~
這篇文章主要分爲兩部分:
1、diff算法大致的流程和實現思路
2、深刻源碼,看看具體的實現html

核心diff思路

diff (2).png
咱們都知道,一般咱們對比時只有當是相同的父元素時,只有當父元素是相同的節點時,纔會往下遍歷。那咱們假設他們的父節點是相同的,直接開始進行子節點們的比較。爲了區分不一樣的場景下的思路,每個部分都會舉的不一樣的例子。vue

預處理優化

咱們先來看一下下面這兩組簡單的節點對比,在Vue3中首先會進行頭尾的遍歷,進行預處理優化。node

一、從頭開始遍歷

首先會遍歷開始節點,判斷新老的第一個節點是否一致,一致的話,執行patch方法更新差別,而後往下繼續比較,不然break跳出。能夠看到下圖中,A vs A 是同樣的,而後去比較B,B也是同樣的,而後去比較C vs D,發現不同了,因而跳出當前循環。
image.pnggit

二、尾部開始

接着咱們開始從後往前遍歷,也是找相同的元素,G vs G,一致,那麼執行patch後往前對比,F vs F一致,繼續往前diff,直到E和C不一致,跳出循環。
image.pnggithub

三、一方已經處理完畢

目前新節點還剩下一個新增節點,那麼咱們就會去判斷是否老節點遍歷完畢,而後新增它。下圖的C節點則是要新增。
若是是老節點還剩下一個多餘節點,則會去判斷新節點是否遍歷完畢,而後卸載它。下圖的I節點則是要卸載。
image.png算法


到了這一步,確定有人想問,爲何要這麼作呢?

但其實你們直覺都知道是爲何,平時咱們在修改列表的時候,有一些比較常見場景,好比說列表中間節點的增長、刪除、修改等,若是使用了這樣的方式查找,能夠減小diff的時間,甚至能夠不用diff來達到咱們想要的結果,而且還能夠減小後續diff的複雜度。這個預處理優化策略,是Neil Fraser提出的。數組


這裏應該都有了一些瞭解,那麼接下來尚未走到的場景是新老節點都還剩餘有多個子節點存在的狀況。那咱們再想想,若是是咱們去作這樣的一個需求,咱們會怎麼作呢?dom

我第一時間想到了Vue2的方式,新老節點去遍歷查找而後進行移動。可是若是這樣的話,好像跟Vue2相比好像不必定更好。在Vue2遍歷時,咱們使用的是交叉遍歷的方式。那這種方式解決的主要是什麼問題呢?舉個簡單的例子:
image.png
這個例子若是在咱們剛剛的流程裏,是不會作任何操做的,可是Vue2去遍歷的時候會進行交叉首尾遍歷,而後一個個的匹配到,而且在第一次匹配到G節點的時候,就會把G節點移動到A節點前面,後續匹配ABF節點的時候,只須要去patch,可是不須要move了,由於將G節點移動到A前面後,真實DOM節點的順序就已經與新節點一致了。
按照前面我去遍歷的思路,須要移動四次,如圖:
image.pngoop

那麼問題來了,接下來該怎麼作可以在以前優化的基礎上繼續優化呢?優化

好像咱們找到持續遞增的那列節點,就知道哪些節點是能夠穩定不變的。
這裏引入一個概念,叫最長遞增子序列。
官方解釋:在一個給定的數組中,找到一組遞增的數值,而且長度儘量的大。
有點比較難理解,那來看具體例子:

const arr = [10, 9, 2, 5, 3, 7, 101, 18]
=> [2, 3, 7, 18, 30]
這一列數組就是arr的最長遞增子序列

想更深刻了解它能夠看一下這道題:最長增加子序列

因此若是咱們可以找到,老節點在新節點序列中順序不變的節點們,就知道,哪一些節點不須要移動,而後只須要把不在這裏的節點插入進來就能夠了。在此以前,咱們先把全部節點都找到,在找到對應的序列。

四、找到新節點對應的老節點座標

最後一個新例子,要diff這兩組節點,上面是老節點,下面是新節點~經過上面的鋪墊,咱們得知了,要找到這樣一個數組[2, 3, 新增, 0],不過由於數組的初始值是0,表明的是新增的意思,因此咱們將這個座標+1,新增的變爲0,也就是[3, 4, 0, 1],咱們能夠當作第1位,第2位,第3位的意思。
image.png

找到這個數組就很簡單了,咱們先遍歷老節點,找到對應的新節點,而後加入到新節點對應的座標上。咱們開始遍歷了,在遍歷過程當中,會執行patch和卸載操做,以下圖表格:

當前老座標下標 當前找到的新節點座標 新節點座標下所對應的舊節點數組(初始值爲0,表明新增,加進來座標+1)
0 3 [0, 0, 0, 1]
1 卸載
2 0 [3, 0, 0, 1]
3 1 [3, 4, 0, 1]

遍歷完數組後,最後獲得的數組爲[3, 4, 0, 1],而後咱們會找到它的最長增加子序列爲3, 4,它所對應的是第一個節點D和第二個節點E,因此這兩個節點是不須要動的。
最後咱們再遍歷新節點,若是咱們當前的節點與在最長增加子序列中,則不移動,爲0則直接新增,剩下的則移動到當前位置。

到這裏大體的流程就已經結束了,咱們在跟着源碼進行一次深刻的瞭解吧~

源碼

源碼文件路徑:packages/runtime-core/src/renderer.ts
源碼倉庫地址: vue-next

patchChildren

咱們從patchChildren方法開始,進行子節點之間的比較。

const patchChildren: PatchChildrenFn = () => {
    // 得到當前新舊節點下的子節點們
    const c1 = n1 && n1.children
    const prevShapeFlag = n1 ? n1.shapeFlag : 0
    const c2 = n2.children

    const { patchFlag, shapeFlag } = n2
    // fragment有兩種類型的靜態標記:子節點有key、子節點無key
    if (patchFlag > 0) {
      if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
        // 子節點所有或者部分有key
        patchKeyedChildren()
        return
      } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
        // 子節點沒有key
        patchUnkeyedChildren()
        return
      }
    }

    // 子節點有三種可能:文本節點、數組(至少一個子節點)、沒有子節點
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      // 匹配到當前是文本節點:卸載以前的節點,爲其設置文本節點
      unmountChildren()
      hostSetElementText()
    } else {
      // old子節點是數組
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 如今(new)也是數組(至少一個子節點),直接full diff(調用patchKeyedChildren())
        } else {
          // 不然當前沒有子節點,直接卸載當前全部的子節點
          unmountChildren()
        }
      } else {
        // old的子節點是文本或者沒有
        if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
          // 清空文本
          hostSetElementText(container, '')
        }
        // 如今(new)的節點是多個子節點,直接新增
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 新建子節點
          mountChildren()
        }
      }
    }
  }

咱們能夠直接用文本描述一下這段代碼:
一、得到當前新舊節點下的子節點們(c一、c2)
二、使用patchFlag進行按位與判斷fragment的子節點是否有key(patchFlag是什麼稍後下面說)
三、無論有沒有key,只要匹配成功必定是數組,有key/部分有key則調用patchKeyedChildren方法進行diff計算,無key則調用patchUnkeyedChildren方法
四、不是fragment節點,那麼子節點有三種可能:文本節點、數組(至少一個子節點)、沒有子節點
五、若是new的子節點是文本節點:old有子節點的話則直接進行卸載,併爲其設置文本節點
六、不然new的子節點是數組 or 無節點,在這個基礎上:
七、若是old的子節點爲數組,那麼new的子節點也是數組的話,調用patchKeyedChildren方法,直接full diff,不然new沒有子節點,直接進行卸載
八、最後old的子節點爲文本節點 or 沒有節點(此時新節點可能爲數組,也可能沒有節點),因此當old的子節點爲文本節點,那麼則清空文本,new節點若是是數組的話,直接新增
九、此時全部的狀況已經處理完畢了,不過真正的diff還沒開始,那咱們來看一下沒有key的狀況下,是否進行diff的

patchUnkeyedChildren

沒有key的處理比較簡單,直接上刪減版源碼

const 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
    // 遍歷新舊節點,進行patch
    for (i = 0; i < commonLength; i++) {
      // 若是新節點已經掛載過了(已通過了各類處理),則直接clone一份,不然建立一個新的vnode節點
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      patch()
    }
    // 若是舊節點的數量大於新節點數量
    if (oldLength > newLength) {
      // 直接卸載多餘的節點
      unmountChildren( )
    } else {
      // old length < new length => 直接進行建立
      mountChildren()
    }
  }

咱們繼續文本描述一下邏輯:
一、首先會拿到新舊節點的最短公共長度
二、而後遍歷公共部分,直接進行patch
三、若是舊節點的數量大於新節點數量,直接卸載多餘的節點,不然新建節點

patchKeyedChildren

到了Diff算法比較核心的部分,咱們先看一個大概預覽,瞭解一下流程~再把patchKeyedChildren源碼內部拆分一下,逐步來看。

const patchKeyedChildren = () => {
    let i = 0
    const l2 = c2.length
    let e1 = c1.length - 1 // prev ending index
    let e2 = l2 - 1 // next ending index

    // 1. 進行頭部遍歷,遇到相同節點則繼續,不一樣節點則跳出循環
    while (i <= e1 && i <= e2) {}

    // 2. 進行尾部遍歷,遇到相同節點則繼續,不一樣節點則跳出循環
    while (i <= e1 && i <= e2) {}

    // 3. 若是舊節點已遍歷完畢,而且新節點還有剩餘,則遍歷剩下的進行新增
    if (i > e1) {
      if (i <= e2) {}
    }

    // 4. 若是新節點已遍歷完畢,而且舊節點還有剩餘,則直接卸載
    else if (i > e2) {
      while (i <= e1) {}
    }

    // 5. 新舊節點都存在未遍歷完的狀況
    else {
      // 5.1 建立一個map,爲剩餘的新節點存儲鍵值對,映射關係:key => index
      // 5.2 遍歷剩下的舊節點,新舊數據對比,移除不使用的舊節點
      // 5.3 拿到最長遞增子序列進行move or 新增掛載
    }
  }

一、第一步是進行頭部遍歷,遇到相同節點則繼續,下標 + 1,不一樣節點則跳出循環

// 1. sync from start
    // (a b) c
    // (a b) d e
    while (i <= e1 && i <= e2) {
      const n1 = c1[i]
      // 若是新節點已經掛載過了(已經經歷了各類處理),則直接clone一份,不然建立一個新的vnode節點
      const n2 = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      // 相同節點,則繼續執行patch方法  
      if (isSameVNodeType(n1, n2)) {
        patch()
      } else {
        break
      }
      i++
    }

1.png

此時i = 2, e1 = 6, e2 = 7, 舊節點剩下C、D、E、F、G,新節點剩下D、E、I、C、F、G

這裏判斷是否爲相同節點的方法isSameVNodeType,是經過類型和key來進行判斷,在Vue2中是經過key和sel(屬性選擇器)來判斷是不是相同元素。這裏的類型指的是ShapeFlag,也是一個標誌位,是對元素的類型進行不一樣的分類,好比:元素、組件、fragment、插槽等等

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

二、第二步是進行尾部遍歷,遇到相同節點則繼續,length - 1,不一樣節點則跳出循環

// 2. sync from end
    // 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()
      } else {
          break
      }
      e1--
      e2--
    }

2.png

此時i = 2, e1 = 4, e2 = 5, 舊節點剩下C、D、E,新節點剩下D、E、I、C

三、若是舊節點已遍歷完畢,而且新節點還有剩餘,則遍歷剩下的進行新增

// 3.common sequence + mount
    // (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, c2[i]) // 節點新增(僞代碼)
          i++
        }
      }
    }

由於咱們上面的圖例(i < e1)走不到這段邏輯,因此咱們能夠直接看一下代碼註釋(註釋真的寫得很是詳細了,patchKeyedChildren裏面的原註釋我都保留了)。若是舊節點遍歷完畢,開頭或者尾部還剩下了新節點,則進行節點新增(經過傳參,patch內部會處理)。

四、若是新節點已經遍歷完畢,則說明多餘的節點須要卸載

// 4.common sequence + unmount
    // (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++
      }
    }

由於咱們上面的圖例(i < e2)依然走不到這段邏輯,因此咱們能夠繼續看一下原註釋。i > e2意味着新節點遍歷完畢,若是新節點遍歷完畢,開頭或者尾部還剩下了舊節點,則進行節點卸載unmount

五、新舊節點都沒有遍歷完成的狀況

// 5. unknown sequence
    // [i ... e1 + 1]: a b [c d e] f g
    // [i ... e2 + 1]: a b [e d c h] f g
    // i = 2, e1 = 4, e2 = 5
    else {
      const s1 = i // prev starting index
      const s2 = i // next starting index
      
      ...
    }

按照上面圖的例子來看,s1 = 2, s2 = 2,舊節點剩下C、D、E,新節點剩下D、E、I、C須要繼續進行diff

5.一、生成map對象,經過鍵值對的方式存儲新節點的key => index

// 5.1 build key:index map for newChildren
      // 建立一個空的map對象
      const keyToNewIndexMap = new Map()
      // 遍歷剩下沒有patch的新節點,也就是D、E、I、H
      for (i = s2; i <= e2; i++) {
        const nextChild = (c2[i] = optimized
          ? cloneIfMounted(c2[i] as VNode)
          : normalizeVNode(c2[i]))
        // 若是剩餘的新節點有key的話,則將其存儲起來,key對應index
        if (nextChild.key != null) {
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }

執行完上面的方法,獲得keyToNewIndexMap = {D => 2, E => 3, I => 4, C => 5},keyToNewIndexMap主要用來幹嗎呢~請繼續往下看

5.二、遍歷剩下的舊節點,新舊數據對比,移除不使用的舊節點

// 5.2 loop through old children left to be patched and try to patch
      // matching nodes & remove nodes that are no longer present
      
      let j
      // 記錄即將被patch過的新節點數量
      let patched = 0
      // 拿到剩下要遍歷的新節點的長度,按照上面的圖示toBePatched = 4
      const toBePatched = e2 - s2 + 1
      // 是否發生過移動
      let moved = false
      // 用於跟蹤是否有任何節點移動
      let maxNewIndexSoFar = 0
      
      // works as Map<newIndex, oldIndex>
      // 注意:舊節點 oldIndex偏移量 + 1
      // 而且oldIndex = 0是一個特殊值,表明新節點沒有對應的舊節點
      // newIndexToOldIndexMap主要做用於最長增加子序列
      // newIndexToOldIndexMap從變量名能夠看出,它表明的是新舊節點的對應關係
      const newIndexToOldIndexMap = new Array(toBePatched)
      
      for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
      // 此時newIndexToOldIndexMap = [0, 0, 0, 0]
      // 遍歷剩餘舊節點的長度
      for (i = s1; i <= e1; i++) {
        const prevChild = c1[i]
        if (patched >= toBePatched) {
          // patched大於剩餘新節點的長度時,表明當前全部新節點已經patch了,所以剩下的節點只能卸載
          unmount(prevChild, parentComponent, parentSuspense, true)
          continue
        }
        let newIndex
        if (prevChild.key != null) {
          // 舊節點的key存在的話,則經過舊節點的key找到對應的新節點的index位置下標
          newIndex = keyToNewIndexMap.get(prevChild.key)
        } else {
          // 舊節點沒有key的話,則遍歷全部的新節點
          for (j = s2; j <= e2; j++) {
            // newIndexToOldIndexMap[j - s2]若是等於0的話
            // 表明當前新節點尚未被patch,由於在下面的運算中
            // 若是找到新節點對應的舊節點位置,newIndexToOldIndexMap[j - s2]則會等於舊節點的下標 + 1
            if (
              newIndexToOldIndexMap[j - s2] === 0 &&
              isSameVNodeType(prevChild, c2[j] as VNode)
            ) {
              // 當前新節點尚未被找到,並新舊節點相同,則將新節點的位置賦予newIndex
              newIndex = j
              break
            }
          }
        }
        
        if (newIndex === undefined) {
          // 當前舊節點沒有找到對應的新節點,則進行卸載
          unmount(prevChild, parentComponent, parentSuspense, true)
        } else {
          // 找到了對應的新節點,則將舊節點的位置存儲在對應的新節點下標
          newIndexToOldIndexMap[newIndex - s2] = i + 1
          // maxNewIndexSoFar若是不是逐級遞增,則表明有新節點的位置前移了,那麼須要進行移動
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex
          } else {
            moved = true
          }
          // 更新節點差別
          patch()
          // 找到一個對應的新節點,+1
          patched++
        }
      }

這段代碼比較長,可是總的來講作了下面幾件事:
一、拿到新節點對應的舊節點下標newIndexToOldIndexMap(下標+1,由於0表明的是新節點沒有對應的舊節點,直接建立新節點),在咱們的圖例中newIndexToOldIndexMap = [4, 5, 0, 3]

二、存在在遍歷的過程當中,若是老節點找到對應的新節點,則進行打補丁,更新節點差別,找不到則刪除該老節點

3️、經過新節點下標的順序是否遞增來判斷,是否有節點發生過移動

5.三、對剩下沒有找到的新節點進行掛載,對須要移動的節點進行移動

// 5.3 move and mount
      // 僅在有節點須要移動的時候才生成最長遞增子序列
      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : EMPTY_ARR
      j = increasingNewIndexSequence.length - 1
      // 此時圖示中的increasingNewIndexSequence = [4, 5]
      // 從後面開始遍歷,將最後一個patch的節點用做錨點
      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( )
        } else if (moved) {
          // 移動的條件:當前最長子序列的length小於0(沒有穩定的子序列),或者當前的節點不在穩定的序列中
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
            move(nextChild, container, anchor, MoveType.REORDER)
          } else {
            j--
          }
        }
      }

最後這段源碼用到了一個優化方法,最長上升子序列,這段大體的流程就是:
一、經過moved來判斷當前是否有節點進行了移動,若是有的話則經過getSequence(newIndexToOldIndexMap)拿到最長上升子序列,咱們的圖示中拿到的是increasingNewIndexSequence = [4, 5]

二、遍歷剩餘新節點的長度,從後面開始遍歷,判斷newIndexToOldIndexMap[i] === 0,當前的新節點是否有對應的老節點,若是等於0,就是沒有,直接新增。

三、不然經過moved判斷是否有移動,有移動的話,若是當前最長子序列的length小於0,或者當前的節點不在穩定的序列中,則意味着如今沒有穩定的子序列,每一個節點須要進行移動,或者,最後一個新節點,不在末尾的子序列中,子序列的末尾另有他人,那當前也須要進行移動。如果不符合移動的條件,則說明當前新節點在最長上升子序列中,不須要進行移動,只用等待別的節點去移動。

到這裏,diff算法的核心流程就瞭解得差很少了~有機會再把最長子序列求解補上。

參考資料:
源碼: https://github.com/vuejs/vue-...
diff優化策略: https://neil.fraser.name/writ...
inforno: https://github.com/infernojs/inferno
https://blog.csdn.net/u014125...
https://zhuanlan.zhihu.com/p/...
https://hustyichi.github.io/2...
https://www.cnblogs.com/Windr...
相關文章
相關標籤/搜索