[第14期] [長文預警] 深入了解React 渲染原理及性能優化

如今的前端,框架橫行,出去面試問到框架是常有的事。

我比較常用React, 這裡就寫了一篇 React 基礎原理的內容, 面試基本上也就問這些, 分享給大家。

React 是什麼

React是一個專註於構建用戶界面的 Javascript Library.

React做了什麼?

  • Virtual Dom模型
  • 生命周期管理
  • setState機制
  • Diff算法
  • React patch、事件系統
  • React的 Virtual Dom模型

virtual dom 實際上是對實際Dom的一個抽象,是一個js對象。react所有的表層操作實際上是在操作Virtual dom。

經過 Diff 算法會計算出 Virtual DOM 的差異,然後將這些差異進行實際的DOM操作更新頁面。

React 總體架構

幾點要了解的知識

  • JSX 如何生成Element
  • Element 如何生成DOM

1

JSX 如何生成Element

先看一個例子, Counter :

App.js 就做了一件事情,就是把 Counter 組件掛在 #root 上.

Counter 組件裏面定義了自己的 state, 這是個默認的 property ,還有一個 handleclick 事件和 一個 render 函數。

看到 render 這個函數里,竟然在 JS 裏面寫了 html !

這是一種 JSX 語法。React 為了方便 View 層組件化,承載了構建 html 結構化頁面的職責。

這裡也簡單的舉個例子:

將 html 語法直接加入到 javascript 代碼中,再通過翻譯器轉換到純 javascript 後由瀏覽器執行。

這裡調用了 React 和 createElement 方法,這個方法就是用於創建虛擬元素 Virtual Dom 的。

React 把真實的 DOM 樹轉換成 Javascript 對象樹,也就是 Virtual Dom。

每次數據更新後,重新計算 Virtual Dom ,並和上一次生成的 virtual dom 做對比,對發生變化的部分做批量更新。

而 React 是通過創建與更新虛擬元素 Virtual Element 來管理整個Virtual Dom 的。

虛擬元素可以理解為真實元素的對應,它的構建與更新都是在內存中完成的,並不會真正渲染到 dom 中去。

回到我們的計數器 counter 組件:

注意下 a 標籤 createElement 的返回結果, 這裡 CreateElement 只是做了簡單的參數修正,返回一個 ReactElemet 實例對象。

Virtual element 彼此嵌套和混合,就得到了一顆 virtual dom 的樹:

2

Element 如何生成DOM

現在我們有了由 ReactElement 組成的 Virtual Dom 樹,接下來我們要怎麼我們構建好的 Virtual dom tree 渲染到真正的 DOM 裏面呢?

這時可以利用 ReactDOM.render 方法,傳入一個 reactElement 和一個 作為容器的 DOM 節點。

看進去 ReactDOM.render 的源碼,裏面有兩個比較關鍵的步驟:

第一步是 instantiateReactComponent。

這個函數創建一個 ReactComponent 的實例並返回,也可以看到 ReactDOM.render 最後返回的也是這個實例。

instantiateReactComponent 方法是初始化組件的入口函數,它通過判斷 node 的類型來區分不同組件的入口。

  1. 當 node 為空的時候,初始化空組件。
  2. 當 node 為對象,類型 type 字段標記為是字符串,初始化 DOM 標籤。否則初始化自定義組件。
  3. 當 node 為字符串或者數字時,初始化文本組件。

雖然 Component 有多種類型,但是它們具有基本的數據結構:ReactComponent 類。

注意到這裡的 setState, 這也是重點之一。

創建了 Component 實例後,調用 component 的 mountComponent 方法,注意到這裡是會被批量 mount 的,這樣組件就開始進入渲染到 DOM 的流程了。

React生命周期

React 組件基本由三個部分組成,

  1. 屬性 props
  2. 狀態 state
  3. 生命周期方法

React 組件可以接受參數props, 也有自身狀態 state。 一旦接受到的參數 props 或自身狀態 state 有所改變,React 組件就會執行相應的生命周期方法。 React 生命周期的全局圖

首次掛載組件時,按順序執行

  1. componentWillMount、
  2. render
  3. componentDidMount

卸載組件時,執行 componentDidUnmount

