react效能優化|bailout策略

語言: CN / TW / HK

theme: smartblue

前面的文章梳理了Fiber架構的render 流程,我們知道 beginWork的目的是為傳入的workInprogress fiberNode生成子fiberNode,生成的方式有兩種:

  1. 通過對比wip.child(workInprogress簡寫)對應的current fiberNode和新的reactElement,生成子fiberNode,稱為reconcile流程。
  2. 通過bailout策略複用子fiberNode

bailout策略展示在beginWork中的流程如圖所示:

在 react 中,引起fiberNode變化的因素包括

  • state
  • props
  • context

流程圖中兩次判斷是否命中bailout都是圍繞這三點來的,具體如下:

bailout策略

第一次判斷

下圖是 react18.2 中關於第一次判斷的原始碼,有四個條件

image.png

條件1:oldProps === newProps

這裡的比較是全等比較,也意味著滿足這個條件並不容易,我們知道:

  1. 一個物件不全等於另一個物件。
  2. createElement方法每次接收的props引數都是一個新的物件,即使沒有props也是一個空物件。
  3. beginWork有兩種方式生成子fiberNode,命中bailout複用節點或者通過reconcile生成新節點,reconcile需要子節點新的reactElement,這就需要執行createElement

通過這三點就知道,要想滿足這個條件,父fiberNode進入beginWork後必須命中bailout策略去複用子fiberNode,這樣在子fiberNodebeginWork中,oldProps全等於newProps 才會成立,子fiberNode才有可能命中bailout策略。

換句話說,在 react 中渲染是具有傳染性的,只要父節點沒有命中策略,子節點就一定不會命中,孫節點也不會,如此往復。

條件2:Legacy Context沒有變化

Legacy Context 是舊的 Context API,有舊的就會有新的,這裡簡單介紹一下。

⚠️注意,建議先跨過這一小節,看完其他內容再回來看。

在舊的 Context 系統中,上下文資料會被存在棧裡:

  1. 在每一個ProviderbeginWork流程中,對應的 Context 都會入棧,在Consumer中就可以通過 Context 棧向上找到對應的上下文。
  2. 在每一個ProvidercompleteWork流程中,對應的 Context 又會出棧。

reconcile和優化程度較低的bailout(即只複用了子fiberNode)中,這個系統沒有問題,但如果命中優化程度高的bailout,就會跳過整個子樹的beginWorkcompleteWork,Context 出入棧自然會被跳過,子樹中如果存在Consumer,就不會響應到更新。react 官網中有對舊 Context 的介紹,這篇文章結尾也指出了這個問題(連結是舊版文件,新版文件可以自行查閱)。

為了解決這個問題,react 團隊設計了新的 Context API,原理是這樣的:當Provider進入beginWork中,會判斷 Context 是否有變化,如果有變化,會立刻向下開啟一次深度優先遍歷,去尋找Consumer,找到之後,會為Consumer對應的fiberNode.lanes附加renderLanes,然後再從這個Consumer fiberNode向上遍歷,依次為祖先fiberNode.childLanes附加renderLanes

image.png

🌟renderLaneslaneschildLanes是和排程有關的內容,這裡只需要知道

  • fiberNode.lanes附加renderLanes就代表該fiberNode存在更新。
  • fiberNode.childLanes附加renderLanes就代表該fiberNode的子樹中存在更新。

所以,即使Provider命中了bailout策略,在選擇優化程度時,子樹有更新,就選擇低程度的優化,不會跳過整顆子樹的beginWork,當然就不會影響子樹中Consumer對 Context 更新的響應。

條件3:fiberNode.type沒有變化

這個沒什麼好說的

條件4:當前fiberNode沒有更新發生

沒有更新發生意味著state沒有變化,但是有更新發生並不代表state就會變化,判斷是否有更新發生是判斷fiberNode.lanes屬性,該屬性和排程有關,這裡不細說。 image.png

比如下面的例子:

jsx function Button() { const [count, setCount] = useState(0) return <button onClick={() => setCount(1)}>測試</button> } 按鈕點選時,Button的fiberNode就有更新發生,但是每次更新的都是1,state就沒有變化。

選擇優化程度

當以上條件都滿足時,第一次判斷就命中了bailout策略,會執行bailoutOnAlreadyFinishedWork方法,選擇優化程度。

image.png

  • 😄高程度:整顆子樹沒有更新時(判斷fiberNode.childLanes)選擇,可以想到如果這顆子樹很龐大,那麼效能優化的效果是顯著的。
  • 😊低程度:子樹中存在更新,只複用子fiberNode,看方法名cloneChildFibers可以猜到,複用的方式就是基於當前子節點的current fiberNode克隆出wip fiberNode,這裡就優化了這個子節點的reconcile流程。

第二次判斷

第一次判斷沒有命中,會根據fiberNode.tag走不同邏輯,其中部分型別節點還有第二次判斷,有兩種命中的可能

使用了效能優化API

函式元件的memo和類元件的PureComponentshouldComponentUpdate

