React源碼之Diff算法

React框架使用的目的,就是爲了維護狀態,更新視圖。html

爲何會說傳統DOM操做效率低呢?當使用document.createElement()建立了一個空的Element時,會須要按照標準實現一大堆的東西,以下圖所示。此外,在對DOM進行操做時,若是一不留神致使迴流,性能可能就很難保證了。前端

相比之下,JS對象的操做卻有着很高的效率,經過操做JS對象,根據這個用 JavaScript 對象表示的樹結構來構建一棵真正的DOM樹,正是React對上述問題的解決思路。以前的文章中能夠看出,使用React進行開發時, DOM樹是經過Virtual DOM構造的,而且,React在Virtual DOM上實現了DOM diff算法,當數據更新時,會經過diff算法計算出相應的更新策略,儘可能只對變化的部分進行實際的瀏覽器的DOM更新,而不是直接從新渲染整個DOM樹,從而達到提升性能的目的。在保證性能的同時,使用React的開發人員就沒必要再關心如何更新具體的DOM元素,而只須要數據狀態和渲染結果的關係。node

傳統的diff算法經過循環遞歸來對節點進行依次比較還計算一棵樹到另外一棵樹的最少操做,算法複雜度爲O(n^3),其中n是樹中節點的個數。儘管這個複雜度並很差看,可是確實一個好的算法,只是在實際前端渲染的場景中,隨着DOM節點的增多,性能開銷也會很是大。而React在此基礎之上,針對前端渲染的具體狀況進行了具體分析,作出了相應的優化,從而實現了一個穩定高效的diff算法。react

diff算法有以下三個策略:git

  1. DOM節點跨層級的移動操做發生頻率很低,是次要矛盾;github

  2. 擁有相同類的兩個組件將會生成類似的樹形結構,擁有不一樣類的兩個組件將會生成不一樣的樹形結構,這裏也是抓前者放後者的思想;算法

  3. 對於同一層級的一組子節點,經過惟一id進行區分,即沒事就warn的key。
    基於各自的前提策略,React也分別進行了算法優化,來保證總體界面構建的性能。數組

虛擬DOM樹分層比較

兩棵樹只會對同一層次的節點進行比較,忽略DOM節點跨層級的移動操做。React只會對相同顏色方框內的DOM節點進行比較,即同一個父節點下的全部子節點。當發現節點已經不存在,則該節點及其子節點會被徹底刪除掉,不會用於進一步的比較。這樣只須要對樹進行一次遍歷,便能完成整個DOM樹的比較。由此一來,最直接的提高就是複雜度變爲線型增加而不是原先的指數增加。瀏覽器

值得一提的是,若是真的發生跨層級移動(以下圖),例如某個DOM及其子節點進行移動掛到另外一個DOM下時,React是不會機智的判斷出子樹僅僅是發生了移動,而是會直接銷燬,並從新建立這個子樹,而後再掛在到目標DOM上。從這裏能夠看出,在實現本身的組件時,保持穩定的DOM結構會有助於性能的提高。事實上,React官方也是建議不要作跨層級的操做。所以在實際使用中,比方說,咱們會經過CSS隱藏或顯示某些節點,而不是真的移除或添加DOM節點。其實一旦接受了React的寫法,就會發現前面所說的那種移動的寫法幾乎不會被考慮,這裏能夠說是React限制了某些寫法,不過遵照這些實踐確實會使得React有更好的渲染性能。若是真的須要有移動某個DOM的狀況,或許考慮考慮儘可能用CSS3來替代會比較好吧。框架

關於這一部分的源碼,首先須要提到的是,React是如何控制「層」的。在許多源碼閱讀的文章裏(搜到的講的比較細的通常都是兩三年前啦),都是說用一個updateDepth或者某種控制樹深的變量來記錄跟蹤。事實上就目前版原本看,已經不是這樣了(若是我沒看錯…)。ReactDOMComponent .updateComponent方法用來更新已經分配並掛載到DOM上的DOM組件,並在內部調用ReactDOMComponent._updateDOMChildren。而ReactDOMComponent經過_assign將ReactMultiChild.Mixin掛到原型上,得到ReactMultiChild中定義的方法updateChildren(事實上還有updateTextContent等方法也會在不一樣的分支裏被使用,React目前已經對這些情形作了不少細化了)。ReactMultiChild包含着diff算法的核心部分,接下來會慢慢進行梳理。到這裏咱們暫時沒必要再繼續往下看,能夠注意prevChildren和nextChildren這兩個變量,固然removedNodes、mountImages也是意義比較明顯且很重要的變量:

prevChildren和nextChildren都是ReactElement,也就是virtual DOM,從它們的$$typeof: Symbol(react.element)就可看出;removedNodes保存刪除的節點,mountImages則是保存對真實DOM的映射,或者能夠理解爲要掛載的真實節點,這些變量會隨着調用棧一層層往下做爲參數傳下去並被修改和包裝。

