本文描述了在實現 React 的 “diffing” 算法中做出的設計決策以保證組件滿足更新具有可預測性,以及在繁雜業務下依然保持應用的高性能性。
1、設計背景
在某一時間節點調用 React 的 render() 方法,會創建一棵由 React 元素組成的樹。在下一次 state 或 props 更新時,相同的 render() 方法會返回一棵不同的樹。React 需要基于這兩棵樹之間的差別來判斷如何有效率的更新 UI 以保證當前 UI 與最新的樹保持同步。
這個算法問題有一些通用的解決方案,即生成將一棵樹轉換成另一棵樹的最小操作數。 然而,即使在最前沿的算法中,該算法的復雜程度為 O(n 3 ),其中 n 是樹中元素的數量。
如果在 React 中使用了該算法,那么展示 1000 個元素所需要執行的計算量將在十億的量級范圍。這個開銷實在是太過高昂。于是 React 在以下兩個假設的基礎之上提出了一套 O(n) 的啟發式算法:
1.兩個不同類型的元素會產生出不同的樹;
2.開發者可以通過 key prop 來暗示哪些子元素在不同的渲染下能保持穩定;
在實踐中,發現以上假設在幾乎所有實用的場景下都成立。
2、Diffing 算法
當對比兩顆樹時,React 首先比較兩棵樹的根節點。不同類型的根節點元素會有不同的形態。
2.1 比對不同類型的元素
當根節點為不同類型的元素時,React 會拆卸原有的樹并且建立起新的樹。舉個例子,當一個元素從 <a> 變成 <img>,從 <Article> 變成 <Comment>,或從 <Button> 變成 <div> 都會觸發一個完整的重建流程。
當拆卸一顆樹時,對應的 DOM 節點也會被銷毀。組件實例將執行 componentWillUnmount() 方法。當建立一顆新的樹時,對應的 DOM 節點會被創建以及插入到 DOM 中。組件實例將執行 componentWillMount() 方法,緊接著 componentDidMount() 方法。所有跟之前的樹所關聯的 state 也會被銷毀。
在根節點以下的組件也會被卸載,它們的狀態會被銷毀。比如,當比對以下更變時:
<div>
<Counter />
</div>
<span>
<Counter />
</span>
React 會銷毀 Counter 組件并且重新裝載一個新的組件。
2.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'}} />
當更新 style 屬性時,React 僅更新有所更變的屬性。比如:
<div style={{color: 'red', fontWeight: 'bold'}} />
<div style={{color: 'green', fontWeight: 'bold'}} />
通過比對這兩個元素,React 知道只需要修改 DOM 元素上的 color 樣式,無需修改 fontWeight。
在處理完當前節點之后,React 繼續對子節點進行遞歸。
2.3 比對同類型的組件元素
當一個組件更新時,組件實例保持不變,這樣 state 在跨越不同的渲染時保持一致。React 將更新該組件實例的 props 以跟最新的元素保持一致,并且調用該實例的 componentWillReceiveProps() 和 componentWillUpdate() 方法。
下一步,調用 render() 方法,diff 算法將在之前的結果以及新的結果中進行遞歸。
2.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 會針對每個子元素 mutate 而不是保持相同的 <li>Duke</li> 和 <li>Villanova</li> 子樹完成。這種情況下的低效可能會帶來性能問題。
2.5 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 可以直接從你的數據中提取:
<li key={item.id}>{item.name}</li>
當以上情況不成立時,你可以新增一個 ID 字段到你的模型中,或者利用一部分內容作為哈希值來生成一個 key。這個 key 不需要全局唯一,但在列表中需要保持唯一。
最后,你也可以使用元素在數組中的下標作為 key。這個策略在元素不進行重新排序時比較合適,但一旦有順序修改,diff 就會變得慢。
當基于下標的組件進行重新排序時,組件 state 可能會遇到一些問題。由于組件實例是基于它們的 key 來決定是否更新以及復用,如果 key 是一個下標,那么修改順序時會修改當前的 key,導致非受控組件的 state(比如輸入框)可能相互篡改導致無法預期的變動。
3、總結
請謹記協調算法是一個實現細節。React 可以在每個 action 之后對整個應用進行重新渲染,得到的最終結果也會是一樣的。在這個 context 下,重新渲染表示在所有組件內調用 render 方法,這不代表 React 會卸載或裝載它們。React 只會基于以上提到的規則來決定如何進行差異的合并。
我們定期探索優化算法,讓常見用例更高效地執行。在當前的實現中,可以理解為一棵子樹能在其兄弟之間移動,但不能移動到其他位置。在這種情況下,算法會重新渲染整棵子樹。
由于 React 依賴探索的算法,因此當以下假設沒有得到滿足,性能會有所損耗。
1.該算法不會嘗試匹配不同組件類型的子樹。如果你發現你在兩種不同類型的組件中切換,但輸出非常相似的內容,建議把它們改成同一類型。在實踐中,我們沒有遇到這類問題。
2.Key 應該具有穩定,可預測,以及列表內唯一的特質。不穩定的 key(比如通過 Math.random() 生成的)會導致許多組件實例和 DOM 節點被不必要地重新創建,這可能導致性能下降和子組件中的狀態丟失。
摘錄自官網文檔Reconciliation部分,自己記錄學習而已。