在第一次判斷時,props通過全等方式比較,只要調了reactElement,newProps 就是一個新物件,即使是屬性都相同也不全等,如果使用淺比較的方式,命中概率會高很多。

如果給函式元件使用memofiberNode.tag就會是SimpleMemoComponentMemoComponent,這取決於是否給元件設定了比較函式(預設是shallowEqual),設定了就是MemoComponent

jsx const Child = memo(() => <div>Child</div>); Child.displayName = 'Child'; Child.compare = (p, c) => p === c; 這裡以MemoComponent為例看一下,會走updateMemoComponent

image.png

如果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來決定命不命中bailoutdidReceiveUpdate===false就會命中:

image.png

在更新發生時,會判斷 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(); ``` 在這個例子中,Child 元件沒有使用到state,沒有傳入props,它甚至只是一個靜態元件。我們期望 Example 元件的 state 改變不會使它渲染,但事實上Child 仍然會渲染,我們來用上面的知識分析一下原因:

  1. hostRootFiber 進入beginWork,第一次判斷是否命中bailout策略,四個條件都滿足,命中,子樹有更新發生,低程度優化,複用子fiberNode(Example 對應的fiberNode)。
  2. Example 進入beginWork,第一次判斷,有更新發生,條件4不滿足,未命中,沒有使用效能優化API,狀態發生改變,走recondile流程(會生成新的reactElement)。
  3. button 進入beginWork,第一次判斷,newProps !== oldProps,條件1就不滿足,未命中,HostComponent型別,不存在第二次判斷,走recondile流程。
  4. button 進入completeWork
  5. Child 進入beginWork,第一次判斷,newProps !== oldProps,條件1就不滿足,未命中,沒有使用效能優化API,走recondile流程。
  6. 接下來的長列表也是一樣,不會命中,一直走reconcile

我們使用 react 開發者工具可以看到這個結果

image.png

如果 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(); ``` 把 state 的定義和引起 state 改變的內容封裝為元件,Example 元件只是應用這個元件。現在再來分析一下:

  1. hostRootFiber 進入beginWork,第一次判斷是否命中bailout策略,四個條件都滿足,命中,子樹有更新發生,低程度優化,複用子fiberNode(Example 對應的fiberNode)。
  2. Example 進入beginWork,第一次判斷,四個條件都滿足,命中,子樹有更新發生,低程度優化,複用子fiberNode([Button, Child])。
  3. Button 進入beginWork,第一次判斷,有更新發生,條件4不滿足,未命中,未使用效能優化API,狀態發生變化,未命中,走reconcile流程。
  4. button 進入beginWork,第一次判斷,newProps!==oldProps,未命中,走reconcile
  5. button 進入completeWork
  6. Button 進入completeWork
  7. Child 進入 beginWork,第一次判斷,四個條件都滿足,子樹沒有更新,高程度優化,整個子樹跳過beginWork階段。
  8. Child 進入completeWork

通過分析我們知道,只有Button元件內部走了完整了reconcile流程,其他階段都命中了bailout,Child 元件甚至是高程度優化,顯著提升了效能。

再來看一下開發者工具

image.png

可以看到只有 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 (

  • {/ 一個長列表 /}
); }); Child.displayName = 'Child'

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 (

{num}
); }

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 (

{num} {children}
) }

const Child = () => { return (

  • {/ 一個長列表 /}
); };

const root = ReactDOM.createRoot(document.getElementById('root')); root.render(); ``` 實際開發總是複雜的,demo總歸是過於理想化,對於確實不好優化的結構可以選擇在效能較差的子樹的根節點使用效能優化API,然後在子樹內部仍然優先選擇優化元件結構的方式。

2. 不要定義不必要的state

  1. 可以通過已有 state 計算出的狀態,就不要再重新定義一個新的 state
  2. 某些不需要引起元件更新的狀態,考慮使用 ref 來替代

3. 避免在元件內定義元件

會造成fiberNode.type發生改變,命中不了bailout策略,這種程式碼完全可以把 Child 放在函式外面定義,或者使用useMemo快取。

jsx function App(props) { const Child = () => <h1>Child</h1> return ( <> <Child /> ); }

4. 真的需要響應式API嗎

reduxmobx或者其周邊庫給我們提供了一些響應式的API,比如mobx的autorun、observer,redux的useSelector、connect。 我在開發中見過一些元件在並不真的需要的情況下仍然使用這些API,元件體量不大的時候可以不管,但是當子樹的效能開銷比較大的時候可能就要注意了。

5. 真的不需要狀態管理庫嗎

有些人認為狀態管理庫並不是很有必要,因為 react 的useStateuseReducerContext就已經是一套很好用的狀態管理方案了,但其實 Context 效能並不是很好,而大多數的狀態管理庫實現的API,效能都優於 Context。

6. 學會使用 react 開發者工具

開發者工具能很直觀的讓我們看到元件的渲染資訊。尤其是Profiler,官方教程傳送門