原文:https://segmentfault.com/a/1190000010686582
React框架使用的目的,就是為了維護狀態,更新視圖。
為什么會說傳統DOM操作效率低呢?當使用document.createElement()創建了一個空的Element時,會需要按照標準實現一大堆的東西,如下圖所示。此外,在對DOM進行操作時,如果一不留神導致回流,性能可能就很難保證了。
相比之下,JS對象的操作卻有著很高的效率,通過操作JS對象,根據這個用 JavaScript 對象表示的樹結構來構建一棵真正的DOM樹,正是React對上述問題的解決思路。之前的文章中可以看出,使用React進行開發時, DOM樹是通過Virtual DOM構造的,并且,React在Virtual DOM上實現了DOM diff算法,當數據更新時,會通過diff算法計算出相應的更新策略,盡量只對變化的部分進行實際的瀏覽器的DOM更新,而不是直接重新渲染整個DOM樹,從而達到提高性能的目的。在保證性能的同時,使用React的開發人員就不必再關心如何更新具體的DOM元素,而只需要數據狀態和渲染結果的關系。
傳統的diff算法通過循環遞歸來對節點進行依次比較還計算一棵樹到另一棵樹的最少操作,算法復雜度為O(n^3),其中n是樹中節點的個數。盡管這個復雜度并不好看,但是確實一個好的算法,只是在實際前端渲染的場景中,隨著DOM節點的增多,性能開銷也會非常大。而React在此基礎之上,針對前端渲染的具體情況進行了具體分析,做出了相應的優化,從而實現了一個穩定高效的diff算法。
diff算法有如下三個策略:
DOM節點跨層級的移動操作發生頻率很低,是次要矛盾;
擁有相同類的兩個組件將會生成相似的樹形結構,擁有不同類的兩個組件將會生成不同的樹形結構,這里也是抓前者放后者的思想;
對于同一層級的一組子節點,通過唯一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。
組件間的比較
參考官方文檔及其他資料,可以講組件間的比較策略總結如下:
如果是同類型組件,則按照原策略繼續比較virtual DOM樹;
如果不是,則將該組件判斷為dirty component,然后整個unmount這個組件下的子節點對其進行替換;
對于同類型組件,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
其中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算法的核心就是這么回事啦。
總結
通過diff策略,將算法從O(n^3)簡化為O(n)
分層求異,對tree diff進行優化
分組件求異,相同類生成相似樹形結構、不同類生成不同樹形結構,對component diff進行優化
設置key,對element diff進行優化
盡量保持穩定的DOM結構、避免將最后一個節點移動到列表首部、避免節點數量過大或更新過于頻繁