當組件接收到更新狀態,重新渲染組件時,執行

  1. componentWillReceiveProps
  2. shouldComponentUpdate
  3. componentWillUpdate
  4. render
  5. componentDidUpdate

更新策略

通過 updateComponent 更新組件,首先判讀上下文是否改變,前後元素是否一致,如果不一致且組件的 componentWillReceiveProps 存在,則執行。然後進行 state 的合併。

調用 shouldComponentUpdate 判斷是否需要進行組件更新,如果存在 componentWillUpdate 則執行。

後面的流程跟 mountComponent 相似,這裡就不贅述了。

setState機制

為避免篇幅過長,這部分可移步我的另一篇文章:

[第10期] 深入了解 React setState 運行機制

Diff算法

Diff算法用於計算出兩個virtual dom的差異,是React中開銷最大的地方。

傳統diff算法通過循環遞歸對比差異,算法複雜度為 O(n3)。

React diff算法制定了三條策略,將算法複雜度從 O(n3)降低到O(n)。

  • 1. UI中的DOM節點跨節點的操作特別少,可以忽略不計。
  • 2. 擁有相同類的組件會擁有相似的DOM結構。擁有不同類的組件會生成不同的DOM結構。
  • 3. 同一層級的子節點,可以根據唯一的ID來區分。

1. Tree Diff

對於策略一,React 對樹進行了分層比較,兩棵樹只會對同一層次的節點進行比較。

只會對相同層級的 DOM 節點進行比較,當發現節點已經不存在時,則該節點及其子節點會被完全刪除,不會用於進一步的比較。

如果出現了 DOM 節點跨層級的移動操作。

如上圖這樣,A節點就會被直接銷毀了。

Diif 的執行情況是:create A -> create C -> create D -> delete A

2. Element Diff

  1. 當節點處於同一層級時,diff 提供了 3 種節點操作:插入、移動和刪除。
  2. 對於同一層的同組子節點添加唯一 key 進行區分。

通過 diff 對比後,發現新舊集合的節點都是相同的節點,因此無需進行節點刪除和創建,只需要將舊集合中節點的位置更新為新集合中節點的位置.

原理解析

幾個概念

  • 對新集合中的節點進行循環遍歷,新舊集合中是否存在相同節點
  • nextIndex: 新集合中當前節點的位置
  • lastIndex: 訪問過的節點在舊集合中最右的位置(最大位置)
  • If (child._mountIndex < lastIndex)

對新集合中的節點進行循環遍歷,通過 key 值判斷,新舊集合中是否存在相同節點,如果存在,則進行移動操作。

在移動操作的過程中,有兩個指針需要注意,

一個是 nextIndex,表示新集合中當前節點的位置,也就是遍歷新集合時當前節點的坐標。

另一個是 lastIndex,表示訪問過的節點在舊集合中最右的位置,

更新流程:

1

( 如果新集合中當前訪問的節點比 lastIndex 大,證明當前訪問節點在舊集合中比上一個節點的位置靠後,則該節點不會影響其他節點的位置,即不進行移動操作。只有當前訪問節點比 lastIndex 小的時候,才需要進行移動操作。)

首先,我們開遍歷新集合中的節點, 當前 lastIndex = 0, nextIndex = 0,拿到了 B.

此時在舊集合中也發現了 B,B 在舊集合中的 mountIndex 為 1 , 比當前 lastIndex 0 要大,不滿足 child._mountIndex < lastIndex,對 B 不進行移動操作,更新 lastIndex = 1, 訪問過的節點在舊集合中最右的位置,也就是 B 在舊集合中的位置,nextIndex++ 進入下一步。

2

當前 lastIndex = 1, nextIndex = 1,拿到了 A,在舊集合中也發現了 A,A 在舊集合中的 mountIndex 為 0 , 比當前 lastIndex 1 要小,滿足 child._mountIndex < lastIndex,對 A 進行移動操作,此時 lastIndex 依然 = 1, A 的 _mountIndex 更新為 nextIndex = 1, nextIndex++, 進入下一步.

3

這裡,A 變成了藍色,表示對 A 進行了移動操作。

當前 lastIndex = 1, nextIndex = 2,拿到了 D,在舊集合中也發現了 D,D 在舊集合中的 mountIndex 為 3 , 比當前 lastIndex 1 要大,不滿足 child._mountIndex < lastIndex,不進行移動操作,此時 lastIndex = 3, D 的 _mountIndex 更新為 nextIndex = 2, nextIndex++, 進入下一步.

