1. 前言
diff 算法是一種通過同層的樹節點進行比較的高效算法,避免了對樹進行逐層搜索遍歷,所以時間復雜度只有 O(n)。diff 算法的在很多場景下都有應用,例如在 vue 虛擬 dom 渲染成真實 dom 的新舊 VNode 節點比較更新時,就用到了該算法。diff 算法有兩個比較顯著的特點:
- 比較只會在同層級進行, 不會跨層級比較。
- 在 diff 比較的過程中,循環從兩邊向中間收攏。
2. diff 流程
本着對 diff 過程的認識和 vue 源碼的學習,我們通過 vue 源碼的解讀和實例分析來理清楚 diff 算法的整個流程,下面把整個 diff 流程拆成三步來具體分析:
第一步
vue 的虛擬 dom 渲染真實 dom 的過程中首先會對新老 VNode 的開始和結束位置進行標記:oldStartIdx、oldEndIdx、newStartIdx、newEndIdx。let oldStartIdx = 0// 舊節點開始下標
let newStartIdx = 0 // 新節點開始下標 let oldEndIdx = oldCh.length - 1 // 舊節點結束下標 let oldStartVnode = oldCh[0] // 舊節點開始 vnode let oldEndVnode = oldCh[oldEndIdx] // 舊節點結束 vnode let newEndIdx = newCh.length - 1 // 新節點結束下標 let newStartVnode = newCh[0] // 新節點開始 vnode let newEndVnode = newCh[newEndIdx] // 新節點結束 vnode
經過第一步之后,我們初始的新舊 VNode 節點如下圖所示:
第二步標記好節點位置之后,就開始進入到的 while 循環處理中,這里是 diff 算法的核心流程,分情況進行了新老節點的比較並移動對應的 VNode 節點。while 循環的退出條件是直到老節點或者新節點的開始位置大於結束位置。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { ....// 處理邏輯 }
情形一:當新老 VNode 節點的 start 滿足 sameVnode 時,直接 patchVnode 即可,同時新老 VNode 節點的開始索引都加 1。接下來具體介紹 while 循環中的處理邏輯, 循環過程中首先對新老 VNode 節點的頭尾進行比較,尋找相同節點,如果有相同節點滿足 sameVnode(可以復用的相同節點) 則直接進行 patchVnode (該方法進行節點復用處理),並且根據具體情形,移動新老節點的 VNode 索引,以便進入下一次循環處理,一共有 2 * 2 = 4 種情形。下面根據代碼展開分析:
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] }
情形三:當老 VNode 節點的 start 和新 VNode 節點的 end 滿足 sameVnode 時,這說明這次數據更新后 oldStartVnode 已經跑到了 oldEndVnode 后面去了。這時候在 patchVnode 后,還需要將當前真實 dom 節點移動到 oldEndVnode 的后面,同時老 VNode 節點開始索引加 1,新 VNode 節點的結束索引減 1。
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] }
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] }
如果都不滿足以上四種情形,那說明沒有相同的節點可以復用。於是則通過查找事先建立好的以舊的 VNode 為 key 值,對應 index 序列為 value 值的哈希表。從這個哈希表中找到與 newStartVnode 一致 key 的舊的 VNode 節點,如果兩者滿足 sameVnode 的條件,在進行 patchVnode 的同時會將這個真實 dom 移動到 oldStartVnode 對應的真實 dom 的前面;如果沒有找到,則說明當前索引下的新的 VNode 節點在舊的 VNode 隊列中不存在,無法進行節點的復用,那么就只能調用 createElm 創建一個新的 dom 節點放到當前 newStartIdx 的位置。
else {// 沒有找到相同的可以復用的節點,則新建節點處理 /* 生成一個 key 與舊 VNode 的 key 對應的哈希表(只有第一次進來 undefined 的時候會生成,也為后面檢測重復的 key 值做鋪墊) 比如 childre 是這樣的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}] beginIdx = 0 endIdx = 2 結果生成{key0: 0, key1: 1, key2: 2} */ if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) /* 如果 newStartVnode 新的 VNode 節點存在 key 並且這個 key 在 oldVnode 中能找到則返回這個節點的 idxInOld(即第幾個節點,下標)*/ idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) if (isUndef(idxInOld)) { // New element /*newStartVnode 沒有 key 或者是該 key 沒有在老節點中找到則創建一個新的節點 */ createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { /* 獲取同 key 的老節點 */ vnodeToMove = oldCh[idxInOld] if (sameVnode(vnodeToMove, newStartVnode)) { /* 如果新 VNode 與得到的有相同 key 的節點是同一個 VNode 則進行 patchVnode*/ patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) // 因為已經 patchVnode 進去了,所以將這個老節點賦值 undefined oldCh[idxInOld] = undefined /* 當有標識位 canMove 實可以直接插入 oldStartVnode 對應的真實 Dom 節點前面 */ canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) } else { // same key but different element. treat as new element /* 當新的 VNode 與找到的同樣 key 的 VNode 不是 sameVNode 的時候(比如說 tag 不一樣或者是有不一樣 type 的 input 標簽),創建一個新的節點 */ createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } } newStartVnode = newCh[++newStartIdx] }
緊接着開始第二次循環,第二次循環后,同樣是舊節點的末尾和新節點的開頭 (都是 C) 相同,同理,diff 后創建了 C 的真實節點插入到第一次創建的 B 節點后面。同時舊節點的 endIndex 移動到了 B,新節點的 startIndex 移動到了 E。
接下來第三次循環中,發現 patchVnode 的 4 種情形都不符合,於是在舊節點隊列中查找當前的新節點 E,結果發現沒有找到,這時候只能直接創建新的真實節點 E,插入到第二次創建的 C 節點之后。同時新節點的 startIndex 移動到了 A。舊節點的 startIndex 和 endIndex 都保持不動。
第四次循環中,發現了新舊節點的開頭 (都是 A) 相同,於是 diff 后創建了 A 的真實節點,插入到前一次創建的 E 節點后面。同時舊節點的 startIndex 移動到了 B,新節點的 startIndex 移動到了 B。
第五次循環中,情形同第四次循環一樣,因此 diff 后創建了 B 真實節點 插入到前一次創建的 A 節點后面。同時舊節點的 startIndex 移動到了 C,新節點的 startIndex 移動到了 F。
這時候發現新節點的 startIndex 已經大於 endIndex 了。不再滿足循環的條件了。因此結束循環,接下來走后面的邏輯。
第三步當 while 循環結束后,根據新老節點的數目不同,做相應的節點添加或者刪除。若新節點數目大於老節點則需要把多出來的節點創建出來加入到真實 dom 中,反之若老節點數目大於新節點則需要把多出來的老節點從真實 dom 中刪除。至此整個 diff 過程就已經全部完成了。
if (oldStartIdx > oldEndIdx) { /* 全部比較完成以后,發現 oldStartIdx > oldEndIdx 的話,說明老節點已經遍歷完了,新節點比老節點多, 所以這時候多出來的新節點需要一個一個創建出來加入到真實 Dom 中 */ refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) // 創建 newStartIdx - newEndIdx 之間的所有節點 } else if (newStartIdx > newEndIdx) { /* 如果全部比較完成以后發現 newStartIdx > newEndIdx,則說明新節點已經遍歷完了,老節點多於新節點,這個時候需要將多余的老節點從真實 Dom 中移除 */ removeVnodes(oldCh, oldStartIdx, oldEndIdx) // 移除 oldStartIdx - oldEndIdx 之間的所有節點 }
再回過頭看我們的實例,新節點的數目大於舊節點,需要創建 newStartIdx 和 newEndIdx 之間的所有節點。在我們的實例中就是節點 F,因此直接創建 F 節點對應的真實節點放到 B 節點后面即可。
3. 結尾
最后通過上述的源碼和實例的分析,我們完成了 Vue 中 diff 算法的完整解讀。如果想要了解更多的 Vue 源碼。歡迎進入我們的 github 地址( https://github.com/DQFE/vue )進行查看,里面對每一行 Vue 源碼都做了注釋,方便大家的理解。
作者介紹:
徐輝,滴滴軟件開發工程師
現就職於泛前端問卷項目組,負責星雲和桔研問卷相關工作,愛好廣而不精,喜歡籃球、游戲、爬山。努力工作,開心生活!
本文轉載自公眾號普惠出行產品技術(ID:pzcxtech)。
原文鏈接: