Vue之diff算法

前言

Vue通過雙向綁定來實現數據驅動視圖更新,當數據更新會觸發Dep對象notify通知所有的訂閱者Watcher對象,Watcher對象最後會調用run來執行Watcher對象的getter方法。
其中需要注意的是在掛載階段創建的一個Watcher對象的getter就是用於updateComponent,其中最主要方法就是調用Vue.prototype._update,而該實例方法主要進行調用patch函數進行節點的diff比較。

patch函數邏輯

通過閱讀Vue源碼可知,patch函數的邏輯實際上主要就是比較新舊vnode創建相關的DOM,主要邏輯分爲如下2點:

  1. 判斷新的vnode是否存在,不存在做相關處理
  2. 判斷oldVnode是否存在,不存在則意味着第一次掛載
新的vnode不存在

新的vnode不存在時,意味着當前頁面應該是空的,此時執行的邏輯有2點:

  1. 判斷oldVnode是否存在,存在調用其destorr生命週期銷燬掉
  2. 直接return退出整個patch函數
oldVnode是否存在

oldVnode是否存在決定了處理邏輯的不同,當oldVnode不存在時,即意味着第一次掛載,處理邏輯只需要:

依據當前虛擬節點vnode來生成真實DOM,即調用createElm

當oldVnode存在時,實際上需要判斷是SSR還是客戶端渲染已作進一步的處理。

  var isRealElement = isDef(oldVnode.nodeType);
  if (!isRealElement && sameVnode(oldVnode, vnode)) {
     // patch existing root node
     patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
   } else {
   	// 其他的處理邏輯,實際上主要是調用createElm創建真實DOM
   }

本文關注的是patchVnode的具體邏輯,這裏是diff算法的核心邏輯。

在具體關注diff算法前,看下sameVnode的邏輯,Vue diff處理的前提是:

客戶端渲染 + 相同節點

那何謂相同節點?直接看源碼:

  function sameVnode (a, b) {
    return (
    	a.key === b.key && (
	      (
	        a.tag === b.tag &&
	        a.isComment === b.isComment &&
	        isDef(a.data) === isDef(b.data) &&
	        sameInputType(a, b)
	      ) || (
	      	// 這裏可以暫不考慮相關邏輯
	        isTrue(a.isAsyncPlaceholder) &&
	        a.asyncFactory === b.asyncFactory &&
	        isUndef(b.asyncFactory.error)
	      )
	    )
	)
  }

從上面代碼可以得到Vue判斷相同節點的邏輯:

  1. 虛擬節點vnode對象的key值相同
  2. 相同標籤 + 是否是註釋節點 + data都存在或都不存在 + 如果是input標籤必須相同

diff算法執行的前提是新舊節點必須相同

diff比較算法

diff比較算法的核心邏輯都是在patchVnode函數中,具體邏輯如下:
在這裏插入圖片描述

Vue diff算法是按層來處理每一個節點的,而這裏需要注意的邏輯就是子節點的處理,這裏是關鍵:

  • 如果新舊vnode都存在子節點且不相同,會調用updateChildren來處理子節點的diff比較
  • 如果僅新vnode存在子節點,調用addVnodes添加相關節點
  • 如果僅舊vnode存在子節點,調用removeVnodes移除所有子節點內容
  • 如果都不存在子節點但舊vnode存在文本內容而新vnode不存在文本內容,設置新vnode內容爲空字符串

updateChildren函數是實現層序比較的關鍵,而實際上層序diff的實現也是由於updateChildren內部調用了patchVnode函數,形成了遞歸調用。

updateChildren函數

該函數的邏輯實際上使用迭代來實現新舊節點數組的遍歷比較,以做到儘可能複用節點的DOM即最小化DOM的創建。
其主要邏輯可以簡述爲:

/*
	- oldStartIndex、oldEndIndex:處理舊節點數組遍歷的雙指針參數
	- newStartIndex、newEndIndex:處理新節點數組遍歷的雙指針參數
*/
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
	if (isUndef(oldStartVnode)) {
		oldStartVnode = oldCh[++oldStartIdx];
     } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx];
     } else if (sameVnode(oldStartVnode, newStartVnode)) {
     	// patchVnode相關操作
     } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // patchVnode相關操作
     } else if (sameVnode(oldStartVnode, newEndVnode)) {
     	// patchVnode相關操作
     } else if (sameVnode(oldEndVnode, newStartVnode)) {
     	// patchVnode相關操作
     } else {
     	// 非上述情況的比較邏輯
     }
}
// 針對不同情況新增新元素或刪除舊元素
if (oldStartIdx > oldEndIdx) {
	// 針對新節點數組元素addVnodes操作
} else if (newStartIdx > newEndIdx) {
   	// 針對舊節點數組元素removeVnodes
}

子組件的比較看似邏輯比較多,實際上邏輯可以分爲2類:

  • 特殊邏輯:新舊首首比較、新舊尾尾比較、新首舊尾比較、新尾舊首比較
  • 正常邏輯順序比較

實際上diff算法目的非常簡單:

就是找到兩個數組中相同節點值並複用它

其實現思路的核心在於:

新節點數組就是當前頁面上需要顯示的,以此爲依據來對比新舊數組

假設自己去實現的話,常規的做法可以有:

// 實現1:以新節點數組爲基礎遍歷,查找舊節點數組是否存在相同節點對象
for (let i = 0; i < newArray.length; i++) {
	for (let j = 0; j < oldArray.length; j++) {
		// 判斷是否相同
	}
}
// 

