ReactJS中的DOM diff算法

翻譯自:https://calendar.perfplanet.com/2013/diff/
萬分感謝Christopher Chedeau!

React是Facebook開發的一個用于構建用戶界面的JavaScript庫。它從零開始設計的時候就考慮了性能的問題。在這篇文章中,我將介紹React中的diff算法和渲染過程。這樣你就能夠對自己的應用進行優化。

DIff算法

在介紹實現細節之前,先了解一下React是如何工作的


    var MyComponent = React.createClass({
        render: function() {
            if(this.props.first) {
                return
                         <div className="first">
                            <span>A Span</span>
                         </div>;
            } 
            else {
                return 
                         <div className="second">
                            <p> A paragraph </p>      
                         </div>;
            }
        }
    });

在任何時候,你都會描述你想要的界面。理解render的結果并不是真正的DOM節點是很重要。這些只是輕量級的javaScript對象。我們稱之為虛擬DOM(Virtual DOM)

React將使用這種表示方法嘗試從一個渲染結果切換到另一個渲染結果的最少步數。例如,從<MyComponent first={true} />開始,然后替換為<MyComponent first={false} />,最后將它們都刪除。

渲染過程(簡化)

1.初始renderA

創建節點:`<div className="first"><span>A Span</span></div>`

生命周期:Mounting

2.再次renderB

 2.1比較父節點div =>相同,跳過 進行下面操作
 2.2比較父節點div屬性=>不相同,下面進行替換屬性操作
     替換屬性:替換`className="first"`為`className="second"`
 2.3比較子節點=>不相同,下面進行子節點替換操作
     替換節點:刪除`<span>A Span</span>` 插入`<p>A Paragraph</p>`

生命狀態:Updating

3.移除節點

移除節點:直接移除div父節點即可,子節點也會被完全刪除`<div className="second"><p>A Paragraph</p></div>`

生命周期:Unmounting

上述的例子是為了講述React的簡單工作過程,下面開始介紹一些細節

不同節點類型的比較

為了在樹之間進行比較,我們首先要能夠比較兩個節點,在React中即比較兩個虛擬DOM節點,當兩個節點不同時,應該如何處理。這分為兩種情況:
(1)節點類型不同

renderA: <span />
renderB: <p />
=> [removeNode <span />], [insertNode <p />]

當在樹中的同一位置前后輸出了不同類型的節點,React直接刪除前面的節點,然后創建并插入新的節點。

(2)節點類型相同,但是屬性不同
第二種節點的比較是相同類型的節點,算法就相對簡單而容易理解。React會對屬性進行重設從而實現節點的轉換。例如:

renderA: <div className="first" />
renderB: <div className="second" />
=> [replaceAttribute className second]

虛擬DOM的style屬性稍有不同,其值并不是一個簡單字符串而必須為一個對象,因此轉換過程如下:

renderA: <div style={{color: 'red'}} />
renderB: <div style={{fontWeight: 'bold'}} />
=> [removeStyle color], [addStyle font-weight 'bold']

逐層進行節點比較

查找任意兩棵樹之間最少修改數的時間復雜度是O(n^3)。可以想象到,這很難處理。
React使用一種簡單但強大的啟發式方式來優化到了接近O(n)。

React只會按層比較兩棵樹。這樣就顯著的降低了問題的復雜度,并且由于Web應用中很少將一個組件在不同層之間的移動,從而產生太多負面影響。Web應用通常只會在子節點之間橫向移動。

逐層比較

例如,考慮有下面的DOM結構轉換


DOM結構轉換1

A節點被整個移動到D節點下,直觀的考慮DOM Diff操作應該是

A.parent.remove(A); 
D.append(A);

但因為React只會簡單的考慮同層節點的位置變換,對于不同層的節點,知識簡單的創建和刪除。當根節點發現子節點A不見了,就會直接銷毀A;而當D發現自己多了一個子節點A,則會創建一個新的A作為子節點。因此對于這種結構的轉變的實際操作是:

A.destroy();
A = new A();
A.append(new B());
A.append(new C());
D.append(A);

可以看到,以A為根節點的樹被整個重新創建。

雖然看上去這樣的算法有些“簡陋”,但是其基于的是第一個假設:兩個不同組件一般產生不一樣的DOM結構。根據React官方博客,這一假設至今為止沒有導致嚴重的性能問題。這當然也給我們一個提示,在實現自己的組件時,保持穩定的DOM結構會有助于性能的提升。例如,我們有時可以通過CSS隱藏或顯示某些節點,而不是真的移除或添加DOM節點。

列表節點的比較

上面介紹了對于不在同一層的節點的比較,即使他們完全一樣,也會銷毀并重新創建.那么當它們在同一層時,又是如何處理的呢?這就涉及到列表節點的Diff算法。相信很多使用React的同學大多遇到過這樣的警告:


警告

這是React在遇到列表時卻又找不到key時提示的警告。雖然無視這條警告大部分界面也會正確工作,但這通常意味著潛在的性能問題。因為React覺得自己可能無法高效地去更新這個列表

列表節點的操作通常包括添加、刪除和排序。例如下圖,我們需要往B和C直接插入節點F,在jQuery中我們可能會直接使用$(B).after(F)來實現。而在React中,我們只會告訴React新的界面應該是A-B-F-C-D-E,由Diff算法完成更新界面。

列表節點比較1