4

當前 lastIndex = 3, nextIndex = 3,拿到了 C,在舊集合中也發現了 C,C 在舊集合中的 mountIndex 為 2 , 比當前 lastIndex 3 要小,滿足 child._mountIndex < lastIndex,要進行移動,此時 lastIndex不變,為 3, C 的 _mountIndex 更新為 nextIndex = 3.

5

由於 C 已經是最後一個節點,因此 diff 操作完成.

這樣最後,要進行移動操作的只有 A C。

另一種情況

剛剛說的例子是新舊集合中都是相同節點但是位置不同。

那如果新集合中有新加入的節點且舊集合存在需要刪除的節點,

那 diff 又是怎麼進行的呢?比如:

1

首先,依舊,我們開遍歷新集合中的節點, 當前 lastIndex = 0, nextIndex = 0,拿到了 B,此時在舊集合中也發現了 B,B 在舊集合中的 mountIndex 為 1 , 比當前 lastIndex 0 要大,不滿足 child._mountIndex < lastIndex,對 B 不進行移動操作,更新 lastIndex = 1, 訪問過的節點在舊集合中最右的位置,也就是 B 在舊集合中的位置,nextIndex++ 進入下一步。

2

當前 lastIndex = 1, nextIndex = 1,拿到了 E,發現舊集合中並不存在 E,此時創建新節點 E,nextIndex++,進入下一步

3

當前 lastIndex = 1, nextIndex = 2,拿到了 C,在舊集合中也發現了 C,C 在舊集合中的 mountIndex 為 2 , 比當前 lastIndex 1 要大,不滿足 child._mountIndex < lastIndex,不進行移動,此時 lastIndex 更新為 2, nextIndex++ ,進入下一步

4

當前 lastIndex = 2, nextIndex = 3,拿到了 A,在舊集合中也發現了 A,A 在舊集合中的 mountIndex 為 0 , 比當前 lastIndex 2 要小,不滿足 child._mountIndex < lastIndex,進行移動,此時 lastIndex 不變, nextIndex++ ,進入下一步

5

當完成新集合中所有節點的差異化對比後,還需要對舊集合進行循環遍歷,判斷是否勛在新集合中沒有但舊集合中存在的節點。

此時發現了 D 滿足這樣的情況,因此刪除 D。

Diff 操作完成。

整個過程還是很繁瑣的, 明白過程即可。

二、性能優化

由於react中性能主要耗費在於update階段的diff算法,因此性能優化也主要針對diff算法。

1

減少diff算法觸發次數

減少diff算法觸發次數實際上就是減少update流程的次數。

正常進入update流程有三種方式:

1.setState

setState機制在正常運行時,由於批更新策略,已經降低了update過程的觸發次數。

因此,setState優化主要在於非批更新階段中(timeout/Promise回調),減少setState的觸發次數。

常見的業務場景即處理接口回調時,無論數據處理多麼複雜,保證最後只調用一次setState。

2.父組件render

父組件的render必然會觸發子組件進入update階段(無論props是否更新)。此時最常用的優化方案即為shouldComponentUpdate方法。

最常見的方式為進行this.props和this.state的淺比較來判斷組件是否需要更新。或者直接使用PureComponent,原理一致。

需要注意的是,父組件的render函數如果寫的不規範,將會導致上述的策略失效。

// Bad case  // 每次父組件觸發render 將導致傳入的handleClick參數都是一個全新的匿名函數引用。  // 如果this.list 一直都是undefined,每次傳入的默認值[]都是一個全新的Array。  // hitSlop的屬性值每次render都會生成一個新對象  class Father extends Component {      onClick() {}      render() {          return <Child handleClick={() => this.onClick()} list={this.list || []} hitSlop={{ top: 10, left: 10}}/>      }  }  // Good case  // 在構造函數中綁定函數,給變量賦值  // render中用到的常量提取成模塊變量或靜態成員  const hitSlop = {top: 10, left: 10};  class Father extends Component {      constructor(props) {          super(props);          this.onClick = this.onClick.bind(this);          this.list = [];      }      onClick() {}      render() {          return <Child handleClick={this.onClick} list={this.list} hitSlop={hitSlop} />      }  }  

