淺談React中的diff

sex_squirrel發表於2018-04-03

簡介

diff演算法在React中處於主導地位,是React V-dom和渲染的效能保證,這也是React最有魅力、最吸引人的地方。
React一個很大一個的設計有點就是將diff和V-dom的完美結合,而高效的diff演算法可以讓使用者更加自由的重新整理頁面,讓開發者也能遠離原生dom操作,更加開心的擼程式碼。
但總所周知,普適diff的複雜度對於大量dom對比會出現嚴重的效能問題,React團隊對diff的優化可以讓React能夠在服務端渲染,到底React的diff做了什麼優化呢?本文來簡單探討一下!

React的diff策略

  1. 策略一:忽略Web UI中DOM節點跨層級移動;
  2. 策略二:擁有相同型別的兩個元件產生的DOM結構也是相似的,不同型別的兩個元件產生的DOM結構則不近相同
  3. 策略三:對於同一層級的一組子節點,通過分配唯一唯一id進行區分(key值) 在Web UI的場景下,基於以上三個點,React對tree diff、component diff、element diff進行優化,將普適diff的複雜度降低到一個數量級,保證了整體UI介面的構建效能!

三個優化

tree diff

基於策略一,React的做法是把dom tree分層級,對於兩個dom tree只比較同一層次的節點,忽略Dom中節點跨層級移動操作,只對同一個父節點下的所有的子節點進行比較。如果對比發現該父節點不存在則直接刪除該節點下所有子節點,不會做進一步比較,這樣只需要對dom tree進行一次遍歷就完成了兩個tree的比較。
==那麼對於跨層級的dom操作是怎麼進行處理的呢?==下面通過一個圖例進行闡述

淺談React中的diff

兩個tree進行對比,右邊的新tree發現A節點已經沒有了,則會直接銷燬A以及下面的子節點B、C;在D節點上面發現多了一個A節點,則會重新建立一個新的A節點以及相應的子節點。
具體的操作順序:create A → create B → creact C → delete A。

優化建議

保證穩定dom結構有利於提升效能,不建議頻繁真正的移除或者新增節點
複製程式碼

component diff

React應用是基於元件構建的,對於元件的比較優化側重於以下幾點:
1. 同一型別元件遵從tree diff比較v-dom樹 2. 不通型別元件,先將該元件歸類為dirty component,替換下整個元件下的所有子節點 3. 同一型別元件Virtual Dom沒有變化,React允許開發者使用shouldComponentUpdate()來判斷該元件是否進行diff,運用得當可以節省diff計算時間,提升效能

淺談React中的diff

如上圖,當元件D → 元件G時,diff判斷為不同型別的元件,雖然它們的結構相似甚至一樣,diff仍然不會比較二者結構,會直接銷燬D及其子節點,然後新建一個G相關的子tree,這顯然會影響效能,官方雖然認定這種情況極少出現,但是開發中的這種現象造成的影響是非常大的。

優化建議

對於同一型別元件合理使用shouldComponentUpdate(),應該避免結構相同型別不同的元件
複製程式碼

element diff

對於同一層級的element節點,diff提供了以下3種節點操作:
1. INSERT_MARKUP 插入節點:對全新節點執行節點插入操作 2. MOVE_EXISING 移動節點:元件新集合中有元件舊集合中的型別,且element可更新,即元件呼叫了receiveComponent,這時可以複用之前的dom,執行dom移動操作 3. REMOVE_NODE 移除節點:此時有兩種情況:元件新集合中有元件舊集合中的型別,但對應的element不可更新、舊組建不在新集合裡面,這兩種情況需要執行節點刪除操作

key值diff中重要性

淺談React中的diff

一般diff在比較集合[A,B,C,D]和[B,A,D,C]的時候會進行全部對比,即按對應位置逐個比較,發現每個位置對應的元素都有所更新,則把舊集合全部移除,替換成新的集合,如上圖,但是這樣的操作在React中顯然是複雜、低效、影響效能的操作,因為新集合中所有的元素都可以進行復用,無需刪除重新建立,耗費效能和記憶體,只需要移動元素位置即可。 React對這一現象做出了一個高效的策略:允許開發者對同一層級的同組子節點新增唯一key值進行區分。意義就是程式碼上的一小步,效能上的一大步,甚至是翻天覆地的變化!

==重點來了,React通過key是如何進行element管理的呢?為何如此高效?==

演算法改進:
React會先進行新集合遍歷,for(name in nextChildren),通過key值判斷兩個對比集合中是否存在相同的節點,即if(prevChild === nextChild),如何為true則進行移動操作,在此之前,需要執行被移動節點在新舊(child._mountIndex)集合中的位置比較,if(child._mountIndex < lastIndex)為true時進行移動,否則不執行該操作,這實際上是一種順序優化,lastIndex是不斷更新的,表示訪問過的節點在集合中的最右的位置。若當前訪問節點在舊集合中的位置比lastIndex大,即靠右,說明它不會影響其他元素的位置,因此不用新增到差異佇列中,不執行移動操作,反之則進行移動操作。