這時如果每個節點都沒有唯一的標識,React無法識別每一個節點,那么更新過程會很低效,即,將C更新成F,D更新成C,E更新成D,最后再插入一個E節點。效果如下圖所示:

列表節點比較2

可以看到,React會逐個對節點進行更新,轉換到目標節點。而最后插入新的節點E,涉及到的DOM操作非常多。diff總共就是移動、刪除、增加三個操作,而如果給每個節點唯一的標識(key),那么React優先采用移動的方式,能夠找到正確的位置去插入新的節點,如下圖所示:

列表節點比較3

對于列表節點順序的調整其實也類似于插入或刪除,下面結合示例代碼我們看下其轉換的過程。仍然使用前面提到的示例:
https://supnate.github.io/react-dom-diff/index.html ,我們將樹的形態從shape5轉換到shape6:

DOM結構轉換3

題外話:如果你之前接觸vue.js,那么你將會很容易理解這個所謂的key,在vue.js中組件復用中使用的就是這個key,如果你對vue.js也敢興趣的話,請自行去Vue.js官方網站進行學習

組件的比較

React應用一般由許多用戶定義的組件構成。這些組件最終會轉換為一個主要由div組成的樹。這些附加信息被diff算法利用了。React只會匹配類型相同的組件。

如果一個<Header>被<Other>替換,React將刪除Header組件然后創建一個Other組件。我們不需要花費寶貴的時間來匹配兩個不一樣的組件。

組件比較

事件代理

給DOM節點增加事件監聽器是一個既慢又消耗內存的事情。React實現了一個叫“事件代理”的技術,并且重新實現了一個W3C兼容的事件系統。因此使得IE 8的事件處理不再是一個問題,所有的事件都能夠跨瀏覽器使用。

下面讓我解釋一下它的實現。首先將單個事件監聽器添加到文檔的根節點上。當事件被觸發后,瀏覽器給出目標DOM節點。為了在整個DOM樹上傳遞事件。React不是在虛擬DOM進行迭代,而是利用每個React組件的唯一ID。我們可以使用一個簡單的字符串來獲取所有父節點的ID。通過將事件監聽器存儲在一個Hash表中,我們發現比將事件添加到虛擬DOM效率更高。下面是事件在虛擬DOM中分發的過程。

//dispatchEvent('click', 'a.b.c', event)
clickCaptureListeners['a'](event);
clickCaptureListeners['a.b'](event);
clickCaptureListeners['a.b.c'](event);
clickBubbleListeners['a.b.c'](event);
clickBubbleListeners['a.b'](event);
clickBubbleListeners['a'](event);

瀏覽器為每個事件和監聽器創建一個新的事件對象。該對象有一個原始事件對象的引用,并且能夠進行修改。這樣做會占用更多的內存,因此React專門為這些對象創建了一個對象池。當需要一個事件對象時,直接從對象池中重用。這樣顯著的降低了垃圾回收。

渲染批處理

當需要調用一個組件的setState時,React將它標記為臟節點。在事件循環的最后才重新渲染所有的臟節點

這種批處理意味著在一個事件循環event-loop,準確地說是一次事件循環中對DOM進行更新。這個特性對構建高性能的JavaScript應用一般來說非常難。但在React中,我們默認就可以利用這個特性。

渲染批處理

渲染子樹

當調用setState時,組件會重建子節點的虛擬DOM。如果在根元素上通用setState,整個React應用都會被重新渲染。所有的組件,包括沒有發生改變的組件都會調用自己的render方法。這樣做效率低的相當可怕,但在實際應用的時候還是可以很好的工作的。因為我們不需要直接操作DOM

首先,我們討論的是如何顯示用戶界面。因為屏幕空間的限制,我們通常需要一次性按順序顯示成千上完個元素。由于整個界面都可以管理,JavaScript能夠足夠快地處理業務邏輯

其次,在編寫React代碼時,我們不需要在每次發生改變后就調用根元素的setState方法。我們只需要在接收到改變事件的節點或者它的一些上層節點調用該方法,很少需要上溯到頂部。這意味著變化被局限在用戶交互的地方

渲染子樹

選擇性渲染子樹

最后,我們還能夠組件一些子樹的重新渲染。如果一個組件實現了下面的方法

boolean shouldComponentUpdate(object nextProps, object nextState)

我們可以基于組件之前的狀態或者下一個狀態決定它是否需要重新渲染。如果實現合理的話,這樣做能夠極大的提高程序的性能

為了使用這個功能,我們需要能夠比較JavaScript對象。但比較愛哦的時候又會引發許多事情,比如是“深比較”還是“淺比較”,是否應該使用不可變數據結構或深拷貝

需要注意的是,這個方法會不斷調用,因此需要確保它的計算盡可能耗時較少。

選擇性渲染子樹

總結

這種技術能夠使React變得更快已經不是一件新鮮事。我們也已經知道,DOM的處理非常耗時、應該批量進行讀寫操作、時間處理效率更高...

大家現在還經常討論他們,因為在正常的JavaScript代碼中很難達到這些目標。React讓這些優化默認就會發生,高性能應用的開發變得更簡單

React的性能模型非常易于理解:每個setState重新渲染一棵子樹。如果想盡量壓榨性能,應該盡可能減少調用setState或使用shouldComponentUpdate阻止重新渲染大的子樹

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,119評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,382評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,038評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,853評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,616評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,112評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,192評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,355評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,869評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,727評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,928評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,467評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,165評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,570評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,813評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,585評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,892評論 2 372

推薦閱讀更多精彩內容