React虛擬Dom渲染演算法

溜達向日葵發表於2018-07-25

React提供了一系列宣告性的API介面,因此在使用時不必擔心每次庫的更新會修改API介面。這樣可以降低編寫應用的複雜度,但是帶來的問題是無法很好的理解React是如何實現這些功能的。這篇文章會介紹React的差異比對演算法——“融合演算法”是如何執行的。

差異匹配演算法實現的前提

我們先來看看第一個值得關注的我問題: render() 方法的作用是建立React元素的樹形結構,當state或props發生更新後, render() 會返回一個與之前有差異的結構樹。在這個機制下,React需要弄清楚如何匹配最近的樹並有效的更新UI。

針對以上問題,有一些通用的演算法可供參考,比如比對2顆樹的差異,在前一個顆樹的基礎上生成最小操作樹,但是這個演算法的時間複雜度為n的三次方=O(n*n*n),當樹的節點較多時,這個演算法的時間代價會導致演算法幾乎無法工作。

假設在我們使用React時,一共使用了1000個Dom標籤元素,那麼使用上面的演算法,我們要比對數億次才能得到比對的結果,根本不可能在一個瀏覽器中短時間完成。React實現了一個計算複雜度是O(n)的演算法來解決這個問題,這個演算法基於2個假設:

  1. 不同型別的2個標籤元素產生不同的樹。
  2. 開發人員可以為不同的子節點在渲染之前設定一個“key”屬性值。

差異演算法

對於2顆有差異的樹,React首先比對2顆樹的根節點。根據跟節點的型別是否相同,演算法接下來會執行不同的操作。

Types不一樣

一旦2棵樹之間的根元素型別不一樣,React會直接移除舊的樹並構建出新的樹。例如從 <a> 變更為 <img>、 <Article> 變更為 <Comment>、 <Button> 變更為 <div> ,所有的這些變化都會導致整顆樹重構。

重構一棵新的樹時,所有的舊節點都會移除。元件的componentWillUnmount()方法會被呼叫。 然後到構建完成之後新的Dom會替換原來的Dom。此時元件的componentWillMount()componentDidMount()會依次被呼叫。舊樹Dom上的所有狀態都會丟失。

根據這個特性,根節點之後的所有元件都會解除安裝並重建,狀態也會隨之改變。例如下面2個元件對比:

<div>
  <Counter />
</div>

<span>
  <Counter />
</span>

Counter 元件會被銷燬並重新安裝一個新的元件。

Dom元素擁有相同的型別

當比較React元素為相同型別時,React會檢視元素上的屬性來比對。比對之後,React會保持的Dom節點不改變然後僅僅更新不同的屬性值,例如:

<div className="before" title="stuff" />

<div className="after" title="stuff" />

在比對這2個元素之後,React知道僅僅需要修改當前Dom的className。在更新style時,React同樣知道僅僅需要更新修改部分即可。例如:

<div style={{color: `red`, fontWeight: `bold`}} />

<div style={{color: `green`, fontWeight: `bold`}} />

在轉換這2個元件時,React知道僅僅需要修改color的樣式,而fontWeight不必發生變動。

在處理完當前Dom節點後,React依次對子節點進行遞迴。

元件元素擁有相同的型別

當一個元件發生更新後,例項依然是原來的例項,所以狀態還是以前的狀態。React通過屬性值(props)的更新來影響需要更新元件,此時元件例項的 componentWillReceiveProps() 和 componentWillUpdate() 方法會被呼叫。

然後, render() 方法會被呼叫並返回一個Dom,差異演算法會遞迴比對之前返回Dom的差異。

遞迴子元素

預設情況下,在遞迴子元素的Dom節點時,React同時對2個子元素列表進行迭代比對,如果發現差異都會產生一個突變(關於突變的概念請見React學習第六篇效能優化介紹不可變資料結構部分)。

例如,當增加一個元素在子元素的隊尾,這2顆樹的轉換效率很高:

<ul>
  <li>first</li>
  <li>second</li>
</ul>

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

React先匹配 <li>first</li> 2棵樹,然後再匹配 <li>second</li> 。最後直接就新增 <li>third</li> 節點。

如果程式碼按下面的方式修改2顆樹,執行的效率相對較差:

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

React會突變修改所有的子節點,最終 <li>Duke</li> and <li>Villanova</li> 會被重新渲染。所以這種方式會帶來很大的效率問題。

Keys

為了解決上面的問題,React提供了一個“key”屬性。當所有的子元素都有一個key值,React直接使用key值來比對樹形結構中的所有子節點列表。例如為上面的的例子增加一個key會大大的提升轉換效率:

<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

現在React可以知道key=`2014`的節點是一個新值另外2個節點僅僅需要移動一下位置。

在實際使用中,key值並不難找。在常規業務中,很多列表都自然包含業務相關的ID了:

<li key={item.id}>{item.name}</li>

當無法使用業務ID時,也可以額外增加一個ID值來標記列表差異,比如根據要使用的資料生成一個hash值,React不需要key值全域性唯一,只需要在兄弟節點之間保持唯一即可。

最差情況下,你可以使用索引資料(0、1、2、….n)。使用索引需要注意的是,如果列表發生重新排序效率會很糟糕。

一些常見的問題

在使用React時需要謹記每次呼叫 render() 方法,它總會嘗試比對呼叫前後2棵樹是否一致。在某些極端情況下,雖然最終呈現效果並沒有發生多大的變化,但是有可能每一個簡單的操作都導致React全域性重新渲染(例如列表沒有Key)。

React在當前版本的實現中還存在一個問題,可以快捷的告知React子樹中某個節點的位置已經發生改變,但是無法告知React他移動到了什麼位置。因此在遇到這種情況時,演算法會重構整個子樹。這個問題告訴我們,如果遇到彈窗之類需要偶爾出現的元件,最好是通過隱藏屬性控制他,而非直接移除Dom。

React依賴啟發式演算法,如果本文開篇提到的2個基本假設不成立,那麼會導致演算法效率極差。

  1. 演算法不會嘗試匹配不同2個元件之間的子樹。如果編碼中發現2個元件之間有非常相似的輸出,應該嘗試將2個元件合併為一個型別的元件。在實際應用中,我們還沒發現這樣導致問題。
  2. 用作列表的key值最好是穩定、可預見、唯一的。易變的key值(比如由Math.random()方法生成的值)將會導致許多元件例項和Dom節點被非必要的重新建立,這會導致效能低下且子元件丟失已有的狀態。


相關文章