React 中 Virtual DOM 與 Diffing 算法的關係

前言

這篇文章是基於 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) 的比較策略,這個策略有兩個假設

  1. 兩個不同類型的元素會產出不同的樹
  2. 開發者可以通過 key prop 來保持元素的穩定

Diffing 策略

1. 對比根節點的元素

如果爲不同類型,React 將會把原有的樹拆卸並重新建立新的樹。例如 <div> -> <span>

  1. 當這顆樹被拆卸後,對應的 DOM 節點也被銷燬,組件實例回調用 willUnmount 方法。
  2. 當建立新的樹的時候,對應的 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)意味着會調用所有的 componentrender(),但並不意味着 React 會卸載(unmount)重載(remount)它們。它(協調算法)只會用上述規則在其過程中找出不同。

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章