虛擬DOM Diff算法解析

頭部大圖.jpg

React中最神奇的部分莫過於虛擬DOM,以及其高效的Diff算法。這讓我們可以無需擔心性能問題而”毫無顧忌”的隨時“刷新”整個頁面,由虛擬DOM來確保只對界面上真正變化的部分進行實際的DOM操作。React在這一部分已經做到足夠透明,在實際開發中我們基本無需關心虛擬DOM是如何運作的。然而,作爲有態度的程序員,我們總是對技術背後的原理充滿着好奇。理解其運行機制不僅有助於更好的理解React組件的生命週期,而且對於進一步優化React程序也會有很大幫助。

什麼是DOM Diff算法

Web界面由DOM樹來構成,當其中某一部分發生變化時,其實就是對應的某個DOM節點發生了變化。在React中,構建UI界面的思路是由當前狀態決定界面。前後兩個狀態就對應兩套界面,然後由React來比較兩個界面的區別,這就需要對DOM樹進行Diff算法分析。

即給定任意兩棵樹,找到最少的轉換步驟。但是標準的的Diff算法複雜度需要O(n^3),這顯然無法滿足性能要求。要達到每次界面都可以整體刷新界面的目的,勢必需要對算法進行優化。這看上去非常有難度,然而Facebook工程師卻做到了,他們結合Web界面的特點做出了兩個簡單的假設,使得Diff算法複雜度直接降低到O(n)

  1. 兩個相同組件產生類似的DOM結構,不同的組件產生不同的DOM結構;

  2. 對於同一層次的一組子節點,它們可以通過唯一的id進行區分。

算法上的優化是React整個界面Render的基礎,事實也證明這兩個假設是合理而精確的,保證了整體界面構建的性能。

不同節點類型的比較

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

當在樹中的同一位置前後輸出了不同類型的節點,React直接刪除前面的節點,然後創建並插入新的節點。假設我們在樹的同一位置前後兩次輸出不同類型的節點。


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


當一個節點從div變成span時,簡單的直接刪除div節點,並插入一個新的span節點。這符合我們對真實DOM操作的理解。

需要注意的是,刪除節點意味着徹底銷燬該節點,而不是再後續的比較中再去看是否有另外一個節點等同於該刪除的節點。如果該刪除的節點之下有子節點,那麼這些子節點也會被完全刪除,它們也不會用於後面的比較。這也是算法複雜能夠降低到O(n)的原因。

上面提到的是對虛擬DOM節點的操作,而同樣的邏輯也被用在React組件的比較,例如:


renderA: <Header />
renderB: <Content />
=> [removeNode <Header />], [insertNode <Content />]


當React在同一個位置遇到不同的組件時,也是簡單的銷燬第一個組件,而把新創建的組件加上去。這正是應用了第一個假設,不同的組件一般會產生不一樣的DOM結構,與其浪費時間去比較它們基本上不會等價的DOM結構,還不如完全創建一個新的組件加上去。

由這一React對不同類型的節點的處理邏輯我們很容易得到推論,那就是React的DOM Diff算法實際上只會對樹進行逐層比較,如下所述。

逐層進行節點比較

提到樹,相信大多數同學立刻想到的是二叉樹,遍歷,最短路徑等複雜的數據結構算法。而在React中,樹的算法其實非常簡單,那就是兩棵樹只會對同一層次的節點進行比較。如下圖所示:

3.jpg

React只會對相同顏色方框內的DOM節點進行比較,即同一個父節點下的所有子節點。當發現節點已經不存在,則該節點及其子節點會被完全刪除掉,不會用於進一步的比較。這樣只需要對樹進行一次遍歷,便能完成整個DOM樹的比較。

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

4.jpg

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節點。

由DOM Diff算法理解組件的生命週期

上一篇文章中介紹了React組件的生命週期,其中的每個階段其實都是和DOM Diff算法息息相關的。例如以下幾個方法:

  • constructor: 構造函數,組件被創建時執行;

  • componentDidMount: 當組件添加到DOM樹之後執行;

  • componentWillUnmount: 當組件從DOM樹中移除之後執行,在React中可以認爲組件被銷燬;

  • componentDidUpdate: 當組件更新時執行。

爲了演示組件生命週期和DOM Diff算法的關係,筆者創建了一個示例:https://supnate.github.io/react-dom-diff/index.html ,大家可以直接訪問試用。這時當DOM樹進行如下轉變時,即從“shape1”轉變到“shape2”時。我們來觀察這幾個方法的執行情況:

瀏覽器開發工具控制檯輸出如下結果:


C will unmount.
is created.
is updated.
is updated.
C did mount.
is updated.
is updated.


可以看到,C節點是完全重建後再添加到D節點之下,而不是將其“移動”過去。如果大家有興趣,也可以fork示例代碼:https://github.com/supnate/react-dom-diff 。從而可以自己添加其它樹結構,試驗它們之間是如何轉換的。

相同類型節點的比較

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


renderA: <div id="before" />
renderB: <div id="after" />
=> [replaceAttribute id "after"]


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


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


列表節點的比較

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

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

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

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

7.jpg

可以看到,React會逐個對節點進行更新,轉換到目標節點。而最後插入新的節點E,涉及到的DOM操作非常多。而如果給每個節點唯一的標識(key),那麼React能夠找到正確的位置去插入新的節點,入下圖所示:

11.jpg

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

12.jpg

即將同一層的節點位置進行調整。如果未提供key,那麼React認爲B和C之後的對應位置組件類型不同,因此完全刪除後重建,控制檯輸出如下:


B will unmount.
C will unmount.
is created.
is created.
C did mount.
B did mount.
is updated.
is updated.


而如果提供了key,如下面的代碼:


shape5: function() {
  return (    <Root>
      <A>
        <B key="B" />
        <C key="C" />
      </A>
    </Root>
  );
},

shape6: function() {
  return (    <Root>
      <A>
        <C key="C" />
        <B key="B" />
      </A>
    </Root>
  );
},


那麼控制檯輸出如下:


is updated.
is updated.
is updated.
is updated.


可以看到,對於列表節點提供唯一的key屬性可以幫助React定位到正確的節點進行比較,從而大幅減少DOM操作次數,提高了性能。

小結

本文分析了React的DOM Diff算法究竟是如何工作的,其複雜度控制在了O(n),這讓我們考慮UI時可以完全基於狀態來每次render整個界面而無需擔心性能問題,簡化了UI開發的複雜度。而算法優化的基礎是文章開頭提到的兩個假設,以及React的UI基於組件這樣的一個機制。理解虛擬DOM Diff算法不僅能夠幫助我們理解組件的生命週期,而且也對我們實現自定義組件時如何進一步優化性能具有指導意義。


本文摘自異步社區,發表人: xiangzhihong ,作品:《虛擬DOM Diff算法解析》,未經授權,禁止轉載。


推薦閱讀

2018年5月新書書單(文末福利)

2018年4月新書書單

異步圖書最全Python書單

一份程序員必備的算法書單

第一本Python神經網絡編程圖書





微信圖.jpg
長按二維碼,可以關注我們喲

每天與你分享IT好文。


異步圖書”後臺回覆“關注”,即可免費獲得2000門在線視頻課程

點擊查看原文,閱讀更多內容


閱讀原文


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章