下圖示例:

淺談React中的diff

  • nextIndex = 0,lastIndex = 0,從新集合中獲取B,在舊集合中發現相同節點B,舊集合中:B._mountIndex = 1,child._mountIndex < lastIndex ==> false,不執行移動操作,更新lastIndex = Math.max(prevChild._mountIndex, lastIndex), prevChild._mountIndex === B._mountIndex ==> true,更新B在新集合中的位置:prevChild._mountIndex = nextIndex,在新集合中:B._mountIndex = 0,nextIndex++,進行下一個節點判斷。

  • nextIndex = 1,lastIndex = 1,從新集合中獲取A,在舊集合中發現相同節點A,舊集合中:A._mountIndex = 0,child._mountIndex < lastIndex ==> true,對A進行移動操作enqueueMove(this, child._mountIndex, toIndex),toIndex是A要被移動到的位置,更新lastIndex = Math.max(prevChild._mountIndex, lastIndex),更新A在新集合中的位置prevChild._mountIndex = nextIndex,在新集合中:A._mountIndex = 1,nextIndex++,進行下一個節點判斷。

  • nextIndex = 2,lastIndex = 1,從新集合中獲取D,在舊集合中發現相同節點D,舊集合中:D._mountIndex = 3,child._mountIndex < lastIndex ==> false,不執行移動操作,更新lastIndex = Math.max(prevChild._mountIndex, lastIndex), prevChild._mountIndex === D._mountIndex ==> true,更新D在新集合中的位置:prevChild._mountIndex = nextIndex,在新集合中:D._mountIndex = 2,nextIndex++,進行下一個節點判斷。

  • nextIndex = 3,lastIndex = 3,從新集合中獲取C,在舊集合中發現相同節點C,舊集合中:C._mountIndex = 2,child._mountIndex < lastIndex ==> true,對C進行移動操作enqueueMove(this, child._mountIndex, toIndex),toIndex是C要被移動到的位置,更新lastIndex = Math.max(prevChild._mountIndex, lastIndex),更新C在新集合中的位置prevChild._mountIndex = nextIndex,在新集合中:A._mountIndex = 3,nextIndex++,進行下一個節點判斷。

    • 由於是最後一個節點,diff操作完成

==那麼,除了有可複用節點,新集合當有新插入節點,舊集合有需要刪除的節點呢?==
下圖示例:

淺談React中的diff

對於這種情況,React則是執行以下步驟:

  • nextIndex = 0,lastIndex = 0,從新集合中獲取B,在舊集合中發現相同節點B,舊集合中:B._mountIndex = 1,child._mountIndex < lastIndex ==> false,不執行移動操作,更新lastIndex = 1,更新B在新集合中的位置,nextIndex++,進行下一個節點判斷。
  • nextIndex = 1,lastIndex = 1,從新集合中獲取E,在舊集合中沒有發現相同節點E,nextIndex++進入下一個節點判斷。
  • nextIndex = 2,lastIndex = 1,從新集合中獲取C,在舊集合中發現相同節點C,舊集合中:C._mountIndex = 2,child._mountIndex < lastIndex ==> false,不對C進行移動操作,更新lastIndex = 2,更新C在新集合的位置,nextIndex++,進行下一個節點判斷。
  • nextIndex = 3,lastIndex = 2,從新集合中獲取A,在舊集合中發現相同節點A,舊集合中:A._mountIndex = 0,child._mountIndex < lastIndex ==> true,對A進行移動操作,更新lastIndex = 2,更新A在新集合中的位置,nextIndex++進入下一個節點判斷。
  • 當完成新集合所有節點中的差異對比後,對舊集合進行遍歷,判讀舊集合中是否存在新集合中不存在的節點,此時發現D節點符合判斷,執行刪除D節點的操作,diff操作完成。

優化後diff的不足

世上沒有百分之百完美演算法,React的diff也有自己的不足之處,比如新舊集合元素全部可以複用,只是新集合中將舊集合最後一個元素放到了第一個位置,短板就會出現 下圖示例:

淺談React中的diff

按照上述順序優化,則舊集合中D的位置是最大的,最少的操作只是將D移動到第一位就可以了,實際上diff操作會移動D之前的三個節點到對應的位置,這種情況會影響渲染的效能。

優化建議

在開發過程中,同層級的節點新增唯一key值可以極大提升效能,儘量減少將最後一個節點移動到列表首部的操作,當節點達到一定的數量以後或者操作過於頻繁,在一定程度上會影響React的渲染效能。比如大量節點拖拽排序的問題。
複製程式碼

總之,React為我們提供優秀的diff演算法,使我們能夠在實際開發中happy的擼程式碼,但也不是說可以“隨意”去構建我們的應用,根據diff的特點,在具體場景中取長補短,規避一些演算法上面的短板也是有利於提升應用整體的效能。

參考資料:

  • 《深入React技術棧》陳屹 ——3.5章節

相關文章