深入diff 算法
diff 作為 Virtual DOM 的加速器,其算法上的改進優化是React頁面渲染的基礎和性能保障,本節從源碼入手,深入剖析diff算法。
React 中醉值得稱道的莫過於Virtual DOM與diff的完美結合,尤其是其高效的diff算法,可以幫助我們在頁面蔌渲染的時候,計算出Virtual DOM真正變化的部分,並只針對該部分進行的原生DOM操作,而不是渲染整個頁面,從而保證了每次操作后,頁面的高效渲染。
一. 傳統的 diff 算法
計算一個樹形結構轉換成另一個樹形結構的最少操作,是一個復雜且值得研究的問題,傳統 diff 算法通過循環遞歸的方法對節點進行操作,算法復雜度 為O(n3),其中n為樹中節點的總數,這效率太低了,如果 React 只是單純的引入 diff 算法,而沒有任何的優化的話,其效率遠遠無法滿足前端渲染所需要的性能。那么React 是如何實現一個高效、穩定的 diff 算法。
二. diff 源碼解讀
React 將 Virtual DOM 樹轉換為 actual DOM 樹的最小操作的過程稱為調和, diff 算法便是調和的結果,React 通過制定大膽的策略,將 O(n3)的時間復雜度轉換成 O(n)。
1. diff 策略
下面是 React diff 算法的 3 個策略:
- 策略一:Web UI 中 DOM 節點跨層級的移動操作特別少。可以忽略不計。
- 策略二:擁有相同類的兩個組件將會生成相似的樹形結構,擁有不同類的兩個組件將會生成不同的樹形結構。
- 策略三:對於同一層級的一組子節點,它們可以通過唯一 id 進行區分。
基於以上三個策略,React 分別對 tree diff、component diff 以及 element diff 進行算法優化。
2. tree diff
對於策略一,React 對樹的算法進行了簡介明了的優化,即對樹進行分層比較,兩顆樹只會對同一層級的節點進行比較。
既然 DOM 節點跨層級的移動,可以少到忽略不計,針對這種現象,React 通過 updateDepth 對 Virtual DOM 樹進行層級控制,只對相同層級的DOM節點進行比較,即同一父節點下的所有子節點,當發現該節點已經不存在時,則該節點及其子節點會被完全刪除掉,不會用於進一步的比較。這樣只需要對樹進行一次遍歷,便能完成整個DOM樹的比較。
// updateChildren 源碼 updateChildren: function (nextNestedChildrenElements, transaction, context) { updateDepth ++; var errorThrown = true; try { this._updateChildren(nextNestedChildrenElements, transaction, context); errorThrown = false; } finally { updateDepth --; if (!updateDepth) { if (errorThrown) { clearQueue(); } else { processQueue(); } } } }
那么就會有這樣的問題:
如果出現了 DOM 節點跨層級的移動操作,diff 會有怎樣的表現喃?
我們舉個例子看一下:
如下圖2-1,A節點(包括其子節點)整個需要跨層級移動到D節點下,React會如何操作喃?
圖2-1 DOM層級變換
由於 React 只會簡單的考慮同層級節點的位置變換,對於不同層級的節點,只有創建和刪除操作。當根節點R發現子節點中A消失了,就會直接銷毀A;當D節點發現多了一個子節點A,就會創建新的A子節點(包括其子節點)。執行的操作為:
create A —> create B —> create C —> delete A
所以。當出現節點跨級移動時,並不會像想象中的那樣執行移動操作,而是以 A 為根節點的整個樹被整個重新創建,這是影響 React 性能的操作,所以 官方建議不要進行 DOM 節點跨層級的操作。
在開發組件中,保持穩定的 DOM 結構有助於性能的提升。例如,可以通過CSS隱藏或顯示節點,而不是真正的移除或添加 DOM 節點。
3. component diff
React 是基於組件構建應用的,對於組件間的比較所采取的策略也是非常簡潔、高效的。
- 如果是同一類型的組件,按照原策略繼續比較 Virtual DOM 樹即可
- 如果不是,則將該組件判斷為 dirty component,從而替換整個組件下的所有子節點
- 對於同一類型下的組件,有可能其 Virtual DOM 沒有任何變化,如果能確切知道這一點,那么就可以節省大量的 diff 算法時間。因此, React 允許用戶通過
shouldComponentUpdate()
來判斷該組件是否需要大量 diff 算法分析。
圖3-1 component diff
如上圖3-1,當 D 組件變成 G 時,即使這兩個組件結構相似,但一旦 React 判斷D和G是兩個不同類型的組件時,就不會再比較這兩個組件的結構,直接進行刪除組件D, 重新創建組件G及其子組件。雖然這兩個組件是不同類型單結構類似,diff 算法會影響性能,正如 React 官方博客所言:
不同類型的組件很少存在相似 DOM 樹的情況,因此,這種極端因素很難在實際開發過程中造成重大影響。
4. element diff
當節點處於同一層級時,diff 提供三種節點操作:
- INSERT_MARKUP(插入):如果新的組件類型不在舊集合里,即全新的節點,需要對新節點執行插入操作。
- MOVE_EXISTING (移動):舊集合中有新組件類型,且 element 是可更新的類型,generatorComponentChildren 已調用 receiveComponent,這種情況下 prevChild=nextChild,就需要做移動操作,可以復用以前的 DOM 節點。
- REMOVE_NODE (刪除):舊組件類型,在新集合里也有,但對應的 elememt 不同則不能直接復用和更新,需要執行刪除操作,或者舊組件不在新集合里的,也需要執行刪除操作。
// INSERT_MARKUP function makeInsertMarkup(markup, afterNode, toIndex) { return { type: ReactMultiChildUpdateTypes.INSERT_MARKUP, content: markup, fromIndex: null, fromNode: null, toIndex: toIndex, afterNode: afterNode } } // MOVE_EXISTING function makeMove(child, afterNode, toIndex) { return { type: ReactMultiChildUpdateTypes.MOVE_EXISTING, content: null, fromIndex: child._mountIndex, fromNode: ReactReconciler.getNativeNode(child), toIndex: toIndex, afterNode: afterNode } } // REMOVE_NODE function makeRemove(child, node) { return { type: ReactMultiChildUpdateTypes.REMOVE_NODE, content: null, fromIndex: child._mountIndex, fromNode: node, toIndex: null, afterNode: null } }
下面由三個例子加深我們的理解
例1:舊集合A、B、C、D四個節點,更新后的新集合為B、A、D、C節點,對新舊集合進行 diff 算法差異化對比,發現 B!=A,則創建並插入B節點到新集合,並刪除舊集合中A,以此類推,創建A、D、C,刪除 B、C、D。如下圖4-1
圖4-1 節點 diff
React發現這樣操作非常繁瑣冗余,因為這些集合里含有相同的節點,只是節點位置發生了變化而已,卻發生了繁瑣的刪除、創建操作,實際上只需要對這些節點進行簡單的位置移動即可。
針對這一現象,React 提出了優化策略:
允許開發者對同一層級的同組子節點,添加唯一key進行區分,雖然只是小小的改動,但性能上卻發生了翻天覆地的變化。
例2:看下圖
進行對新舊集合的 diff 差異化對比,通過 key 發現新舊集合中包含的節點是一樣的,所以可以通過簡單的位置移動就可以更新為新集合,React 給出的 diff 結果為:B、D不做任何操作,A、C移動即可。
圖4-2 對節點進行 diff 差異化對比
步驟:
-
初始化,lastIndex = 0, nextIndex = 0
-
從新集合取出節點B,發現舊集合中也有節點B,並且B.__mountIndex = 1,lastIndex = 0,不滿足 B._mountIndex < lastIndex,則不對B操作,並且更新 lastIndex= Math.max(prevChild._mountIndex, lastIndex),並將B的位置更新為新集合中的位置prevChild._mountIndex = nextIndex,即B._mountIndex = 0, nextIndex ++ 進入下一步
-
從新集合取出節點A,發現舊集合中也有節點A,並且A.__mountIndex = 0,lastIndex = 1,滿足 A._mountIndex < lastIndex,則對A進行移動操作,enqueue( updates, makeMove(prevChild, lastPlacedNode, nextIndex))並且更新 lastIndex= Math.max(prevChild._mountIndex, lastIndex),並將A的位置更新為新集合中的位置prevChild._mountIndex = nextIndex,即A._mountIndex = 1, nextIndex ++ 進入下一步
-
依次進行操作,可以根據下面代碼執行的步驟實現,這里不再贅述
操作為:
updateChildren1: function(prevChildren, nextChildren) { // 舊集合 新集合 var updates = null var name // lastIndex 是 prevChildren 中最后一個索引,nextIndex 是 nextChildren 中每個節點的索引 var lastIndex = 0 var nextIndex = 0 for (name in nextChildren) { // 對新集合的節點進行循環遍歷 if (!nextChildren.hasOwnProperty(name)) { continue } var prevChild = prevChildren && prevChildren[name] var nextChild = nextChildren[name] // 通過唯一的key判斷新舊集合是否有相同的節點 if (prevChild === nextChild) { // 新舊集合有相同的節點 // 如果子節點的 index 小於 lastIndex 則移動該節點 if (prevChild._mountIndex < lastIndex) { // 獲取移動節點 let moveNode = makeMove(prevChild, lastPlacedNode, nextIndex) // 存入差異隊列 updates = enqueue( updates, moveNode ) } // 這是一種順序優化手段,lastIndex 一直在更新表示訪問過的節點一直在prevChildren最大的位置,如果當前訪問的節點比 lastIndex 大,說明當前訪問的節點在舊結合中就比上一個節點靠后,則該節點不會影響其它節點的位置,因此不插入差異隊列,不要執行移動操作,只有訪問的節點比 lastIndex 小時,才需要進行移動操作。 // 更新lastIndex lastIndex= Math.max(prevChild._mountIndex, lastIndex) // 將prevChild的位置更新為在新集合中的位置 prevChild._mountIndex = nextIndex } else { if (prevChild) {// 如果沒有相同節點且prevChild存在 // 更新lastIndex lastIndex = Math.max(prevChild._mountIndex, lastIndex) } } // 進入下一個節點的判斷 nextIndex ++ } // 如果存在更新,則處理更新隊列 if (updates) { processQueue(this, updates) } // 更新 DOM this._renderedChildren = nextChildren } function enqueue(queue, update) { // 如果有更新,將其存入 queue if (update) { queue = queue || [] queue.push(update) } return queue } // 處理隊列的更新 function processQueue (inst, updateQueue) { ReactComponentEnvironment.processChildrenUpdates( inst, updateQueue )
}
例3:看下圖
圖4-3 創建、移動、刪除節點
可以看出在這個例子中,有新增的節點,還有需要刪除的節點,具體怎么操作,請大膽的嘗試一下吧
5. 源碼
_updateChildren: function(nextNestedChildrenElements, transaction, context) { var prevChildren = this._renderedChildren // 舊集合 var removedNodes = {} // 需要刪除的節點集合 var nextChildren = this._reconcilerUpdateChildren(prevChildren, nextNestedChildrenElements, removedNodes, transaction, context) // 新集合 // 如果不存在 prevChildren 及 nextChildren,則不做 diff 處理 if (!prevChildren && !nextChildren) { return } var updates = null var name // lastIndex 是 prevChildren 中最后一個索引,nextIndex 是 nextChildren 中每個節點的索引 var lastIndex = 0 var nextIndex = 0 var lastPlacedNode = null for (name in nextChildren) { // 對新集合的節點進行循環遍歷 if (!nextChildren.hasOwnProperty(name)) { continue } var prevChild = prevChildren && prevChildren[name] var nextChild = nextChildren[name] // 通過唯一的key判斷新舊集合是否有相同的節點 if (prevChild === nextChild) { // 新舊集合有相同的節點 // 如果子節點的 index 小於 lastIndex 則移動該節點,並加入差異隊列 updates = enqueue( updates, this.moveChild(prevChild, lastPlacedNode, nextIndex, lastIndex) )// 這是一種順序優化手段,lastIndex 一直在更新表示訪問過的節點一直在prevChildren最大的位置,如果當前訪問的節點比 lastIndex 大,說明當前訪問的節點在舊結合中就比上一個節點靠后,則該節點不會影響其它節點的位置,因此不插入差異隊列,不要執行移動操作,只有訪問的節點比 lastIndex 小時,才需要進行移動操作。 // 更新lastIndex lastIndex= Math.max(prevChild._mountIndex, lastIndex) // 將prevChild的位置更新為在新集合中的位置 prevChild._mountIndex = nextIndex } else { if (prevChild) {// 如果沒有相同節點且prevChild存在 // 更新lastIndex lastIndex = Math.max(prevChild._mountIndex, lastIndex) // 通過遍歷 removedNodes 刪除子節點 prevChild } // 初始化並創建節點 updates = enqueue( updates, this._mountChildAtIndex(nextChild, lastPlacedNode, nextIndex, transaction, context) ) } // 進入下一個節點的判斷 nextIndex ++ lastPlacedNode = ReactReconciler.getNativeNode(nextChild) } // 如果父節點不存在,則將其子節點全部移除 for (name in removedNodes) { if (removedNodes.hasOwnProperty(name)) { updates = enqueue( updates, this._unmountChild(prevChildren[name], removedNodes[name]) ) } } // 如果存在更新,則處理更新隊列 if (updates) { processQueue(this, updates) } this._renderedChildren = nextChildren } function enqueue(queue, update) { // 如果有更新,將其存入 queue if (update) { queue = queue || [] queue.push(update) } return queue } // 處理隊列的更新 function processQueue (inst, updateQueue) { ReactComponentEnvironment.processChildrenUpdates( inst, updateQueue ) } // 移動節點 moveChild: function(child, afterNode, toIndex, lastIndex) { // 如果子節點的 index 小於 lastIndex 則移動該節點 if (child