參考資料
一、為什么需要 diffing 算法?
在某一時間節點調用 React
的 render()
方法,會創建一棵由 React
元素組成的樹。在下一次 state
或 props
更新時,相同的 render()
方法會返回一棵不同的樹。React
需要基于這兩棵樹之間的差別來判斷如何有效率的更新 UI 以保證當前 UI 與最新的樹保持同步。
這個算法問題有一些通用的解決方案,即生成將一棵樹轉換成另一棵樹的最小操作數。 然而,即使在最前沿的算法中,該算法的復雜程度為 O(n<sup> 3 </sup>)
,其中 n
是樹中元素的數量。
如果在 React
中使用了該算法,那么展示 1000
個元素所需要執行的計算量將在 十億
的量級范圍。這個開銷實在是太過高昂。于是 React
在以下兩個假設的基礎之上提出了一套 O(n)
的啟發式算法:
二、diffing 算法的復雜程度為?
O(n)
三、能做到如此低的算法復雜程度的兩個假設基礎
- 兩個不同類型的元素會產生出不同的樹;
- 開發者可以通過
key
、prop
來暗示哪些子元素在不同的渲染下能保持穩定;
在實踐中,我們發現以上假設在幾乎所有實用的場景下都成立。
四、diffing 算法具體對比
總結:
- 對比不同類型的普通元素:當根節點為不同類型的元素時,
React
會拆卸原有的樹并且建立起新的樹。 - 對比同一類型的普通元素:① 元素的屬性改變時,
React
會保留DOM
節點,僅比對及更新有改變的屬性。② 特殊的style
屬性改變時,React
僅更新有所更變的style
里的屬性。 - 對比同一類型的組件元素:不改變組件的
state
;更新組件的props
;調用實例的相關方法;遞歸新舊結果。 - 對子節點進行遞歸:① 在子元素列表末尾新增元素時,更新開銷比較小,只新增末尾元素。② 將新增元素插入到表頭,那么更新開銷會比較大,會重建每一個子元素。
1. 對比不同類型的元素
當根節點為不同類型的元素時,React
會拆卸原有的樹并且建立起新的樹。舉個例子,當一個元素從 <a>
變成 <img>
,從 <Article>
變成 <Comment>
,或從 <Button>
變成 <div>
都會觸發一個完整的重建流程。
當拆卸一棵樹時,對應的 DOM
節點也會被銷毀。組件實例將執行 componentWillUnmount()
方法。當建立一棵新的樹時,對應的 DOM
節點會被創建以及插入到 DOM
中。組件實例將執行 UNSAFE_componentWillMount()
方法,緊接著 componentDidMount()
方法。所有跟之前的樹所關聯的 state
也會被銷毀。
在根節點以下的組件也會被卸載,它們的狀態會被銷毀。比如,當比對以下更變時:
<div>
<Counter />
</div>
<span>
<Counter />
</span>
React 會銷毀 Counter
組件并且重新裝載一個新的組件。
注意:
這些方法被認為是過時的,在新的代碼中應該避免使用它們:
UNSAFE_componentWillMount()
2. 對比同一類型的元素
當對比兩個相同類型的 React
元素時,React
會保留 DOM
節點,僅比對及更新有改變的屬性。比如:
<div className="before" title="stuff" />
<div className="after" title="stuff" />
過對比這兩個元素,React
知道只需要修改 DOM
元素上的 className
屬性。
當更新 style
屬性時,React
僅更新有所更變的屬性。比如:
<div style={{color: 'red', fontWeight: 'bold'}} />
<div style={{color: 'green', fontWeight: 'bold'}} />
通過對比這兩個元素,React
知道只需要修改 DOM
元素上的 color
樣式,無需修改 fontWeight
。
在處理完當前節點之后,React
繼續對子節點進行遞歸。
3. 對比同類型的組件元素
當一個組件更新時,組件實例保持不變,這樣 state
在跨越不同的渲染時保持一致。React
將更新該組件實例的 props
以跟最新的元素保持一致,并且調用該實例的 UNSAFE_componentWillReceiveProps()
、UNSAFE_componentWillUpdate()
以及 componentDidUpdate()
方法。
下一步,調用 render()
方法,diff
算法將在之前的結果以及新的結果中進行遞歸。
注意:
這些方法已過時,在新代碼中應避免使用它們:
UNSAFE_componentWillUpdate()
UNSAFE_componentWillReceiveProps()
4. 對子節點進行遞歸
在默認條件下,當遞歸 DOM
節點的子元素時,React
會同時遍歷兩個子元素的列表;當產生差異時,生成一個 mutation
。
在子元素列表末尾新增元素時,更新開銷比較小。比如:
<ul>
<li>first</li>
<li>second</li>
</ul>
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
React
會先匹配兩個 <li>first</li>
對應的樹,然后匹配第二個元素 <li>second</li>
對應的樹,最后插入第三個元素的 <li>third</li>
樹。
如果只是簡單的將新增元素插入到表頭,那么更新開銷會比較大。比如:
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>
<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>
React
不會意識到應該保留 <li>Duke</li>
和 <li>Villanova</li>
,而是會重建每一個子元素 。這種情況會帶來性能問題。
五、diffing 算法對子節點進行遞歸的優化 —— 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
知道只有帶著 '2014'
的 key
元素是新元素,帶著 '2015'
以及 '2016'
的 key
元素僅僅移動了。
現實場景中,產生一個 key
并不困難。你要展現的元素可能已經有了一個唯一 ID,于是 key
可以直接從你的數據中提?。?/p>
<li key={item.id}>{item.name}</li>
當以上情況不成立時,你可以新增一個 ID 字段到你的模型中,或者利用一部分內容作為哈希值來生成一個 key
。這個 key
不需要全局唯一,但在列表中需要保持唯一。
最后,你也可以使用元素在數組中的下標作為 key
。這個策略在元素不進行重新排序時比較合適,如果有順序修改,diff 就會變得慢。
當基于下標的組件進行重新排序時,組件 state
可能會遇到一些問題。由于組件實例是基于它們的 key
來決定是否更新以及復用,如果 key
是一個下標,那么修改順序時會修改當前的 key
,導致非受控組件的 state
(比如輸入框)可能相互篡改導致無法預期的變動。
在 Codepen 有兩個例子,分別為 展示使用下標作為 key 時導致的問題,以及不使用下標作為 key 的例子的版本,修復了重新排列,排序,以及在列表頭插入的問題 。
六、性能會有所損耗的假設
請謹記協調算法是一個實現細節。React 可以在每個 action 之后對整個應用進行重新渲染,得到的最終結果也會是一樣的。在此情境下,重新渲染表示在所有組件內調用 render 方法,這不代表 React 會卸載或裝載它們。React 只會基于以上提到的規則來決定如何進行差異的合并。
我們定期探索優化算法,讓常見用例更高效地執行。在當前的實現中,可以理解為一棵子樹能在其兄弟之間移動,但不能移動到其他位置。在這種情況下,算法會重新渲染整棵子樹。
由于 React 依賴探索的算法,因此當以下假設沒有得到滿足,性能會有所損耗:
- 該算法不會嘗試匹配不同組件類型的子樹。如果你發現你在兩種不同類型的組件中切換,但輸出非常相似的內容,建議把它們改成同一類型。在實踐中,我們沒有遇到這類問題。
-
Key
應該具有穩定,可預測,以及列表內唯一的特質。不穩定的key
(比如通過Math.random()
生成的)會導致許多組件實例和DOM
節點被不必要地重新創建,這可能導致性能下降和子組件中的狀態丟失。