而控制樹的深度的方法就是靠傳入nextNestedChildrenElements,把整個樹的索引一層層遞歸的傳下去,同時傳入prevChildren這個虛擬DOM,進入_reconcilerUpdateChildren方法,會在裏面經過flattenChildren方法(固然裏面還有個traverse方法)來訪問咱們的子樹指針nextNestedChildrenElements,獲得與prevChildren同層的nextChildren。而後ReactChildReconciler.updateChildren就會將prevChildren、nextChildren封裝成ReactDOMComponent類型,並進行後續比較和操做。

至此,同層比較敘述結束,後面會繼續討論針對組件的diff和對元素自己的diff。

組件間的比較

參考官方文檔及其餘資料,能夠講組件間的比較策略總結以下:

  1. 若是是同類型組件,則按照原策略繼續比較virtual DOM樹;

  2. 若是不是,則將該組件判斷爲dirty component,而後整個unmount這個組件下的子節點對其進行替換;

  3. 對於同類型組件,virtual DOM可能並無發生任何變化,這時咱們能夠經過shouldCompoenentUpdate鉤子來告訴該組件是否進行diff,從而提升大量的性能。

這裏能夠看出React再次抓了主要矛盾,對於不一樣組件但結構類似的情形再也不去關注,而是對相同組件、類似結構的情形進行diff算法,並提供鉤子來進一步優化。能夠說,對於頁面結構基本沒有變化的狀況,確實是有着很大的優點。

元素間的比較

這一節算是diff算法最核心的部分,我會嘗試着對算法的思想進行分析,並結合本身的demo來增進理解。

例子很簡單,是一個涉及到新集合中有新加入的節點且老集合存在須要刪除的節點的情形。以下圖所示。

也就是說,經過點擊來控制文字和數字的顯示與消失。這種JSX能夠說是太經常使用了。正好借學習diff算法的機會,來看看就這種最基本的結構,React是怎麼作的。

首先先在ReactMultiChild中的_updateChildren中打上第一個debugger。

斷點以前的代碼會獲得prevChildren和nextChildren,他們通過處理會從ReactElement數組變成一個奇怪的對象,key爲「.0」、「.1」這樣的帶點序號(這裏不妨先多說一句,這是React爲一個個組件們默認分配的key,若是這裏我強行設置一個key給h2h3標籤,那麼它就會擁有如’$123’這樣的key),值爲ReactDOMComponent 組件,前面寫初次渲染的文章中提到過ReactDOMComponent就是最終渲染到DOM以前的那一環。而在本demo中,prevChildren存放着「哈哈哈的h1標籤」和「142567的h3標籤」,而nextChildren存放着「哈哈哈的h1標籤」和「你好啊的h2標籤」。

先不看若干index變量,看到for循環的in寫法,便可明白是在遍歷存放了新的ReactDOMComponent的對象,而且經過hasOwnProperty來過濾掉原型上的屬性和方法。接着各自拿到同層節點的第一個,並對兩者進行比較。若是相同,則enqueue一個moveChild方法返回的type爲MOVE_EXISTING的對象到updates裏,即把更新放入一個隊列,moveChild也就是移動已有節點,可是是否真的移動會根據總體diff算法的結果來決定(本例固然是沒移動了),而後修改若干index量;不然,就會計算一堆index(這裏實際上是算法的核心,此處先不細說),而後再次enqueue一個update,事實上是一個type屬性爲INSERT_MARKUP的對象。對於本例而言,h1標籤不變,則會先來一個MOVE_EXISTING對象,而後h3變h2,再來一個INSERT_MARKUP,而後經過ReactReconciler.getHostNode根據nextChild獲得真實DOM。

這個for-in結束後,則是會把須要刪除的節點用enqueue的方法繼續入隊unmount操做,這裏this._unmountChild返回的是REMOVE_NODE對象,至此,整個更新的diff流程就走完了,而updates保存了所有的更新隊列,最終由processQueue來挨個執行更新。

那麼細節在哪裏?慢慢來。

首先,React爲同層節點比較提供了若干操做。早期版本有INSERT_MARKUP、MOVE_EXISTING、REMOVE_NODE這三個增、移、刪操做,如今又加入了SET_MARKUP和TEXT_CONTENT這倆操做。

INSERT_MARKUP,新的component類型(nextChildren裏的)不在老集合(prevChildren)裏,便是全新的節點,須要對新節點執行插入操做;

MOVE_EXISTING,在老集合有新component類型,且element是可更新的類型,這種狀況下prevChild===nextChild,就須要作移動操做,能夠複用之前的DOM節點。

REMOVE_NODE,老component類型在新集合裏也有,但對應的element不一樣則不能直接複用和更新,須要執行刪除操做;或者老component不在新集合裏的,也須要執行刪除操做。

全部的操做都會經過enqueue來入隊,把更新細節隱藏,而如何判斷作出何種更新操做,則是diff算法之所在。咱們回到前面的代碼從新再看,並分狀況討論其中的原理。

代碼分析

