1. 程式人生 > >React虛擬DOM Diff演算法解析

React虛擬DOM Diff演算法解析

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中,樹的演算法其實非常簡單,那就是兩棵樹只會對同一層次的節點進行比較。如下圖所示:

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

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

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.
C is created.
B is updated.
A is updated.
C did mount.
D is updated.
R 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節點。效果如下圖所示:

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

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

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

B will unmount.
C will unmount.
C is created.
B is created.
C did mount.
B did mount.
A is updated.
R 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>
  );
},

那麼控制檯輸出如下:

C is updated.
B is updated.
A is updated.
R is updated.

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

小結

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