react效能優化|bailout策略
theme: smartblue
前面的文章梳理了Fiber架構的render
流程,我們知道 beginWork
的目的是為傳入的workInprogress fiberNode
生成子fiberNode
,生成的方式有兩種:
- 通過對比
wip.child
(workInprogress簡寫)對應的current fiberNode
和新的reactElement
,生成子fiberNode
,稱為reconcile
流程。 - 通過
bailout
策略複用子fiberNode
。
把bailout
策略展示在beginWork
中的流程如圖所示:
在 react 中,引起fiberNode
變化的因素包括
- state
- props
- context
流程圖中兩次判斷是否命中bailout
都是圍繞這三點來的,具體如下:
bailout策略
第一次判斷
下圖是 react18.2 中關於第一次判斷的原始碼,有四個條件
條件1:oldProps === newProps
這裡的比較是全等比較,也意味著滿足這個條件並不容易,我們知道:
- 一個物件不全等於另一個物件。
createElement
方法每次接收的props
引數都是一個新的物件,即使沒有props也是一個空物件。beginWork
有兩種方式生成子fiberNode
,命中bailout
複用節點或者通過reconcile
生成新節點,reconcile
需要子節點新的reactElement
,這就需要執行createElement
。
通過這三點就知道,要想滿足這個條件,父fiberNode
進入beginWork
後必須命中bailout
策略去複用子fiberNode
,這樣在子fiberNode
的beginWork
中,oldProps全等於newProps 才會成立,子fiberNode
才有可能命中bailout
策略。
換句話說,在 react 中渲染是具有傳染性的,只要父節點沒有命中策略,子節點就一定不會命中,孫節點也不會,如此往復。
條件2:Legacy Context沒有變化
Legacy Context 是舊的 Context API,有舊的就會有新的,這裡簡單介紹一下。
⚠️注意,建議先跨過這一小節,看完其他內容再回來看。
在舊的 Context 系統中,上下文資料會被存在棧裡:
- 在每一個
Provider
的beginWork
流程中,對應的 Context 都會入棧,在Consumer
中就可以通過 Context 棧向上找到對應的上下文。 - 在每一個
Provider
的completeWork
流程中,對應的 Context 又會出棧。
在reconcile
和優化程度較低的bailout
(即只複用了子fiberNode
)中,這個系統沒有問題,但如果命中優化程度高的bailout
,就會跳過整個子樹的beginWork
和completeWork
,Context 出入棧自然會被跳過,子樹中如果存在Consumer
,就不會響應到更新。react 官網中有對舊 Context 的介紹,這篇文章結尾也指出了這個問題(連結是舊版文件,新版文件可以自行查閱)。
為了解決這個問題,react 團隊設計了新的 Context API,原理是這樣的:當Provider
進入beginWork
中,會判斷 Context 是否有變化,如果有變化,會立刻向下開啟一次深度優先遍歷,去尋找Consumer
,找到之後,會為Consumer
對應的fiberNode.lanes
附加renderLanes
,然後再從這個Consumer fiberNode
向上遍歷,依次為祖先fiberNode.childLanes
附加renderLanes
。
🌟renderLanes
、lanes
、childLanes
是和排程有關的內容,這裡只需要知道
fiberNode.lanes
附加renderLanes
就代表該fiberNode
存在更新。fiberNode.childLanes
附加renderLanes
就代表該fiberNode
的子樹中存在更新。
所以,即使Provider
命中了bailout
策略,在選擇優化程度時,子樹有更新,就選擇低程度的優化,不會跳過整顆子樹的beginWork
,當然就不會影響子樹中Consumer
對 Context 更新的響應。
條件3:fiberNode.type沒有變化
這個沒什麼好說的
條件4:當前fiberNode沒有更新發生
沒有更新發生意味著state沒有變化,但是有更新發生並不代表state就會變化,判斷是否有更新發生是判斷fiberNode.lanes
屬性,該屬性和排程有關,這裡不細說。
比如下面的例子:
jsx
function Button() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(1)}>測試</button>
}
按鈕點選時,Button的fiberNode
就有更新發生,但是每次更新的都是1,state就沒有變化。
選擇優化程度
當以上條件都滿足時,第一次判斷就命中了bailout
策略,會執行bailoutOnAlreadyFinishedWork
方法,選擇優化程度。
- 😄高程度:整顆子樹沒有更新時(判斷
fiberNode.childLanes
)選擇,可以想到如果這顆子樹很龐大,那麼效能優化的效果是顯著的。 - 😊低程度:子樹中存在更新,只複用
子fiberNode
,看方法名cloneChildFibers
可以猜到,複用的方式就是基於當前子節點的current fiberNode
克隆出wip fiberNode
,這裡就優化了這個子節點的reconcile
流程。
第二次判斷
第一次判斷沒有命中,會根據fiberNode.tag
走不同邏輯,其中部分型別節點還有第二次判斷,有兩種命中的可能
使用了效能優化API
函式元件的memo
和類元件的PureComponent
、shouldComponentUpdate
。
在第一次判斷時,props通過全等方式比較,只要調了reactElement
,newProps 就是一個新物件,即使是屬性都相同也不全等,如果使用淺比較的方式,命中概率會高很多。
如果給函式元件使用memo
,fiberNode.tag
就會是SimpleMemoComponent
或MemoComponent
,這取決於是否給元件設定了比較函式(預設是shallowEqual
),設定了就是MemoComponent
。
jsx
const Child = memo(() => <div>Child</div>);
Child.displayName = 'Child';
Child.compare = (p, c) => p === c;
這裡以MemoComponent
為例看一下,會走updateMemoComponent
:
如果fiberNode
沒有更新發生,通過比較函式props也沒變,ref也沒變,就命中bailout
,否則就去建立新的fiberNode
。
類元件這裡就不細說了,只要shouldComponentUpdate
返回false,就滿足類似於函式元件props沒變的效果。
有更新,但是state沒變化
這條路徑算是 react 中的一個邊界情況,先來看一個例子 ```jsx function Button2() { const [count, setCount] = useState(0)
function handleClick(){ setCount(1) setCount(0) }
return } ``` Button 元件點選後有更新發生,但是state沒改變,儘管有一次更新改變了,但是最終 state 是沒改變的,這涉及到 react 批量更新的特性。
使用過 react 的同學肯定會認為這不會引起render
,因為 state 都沒變。
確實不會render
,但看一下第一次判斷的條件4,是判斷有沒有更新發生,並不是判斷 state 有沒有改變,所以這裡 Button 元件第一次判斷是不會命中bailout
的,那為什麼不會render
呢?🤔
其實在 react 中有一個全域性變數didReceiveUpdate
,一些型別的fiberNode
即使在第一次沒命中並且沒有使用效能優化API時,在beginWork
時候還會根據didReceiveUpdate
來決定命不命中bailout
,didReceiveUpdate===false
就會命中:
在更新發生時,會判斷 state 有沒有改變,如果有改變,didReceiveUpdate
就會被賦值為true,從而不會命中bailout
,反之則不會被賦值為true,就會命中。
但要注意,didReceiveUpdate
是一個全域性變數,很多地方都有賦值操作,並不代表某元件的更新沒有讓 state 改變didReceiveUpdate
就一定會是false,只是這個機制可以解釋上面 Button 元件的例子不render
。
eagerState策略
有意思的是,如果把例子改成這樣: ```jsx function Button2() { const [count, setCount] = useState(0)
function handleClick(){ setCount(0) }
return
}
``
按鈕點選後當然還是不會
render,但此時不
render的原因和上面的例子不一樣了,這是另一個策略,稱為
eagerState策略:**如果當前**
fiberNode`不存在待執行的更新,某個狀態更新前後沒有變化,可以跳過後續更新流程。
這個策略我個人認為沒有太大的學習價值,因為一般我們不會寫出示例中的程式碼,下面我淺淺描述一下。
有一個前提條件是不存在待執行的更新,意味著此時的更新是第一個更新,並且不會被其他更新所影響,所以這次更新可以提前到schedule
階段之前執行,如果state 沒有改變,則不會進入schedule
階段,schedule
是排程render
任務的,自然也就不會有render
發生。
當第二次判斷成功命中bailout
,接下來和第一次判斷命中一樣,執行bailoutOnAlreadyFinishedWork
方法選擇優化程度。
bailout規則流程總結
優化程式碼示例
直接看程式碼 ```jsx import React, { useState } from 'react'; import ReactDOM from 'react-dom/client';
function Example(props) { const [num, setNum] = useState(0); const handleClick = () => setNum(n => n + 1);
return (
<>
);
}
const Child = () => { return (
- {/ 一個長列表 /}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
hostRootFiber
進入beginWork
,第一次判斷是否命中bailout
策略,四個條件都滿足,命中,子樹有更新發生,低程度優化,複用子fiberNode
(Example 對應的fiberNode
)。- Example 進入
beginWork
,第一次判斷,有更新發生,條件4不滿足,未命中,沒有使用效能優化API,狀態發生改變,走recondile
流程(會生成新的reactElement
)。 - button 進入
beginWork
,第一次判斷,newProps !== oldProps,條件1就不滿足,未命中,HostComponent
型別,不存在第二次判斷,走recondile
流程。 - button 進入
completeWork
。 - Child 進入
beginWork
,第一次判斷,newProps !== oldProps,條件1就不滿足,未命中,沒有使用效能優化API,走recondile
流程。 - 接下來的長列表也是一樣,不會命中,一直走
reconcile
。
我們使用 react 開發者工具可以看到這個結果
如果 Child 內包含了非常多節點,這樣的渲染流程肯定會對效能造成影響。為了命中bailout
策略,有兩種改法
優化元件結構
```jsx import React, { useState } from 'react'; import ReactDOM from 'react-dom/client';
function Example(props) { return ( <> ); };
const Child = () => { return (
- {/ 一個長列表 /}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
hostRootFiber
進入beginWork
,第一次判斷是否命中bailout
策略,四個條件都滿足,命中,子樹有更新發生,低程度優化,複用子fiberNode
(Example 對應的fiberNode
)。- Example 進入
beginWork
,第一次判斷,四個條件都滿足,命中,子樹有更新發生,低程度優化,複用子fiberNode
([Button, Child])。 - Button 進入
beginWork
,第一次判斷,有更新發生,條件4不滿足,未命中,未使用效能優化API,狀態發生變化,未命中,走reconcile
流程。 - button 進入
beginWork
,第一次判斷,newProps!==oldProps,未命中,走reconcile
。 - button 進入
completeWork
。 - Button 進入
completeWork
。 - Child 進入
beginWork
,第一次判斷,四個條件都滿足,子樹沒有更新,高程度優化,整個子樹跳過beginWork
階段。 - Child 進入
completeWork
。
通過分析我們知道,只有Button元件內部走了完整了reconcile
流程,其他階段都命中了bailout
,Child 元件甚至是高程度優化,顯著提升了效能。
再來看一下開發者工具
可以看到只有 Button 元件渲染了,其他元件都是置灰狀態,也就表示沒有渲染。
讓我們來看一下具體是怎麼優化的元件結構,我們可以分析出來優化前的程式碼,主要是因為 Example 元件走了reconcil
流程,使用了新的reactElement
,所以每一個子節點的 props 都變成了新的物件(即使是空物件),所以也就無法命中bailout
策略,前面也說了,在 react 中渲染是具有傳染性的。那我們可以想辦法讓 Example 元件命中bailout
策略,所以把引起改變的部分抽離成元件,這個方法用一句話概括就是:變的部分和不變的部分分離。
使用效能優化API
或者可以使用一種比較簡單的方式,直接使用memo
```jsx
import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';
function Example(props) { const [num, setNum] = useState(0); const handleClick = () => setNum(n => n + 1);
return (
<>
);
}
const Child = memo(() => { return (
- {/ 一個長列表 /}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(``
我們看一下優化前的流程分析的第五點,因為使用了
memo`,所以需要改一下:
Child 進入beginWork
,第一次判斷,newProps !== oldProps,條件1就不滿足,未命中,使用效能優化API,經過比較發現props沒有變化,命中,子樹沒有更新發生,跳過整顆子樹的beginWork
。
開發者工具的效果和第一種優化方法一致。
可以看到這種方式比較簡單,可以降低開發者的心智負擔,但要論最極致的優化方式,還是第一種更高,因為任何一個性能優化API都有其本身的優化開銷。
對開發的啟示
下面針對這部分內容,我列一些針對於開發中的啟示,遵循的原則基本只有一條:儘量避免不必要的渲染。
1. 注意元件結構
根據變的部分和不變的部分分離這個原則來儘可能的優化元件結構,詳細內容上面已經闡述,這裡來看一個不一樣的例子 ```jsx import React, { useState } from 'react'; import ReactDOM from 'react-dom/client';
function Example(props) { const [num, setNum] = useState(0); const handleClick = () => setNum(n => n + 1);
return (
const Child = () => { return (
- {/ 一個長列表 /}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(這裡會引起改變的 div 和 Child 元件看起來不容易分離,其實可以使用 children 屬性
jsx
import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';
function Example(props) { return (
function Div({children}) { const [num, setNum] = useState(0); const handleClick = () => setNum(n => n + 1);
return (
const Child = () => { return (
- {/ 一個長列表 /}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
2. 不要定義不必要的state
- 可以通過已有 state 計算出的狀態,就不要再重新定義一個新的 state
- 某些不需要引起元件更新的狀態,考慮使用 ref 來替代
3. 避免在元件內定義元件
會造成fiberNode.type
發生改變,命中不了bailout
策略,這種程式碼完全可以把 Child 放在函式外面定義,或者使用useMemo
快取。
jsx
function App(props) {
const Child = () => <h1>Child</h1>
return (
<>
<Child />
);
}
4. 真的需要響應式API嗎
redux
、mobx
或者其周邊庫給我們提供了一些響應式的API,比如mobx
的autorun、observer,redux
的useSelector、connect。
我在開發中見過一些元件在並不真的需要的情況下仍然使用這些API,元件體量不大的時候可以不管,但是當子樹的效能開銷比較大的時候可能就要注意了。
5. 真的不需要狀態管理庫嗎
有些人認為狀態管理庫並不是很有必要,因為 react 的useState
、useReducer
、Context
就已經是一套很好用的狀態管理方案了,但其實 Context 效能並不是很好,而大多數的狀態管理庫實現的API,效能都優於 Context。
6. 學會使用 react 開發者工具
開發者工具能很直觀的讓我們看到元件的渲染資訊。尤其是Profiler,官方教程傳送門