首先對新集合的節點(nextChildren)進行in循環遍歷,經過惟一的key(這裏是變量name,前面提到過nextChildren和prevChildren是以對象的形式存儲ReactDOMComponent的)能夠取得新老集合中相同的節點,若是不存在,prevChildren即爲undefined。根據圖中代碼,若是存在相同節點,也即prevChild === nextChild,則進行移動操做,但在移動前須要將當前節點在老集合中的位置與 lastIndex 進行比較,見moveChild函數,以下圖:

if (child._mountIndex < lastIndex),則進行節點移動操做,不然不執行該操做。這是一種順序優化手段,lastIndex一直在更新,表示訪問過的節點在老集合中最右的位置(即最大的位置),若是新集合中當前訪問的節點比lastIndex大,說明當前訪問節點在老集合中就比上一個節點位置靠後,則該節點不會影響其餘節點的位置,所以不用添加到差別隊列中,即不執行移動操做,只有當訪問的節點比lastIndex小時,才須要進行移動操做。

新老集合節點相同、只須要移動的情形

圖是直接拷來的…畫那麼好我就不重複畫輪子了。仍是源碼,就按上面的圖來說。

源碼中會開始對nextChildren(即新的節點狀態 對象形式)進行遍歷,而且對象自己是以鍵值對的形式存儲這些節點的狀態。首先,key=’b’時,經過prevChildren[name]的方式(name即爲key)取老集合節點中是否存在key爲b的節點,顯然,若是存在,則取得,不存在,則爲undefined。而後,判斷是否相等。當咱們兩個key值相同的B節點被斷定相等時,enqueue一個’ MOVE_EXISTING’操做。這一操做內部會做以下判斷:

child即爲prevChild,也就是判斷B._mountIndex < lastIndex,lastIndex是prevChildren最近訪問的最新index,初始爲0(其實由於這些個children都是對象,因此index更多的是計數而非下標)。這裏,B._mountIndex=1,lastIndex爲0,因此不作移動操做更新。而後更新lastIndex,以下圖所示:

咱們知道prevChild就是B,則prevChild._mountIndex如前所示爲1,因此lastIndex更新爲1,這樣lastIndex就能夠記錄着prevChildren中最後訪問的那個的序號。再而後,更新B的位置爲信集合中的位置:

nextIndex隨着nextChildren中遍歷的子元素遞增,此時爲1,也就是說,把B的掛載位置設置爲0,就至關於告訴B你的位置從1移動到了0。

最後更新nextIndex,準備爲下一個放在位置1的元素準備序號。這裏getHostNode方法會返回一個真正的DOM,它主要是給enqueue使用,能夠理解爲開始執行更新隊列時能讓React知道這些更新的節點要放到的DOM的位置。

第二輪,重新集合取到A,判斷到老集合中存在相同節點,一樣是對比位置來判斷是否進行移動操做。只不過,這一次A._mountIndex=0,lastIndex在上一輪更新爲1,知足child._mountIndex<lastIndex的條件,因而enqueue移動操做。

其中toIndex就是nextIndex,目前爲1,很正確嘛。而後繼續更新lastIndex爲1,並更新A._mountIndex=1,而後後續基本一致。
剩下兩輪判斷,不出上述情形。在此再也不細表。

存在須要插入、刪除節點的情形

仍是拿了大佬的圖,哈哈。這裏其實就是更完整的情形,也就會涉及到整個代碼流程,固然也並不複雜。
首先,仍是重新集合先取到B,判斷出老集合中有B,因而本輪與上面的第一輪就同樣了(同一段代碼嘛)。
第二輪,重新集合取到E,可是老集合中不存在,因而走入新流程:

講白了,就是enqueue來建立節點到指定位置,而後更新E的位置,並nextIndex++來進入下一個節點的執行。

第三輪,重新集合取到C,C在老集合中有,可是判斷以後並不進行移動操做,繼續各類更新而後進入下一個節點的判斷。

第四輪,重新集合中取到A,A也存在,因此enqueue移動操做。

至此,diff已經完成,這以後會對removedNodes進行循環遍歷,這個對象是在this._reconcilerUpdateChildren就對比新老集合獲得的。

這樣一來,新集合中不存在的D也就被清除了。總體上看,是先建立,後刪除的方式。

Ok,差很少啦,diff算法的核心就是這麼回事啦。

總結

  1. 經過diff策略,將算法從O(n^3)簡化爲O(n)

  2. 分層求異,對tree diff進行優化

  3. 分組件求異,相同類生成類似樹形結構、不一樣類生成不一樣樹形結構,對component diff進行優化

  4. 設置key,對element diff進行優化

  5. 儘可能保持穩定的DOM結構、避免將最後一個節點移動到列表首部、避免節點數量過大或更新過於頻繁

補充

官方文檔Keys should be stable, predictable, and unique. Unstable keys (like those produced by Math.random() will cause many component instances and DOM nodes to be unnecessarily recreated, which can cause performance degradation and lost state in child components.

相關文章
相關標籤/搜索