3. forceUpdate

forceUpdate方法調用後將會直接進入componentWillUpdate階段,無法攔截,因此在實際項目中應該棄用。

其他優化策略

1. shouldComponentUpdate

使用shouldComponentUpdate鉤子,根據具體的業務狀態,減少不必要的props變化導致的渲染。如一個不用於渲染的props導致的update。 另外, 也要盡量避免在shouldComponentUpdate 中做一些比較複雜的操作, 比如超大數據的pick操作等。

2. 合理設計state,不需要渲染的state,盡量使用實例成員變量。

不需要渲染的 props,合理使用 context機制,或公共模塊(比如一個單例服務)變量來替換。

2

正確使用 diff算法

  • 不使用跨層級移動節點的操作。
  • 對於條件渲染多個節點時,盡量採用隱藏等方式切換節點,而不是替換節點。
  • 盡量避免將後面的子節點移動到前面的操作,當節點數量較多時,會產生一定的性能問題。

看個具體的例子

這時一個 List 組件,裏面有標題,包含 ListItem 子組件的members列表,和一個按鈕,綁定了一個 onclick 事件.

然後我加了一個插件,可以顯示出各個組件的渲染情況。

現在我們來點擊改變標題, 看看會發生些什麼。

奇怪的事情發生了,為什麼我只改了標題, 為什麼不相關的 ListItem 組件也會重新渲染呢?

我們可以回到組件生命周期看看為什麼。

還記得這個組件更新的生命周期流程圖嘛,這裡的重點在於這個 shouldComponentUpdate。

只有這個方法返回 true 的時候,才會進行更新組件的操作。我們進步一來看看源碼。

可以看到這裡,原來如果組件沒有定義 shouldComponentUpdate 方法,也是默認認為需要更新的。

當然,我們的 ListItem 組件是沒有定義這個 shouldComponentUpdate 方法的。

然後我們使用PureComponent :

其原理為重新實現了 shouldComponentUpdate 生命周期方法,讓當前傳入的 props 和 state 之前做淺比較,如果返回 false ,那麼組件就不會更新了。

這裡也放上一張官網的例圖:

根據渲染流程,首先會判斷shouldComponentUpdate(SCU)是否需要更新。

如果需要更新,則調用組件的render生成新的虛擬DOM,然後再與舊的虛擬DOM對比(vDOMEq)。

如果對比一致就不更新,如果對比不同,則根據最小粒度改變去更新DOM;

如果SCU不需要更新,則直接保持不變,同時其子元素也保持不變。

相似的APi還有React.memo:

回到組件

再次回到我們的組件中, 這次點擊按鈕, 把第二條數據換掉:

奇怪的事情發生了,為什麼我只改了第二個 listItem, 還是全部 10 個都重新渲染了呢?

原因在於 shallow compare , 淺比較。

前面說到,我們不能直接修改 this.state 的值,所以我們把

this.state.members 拷貝出來再修改第二個人的信息。

很明顯,因為對象的比較是引用地址,顯然是不相等的。

因此 shoudComponentUpdate 方法都返回了 false, 組件就進行了更新。

那麼我們怎麼能避免這種情況的發生呢?

其中一個方法是做深比較,但是如果對象或數組層級比較深和複製,那麼這個代價就太昂貴了。

我們就可以用到 Immutable.js 來解決這個問題,進一步提高組件的渲染性能。

Immutable Data 就是一旦被創建,就是不能再更改的數據。

首先,我們定義了一個 Immutable 的 List 對象,List 對應於原生 JS 的 Array,對 Immutable 對象進行修改、添加或刪除操作,都會返回一個新的 Immutable 對象,所以這裡 bbb 不等於 aaa。

但是同時為了避免深拷貝吧所有節點都複製一遍帶來的性能消耗,Immutable 使用了結構共享,即如果對象樹中一個節點發生變化,只修改這個節點和受它影響的父節點,其他節點則進行共享。

結果也是我們預期的那樣。

性能分析

用好火焰圖, 該優化的時候再優化。

Hooks 及其後續更新

請轉到 第7期:全面了解 React Suspense 和 Hooks

最後

關注我啦

如果你覺得內容有幫助可以關注下這個公眾號 「 前端e進階 」,一起成長!

我就知道你「在看」