[第14期] [長文預警] 深入了解React 渲染原理及性能優化
- 2020 年 3 月 3 日
- 筆記
如今的前端,框架橫行,出去面試問到框架是常有的事。
我比較常用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 的類型來區分不同組件的入口。
- 當 node 為空的時候,初始化空組件。
- 當 node 為對象,類型 type 字段標記為是字符串,初始化 DOM 標籤。否則初始化自定義組件。
- 當 node 為字符串或者數字時,初始化文本組件。

雖然 Component 有多種類型,但是它們具有基本的數據結構:ReactComponent 類。
注意到這裡的 setState, 這也是重點之一。

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

React 組件基本由三個部分組成,
- 屬性 props
- 狀態 state
- 生命周期方法
React 組件可以接受參數props, 也有自身狀態 state。 一旦接受到的參數 props 或自身狀態 state 有所改變,React 組件就會執行相應的生命周期方法。 React 生命周期的全局圖

首次掛載組件時,按順序執行
- componentWillMount、
- render
- componentDidMount
卸載組件時,執行 componentDidUnmount
當組件接收到更新狀態,重新渲染組件時,執行
- componentWillReceiveProps
- shouldComponentUpdate
- componentWillUpdate
- render
- 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
- 當節點處於同一層級時,diff 提供了 3 種節點操作:插入、移動和刪除。
- 對於同一層的同組子節點添加唯一 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進階 」,一起成長!
我就知道你「在看」