前言
這篇文章是基於 React 官方文檔對於 Virtual DOM 的理念和 Diffing 算法的策略的整合。
Virtual DOM 是一種編程理念
Virtual DOM 是一種編程理念。UI 信息被特定語言描述並保存到內存中,再通過特定的庫,例如 ReactDOM 與真實的 DOM 同步信息。這一過程成爲 協調 (Reconciliation)
。
與之對應的數據結構
Virtual DOM 反映到實際的數據結構上,就是每一個 React 的 fiber node
// UI 組件描述
const Span = (props) => <span></span>
// 實際的 Fiber node structure
{
stateNode: new HTMLSpanElement,
type: "span",
alternate: null,
key: null,
updateQueue: null,
memoizedState: null,
pendingProps: {},
memoizedProps: {},
tag: 1,
effectTag: 0,
nextEffect: null
}
這一抽離結構有點像 React 版本的 AST 抽象語法樹。
Diffing 算法
問題
在 Virtual DOM -> Real DOM 之間的轉換過程中,需要高效率的算法來支撐。由於某個時刻調用 React render() 方法生成的 React 元素組成的樹,與下一次 state 或 props 變化時調用同一個 render 返回的樹是不一樣的,React 需要根據這兩個不同的樹來決定如何高效地讓最新的 Virtual DOM 反應到真實 DOM 中。
解決方式
Diffing 算法就是解決如何更有效率地更新 UI 的關鍵。
React 採取了一個複雜度爲 O(n) 的比較策略,這個策略有兩個假設
- 兩個不同類型的元素會產出不同的樹
- 開發者可以通過 key prop 來保持元素的穩定
Diffing 策略
1. 對比根節點的元素
如果爲不同類型,React 將會把原有的樹拆卸並重新建立新的樹。例如 <div>
-> <span>
。
- 當這顆樹被拆卸後,對應的 DOM 節點也被銷燬,組件實例回調用 willUnmount 方法。
- 當建立新的樹的時候,對應的 DOM 將被插入到 DOM 中,並調用 didMount 方法。
在根節點以下的組件也會被卸載,它們的狀態會被銷燬。例如:
<div>
<Counter />
</div>
<span>
<Counter />
</span>
2. 對比同一類型的元素
當對比兩個同類型的 React 元素時,React 會保留 DOM 節點,僅對比以及更新有變化的屬性
<div className="before" title="stuff" />
<div className="after" title="stuff" />
通過對比兩個元素,React 得知 className
變化,所以只需要更新 DOM 對應元素上的 class
。
當處理完當前節點時,React 將會對子節點進行遞歸。
3. 對比同類型的組件元素
當一個 React 組件需要更新時(例如 props
有變化),組件實例保持不變,實例中的 state 能在不同渲染時保持一致。React 將更新該組件實例的 props
以保持與最新的元素的一致。並調用 該實例的原型 上的函數 getDerivedStateFromProps
(官方文檔是 componentWillReceiveProps 和 componentWillUpdate,但這將會被棄用)。
下一步是調用該實例的 render
方法,diffing 算法將在之前的結果和最新的結果中進行遞歸。
4. 對子節點進行遞歸
問題
在默認條件下,當遞歸 DOM 節點的子元素時,React 會同時遍歷兩個子元素的列表,當發現兩個子元素有差異時,將生成一個「變種(mutation)」。
例如在子元素列表末尾新增元素時,更變開銷比較小。比如:
// before
<ul>
<li>first</li>
<li>second</li>
</ul>
// after
<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>
子樹不變,從而導致重新渲染。這種情況下的低效可能會帶來性能問題。
解決策略 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 的元素僅僅移動了位置。
所以一般在開發的時候最好使用一個有唯一屬性的 id 來作爲 key
<li key={item.id}>{item.name}</li>
在開發者自己確定數組數據不會輕易改變
的情況下纔可以用數組下表來作爲 key。
權衡(Tradeoffs)
上述只是 協調算法(reconciliation algorithm)的實現細節而已。React 可以響應每一次 action 後重新渲染整個應用,最終結果也會是一樣的。
需要明確知道的是,在當前上下文(this context)
中重新渲染(rerender)
意味着會調用所有的 component
的 render()
,但並不意味着 React 會卸載(unmount)
或重載(remount)
它們。它(協調算法)只會用上述規則在其過程中找出不同。