React內部的效能優化沒有達到極致?

卡頌發表於2022-03-12

大家好,我卡頌。

對於如下這個常見互動步驟:

  1. 點選按鈕,觸發狀態更新
  2. 元件render
  3. 檢視渲染

你覺得哪些步驟有效能優化的空間呢?

答案是:1和2。

對於步驟1,如果狀態更新前後沒有變化,則可以略過剩下的步驟。這個優化策略被稱為eagerState

對於步驟2,如果元件的子孫節點沒有狀態變化,可以跳過子孫元件的render。這個優化策略被稱為bailout

看起來eagerState的邏輯很簡單,只需要比較狀態更新前後是否有變化

然而,實踐上卻很複雜。

本文通過了解eagerState的邏輯,回答一個問題:React的效能優化達到極致了麼?

歡迎加入人類高質量前端框架群,帶飛

一個奇怪的例子

考慮如下元件:

function App() {
  const [num, updateNum] = useState(0);
  console.log("App render", num);

  return (
    <div onClick={() => updateNum(1)}>
      <Child />
    </div>
  );
}

function Child() {
  console.log("child render");
  return <span>child</span>;
}
線上Demo地址

首次渲染,列印:

App render 0
child render 

第一次點選div,列印:

App render 1
child render 

第二次點選div,列印:

App render 1

第三、四......次點選div,不列印

第二次點選中,列印了App render 1,沒有列印child render。代表App的子孫元件沒有render,命中了bailout

第三次及之後的點選,什麼都不列印,代表沒有元件render,命中了eagerState

那麼問題來了,明明第一、二次點選都是執行updateNum(1),顯然狀態是沒有變化的,為什麼第二次沒有命中eagerState

eagerState的觸發條件

首先我們需要明白,為什麼叫eagerState(急迫的狀態)?

通常,什麼時候能獲取到最新狀態呢?元件render的時候。

當元件renderuseState執行並返回最新狀態

考慮如下程式碼:

const [num, updateNum] = useState(0);

useState執行後返回的num就是最新狀態

之所以useState執行時才能計算出最新狀態,是因為狀態是根據一到多個更新計算而來的。

比如,在如下點選事件中觸發3個更新:

const onClick = () => {
  updateNum(100);
  updateNum(num => num + 1);
  updateNum(num => num * 2);
}

元件rendernum最新狀態應該是多少呢?

  • 首先num變為100
  • 100 + 1 = 101
  • 101 * 2 = 202

所以,useState會返回202作為num的最新狀態

實際情況會更復雜,更新擁有自己的優先順序,所以在render前不能確定究竟是哪些更新會參與狀態的計算

所以,在這種情況下元件必須renderuseState必須執行才能知道num的最新狀態是多少。

那就沒法提前將num的最新狀態num的當前狀態比較,判斷狀態是否變化

eagerState的意義在於,在某種情況下,我們可以在元件render前就提前計算出最新狀態(這就是eagerState的由來)。

這種情況下元件不需要render就能比較狀態是否變化

那麼是什麼情況呢?

答案是:當前元件上不存在更新的時候。

當不存在更新時,本次更新就是元件的第一個更新。在只有一個更新的情況下是能確定最新狀態的。

所以,eagerState的前提是:

當前元件不存在更新,那麼首次觸發狀態更新時,就能立刻計算出最新狀態,進而與當前狀態比較。

如果兩者一致,則省去了後續render的過程。

這就是eagerState的邏輯。但遺憾的是,實際情況還要再複雜一丟丟。

先讓我們看一個看似不相干的例子。

必要的React原始碼知識

對於如下元件:

function App() {
  const [num, updateNum] = useState(0);
  window.updateNum = updateNum;

  return <div>{num}</div>;
}

在控制檯執行如下程式碼,可以改變檢視顯示的num麼?

window.updateNum(100)

答案是:可以。

因為App元件對應fiber(儲存元件相關資訊的節點)已經被作為預設的引數傳遞給window.updateNum了:

// updateNum的實現類似這樣
// 其中fiber就是App對應fiber
const updateNum = dispatchSetState.bind(null, fiber, queue);

所以updateNum執行時是能獲取App對應fiber的。

然而,一個元件實際有2個fiber,他們:

  • 一個儲存當前檢視對應的相關資訊,被稱為current fiber
  • 一個儲存接下來要變化的檢視對應的相關資訊,被稱為wip fiber

updateNum中被預設的是wip fiber

當元件觸發更新後,會在元件對應的2個fiber上都標記更新

當元件render時,useState會執行,計算出新的狀態,並把wip fiber上的更新標記清除。

當檢視完成渲染後,current fiberwip fiber會交換位置(也就是說本次更新的wip fiber會變為下次更新的current fiber)。

回到例子

剛才談到,eagerState的前提是:當前元件不存在更新

具體來講,是元件對應的current fiberwip fiber都不存在更新。

回到我們的例子:

第一次點選div,列印:

App render 1
child render 

current fiberwip fiber同時標記更新。

renderwip fiber更新標記清除。

此時current fiber還存在更新標記

完成渲染後,current fiberwip fiber會交換位置。

變成:wip fiber存在更新,current fiber不存在更新。

所以第二次點選div時,由於wip fiber存在更新,沒有命中eagerState,於是列印:

App render 1

renderwip fiber更新標記清除。

此時兩個fiber上都不存在更新標記。所以後續點選div都會觸發eagerState,元件不會render

總結

由於React內部各個部分間互相影響,導致React效能優化的結果有時讓開發者迷惑。

為什麼沒有聽到多少人抱怨呢?因為效能優化只會反映在指標上,不會影響互動邏輯。

通過本文我們發現,React效能優化並沒有做到極致,由於存在兩個fibereagerState策略並沒有達到最理想的狀態。

相關文章