進一步優化使用雙指針方式來處理,可以減少遍歷次數:

let newStartIndex = 0;
let newEndIndex = newArray.length - 1;
let newStartVnode, newEndVnode;
for (;newStartIndex <= newEndIndex;newStartIndex++, newEndIndex--) {
	newStartVnode = newArray[newStartIndex];
	newEndVnode = newArray[newEndIndex];
	for (let j = 0; j < oldArray.length; j++) {
		// 相關比較處理
	}
}

實際上這裏還是存在優化空間的,即內部循環也可以採用雙指針形式來實現,由此可以延伸到Vue diff算法的實現:新舊節點數組都採用雙指針方式來遍歷,而額外邏輯是如何更加高效的找到相同節點,爲了更加高效Vue diff算法做了大概下面2點的優化:

  • 每一次遍歷優先對首尾元素進行兩兩比較
  • 如果首尾兩兩比較判斷不是相同節點,則會依據舊節點數組生成一個map來保存一定範圍的組件key集合,便於進一步複用相同舊節點對象

Vue Diff算法去閱讀理解上會比較抽象,而抽象的來源個人感覺是如何控制4個指針參數的變化來保證不會有比較上的遺漏。
從源碼上去了解4個下標參數有如下5點的處理:

  • sameVnode(oldStartVnode, newStartVnode)

    新節點數組首元素與舊節點數組首元素比較,如果元素相同此時只處理newStartIndex和oldStartIndex,都是遞增操作

  • sameVnode(oldEndVnode, newEndVnode)

    新節點數組尾元素與舊節點數組尾元素比較,如果元素相同此時只處理newEndIndex和oldEndIndex,都是遞減操作

  • sameVnode(oldStartVnode, newEndVnode))

    新節點數組尾元素與舊節點數組首元素比較,如果元素相同此時newEndIndex遞減,oldStartIndex遞增

  • sameVnode(oldEndVnode, newStartVnode))

    新節點數組首元素與舊節點數組尾元素比較,如果元素相同此時oldEndIndex遞減,newStartIndex遞增

  • 正常邏輯的處理

    首尾元素比較沒有相同,則按照正常的比較邏輯,以當前newStartIndex對應的下標開始順序比較,遞增newStartIndex

通過上面的拆解可以有一個大概的思路了,接下來通過一個案例來描述Vue Diff算法大概的過程:

// 舊節點數組old -> 新節點數組new
[a, b, c, d, i, g] -> [b, a, a, f, d]

開始執行diff比較:

  1. oldStartIndex = 0,oldEndIndex = 5,newStartIndex = 0,newEndIndex = 4

    此時a和b節點不滿足首尾sameVNode的比較,所以執行正常順序邏輯(這裏會查找舊節點數組是否有b來複用,如果複用b後此時需要注意有一個額外邏輯,將old數組中b值對應下標對應的值設置爲undefined),newStartIndex++,old數組變成了[a, undefined, c, d, i, g]

  2. oldStartIndex = 0, oldEndIndex = 5,newStartIndex = 1,newEndIndex = 4

    此時是old數組中第一個元素a 和 new數組中第2個元素a比較,滿足首首相同的條件,此時oldStartIndex++,newStartIndex++

  3. oldStartIndex = 1,oldEndIndex = 5,newStartIndex = 2,newEndIndex = 4

    此時old數組中第2個元素b 和 new數組中第3個元素a比較,不滿足首尾的sameVnode條件,所以執行正常順序邏輯(會再次複用a,此時需要注意有一個額外邏輯,將old數組中a值對應下標對應的值設置爲undefined),此時newStartIndex++,而old數組變成了[undefined, undefined, c, d, i, g]

  4. oldStartIndex = 1,oldEndIndex = 5,newStartIndex = 3,newEndIndex = 4

    此時old數組中第2個元素b和new數組中第4個元素f比較,不滿足首尾sameVnode條件,執行正常順序邏輯(查找舊數組中是否有f,沒有則新創建一個節點f),newStartIndex++

  5. oldStartIndex = 1,oldEndIndex = 5,newStartIndex = 4,newEndIndex = 4

    此時old數組中第2個元素b和new數組中第4個元素d比較,不滿足首尾的sameVnode條件,所以執行正常順序邏輯(查找舊節點數組中是否存在d,存在複用不存在則新建,存在的話會將old數組中d對應下標對應的值設置爲undefined),newStartIndex++,此時old數組變成了[undefined, undefined, c, undefined, i, g]

  6. 循環結束

當實例循環結束時,相應的數組下標停留在:

  • oldStartIndex = 1,oldEndIndex = 5
  • newStartIndex = 5,newEndIndex = 4

當迭代都執行完後此時Vue Diff算法還有一個額外的邏輯執行:

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);
}

依據迭代結束後newStartIndex > newEndIndex,會執行舊節點數組相關節點移除動作:

上面實例迭代結後舊節點數組爲[undefined, undefined, c, undefined, i, g]

可以看到都是沒有使用的節點,執行removeVnodes函數,該函數的邏輯如下:

function removeVnodes (vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
  	var ch = vnodes[startIdx];
  	// 節點存在
    if (isDef(ch)) {
    	// 標籤的話執行銷燬邏輯
    	if (isDef(ch.tag)) {
        	removeAndInvokeRemoveHook(ch);
        	invokeDestroyHook(ch);
        } else { // Text node
           	removeNode(ch.elm);
        }
     }
   }
}

至此完成一層的迭代處理,而多層結構就是通過遞歸調用patchVnode來實現的,重複上面整個過程。

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