聊聊React內部的性能優化沒有達到極致?
大家好,我卡頌。
對于如下這個常見交互步驟:
- 點擊按鈕,觸發狀態更新。
- 組件render。
- 視圖渲染。
你覺得哪些步驟有「性能優化的空間」呢?
答案是: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地址[1]。
首次渲染,打印:
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的時候。
當組件render,useState執行并返回最新狀態。
考慮如下代碼:
const [num, updateNum] = useState(0);
useState執行后返回的num就是最新狀態。
之所以useState執行時才能計算出最新狀態,是因為狀態是根據「一到多個更新」計算而來的。
比如,在如下點擊事件中觸發3個更新:
const onClick = () => {
updateNum(100);
updateNum(num => num + 1);
updateNum(num => num * 2);
}
組件render時num的最新狀態應該是多少呢?
- 首先num變為100。
- 100 + 1 = 101。
- 101 * 2 = 202。
所以,useState會返回202作為num的最新狀態。
實際情況會更復雜,更新擁有自己的優先級,所以在render前不能確定「究竟是哪些更新會參與狀態的計算」。
所以,在這種情況下組件必須render,useState必須執行才能知道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 fiber與wip fiber會交換位置(也就是說本次更新的wip fiber會變為下次更新的current fiber)。
回到例子
剛才談到,eagerState的前提是:「當前組件不存在更新」。
具體來講,是組件對應的current fiber與wip fiber都不存在更新。
回到我們的例子:
第一次點擊div,打印:
App render 1
child render
current fiber與wip fiber同時標記更新。
render后wip fiber的「更新標記」清除。
此時current fiber還存在「更新標記」。
完成渲染后,current fiber與wip fiber會交換位置。
變成:wip fiber存在更新,current fiber不存在更新。
所以第二次點擊div時,由于wip fiber存在更新,沒有命中eagerState,于是打印:
App render 1
render后wip fiber的「更新標記」清除。
此時兩個fiber上都不存在「更新標記」。所以后續點擊div都會觸發eagerState,組件不會render。
總結
由于React內部各個部分間互相影響,導致React性能優化的結果有時讓開發者迷惑。
為什么沒有聽到多少人抱怨呢?因為性能優化只會反映在指標上,不會影響交互邏輯。
通過本文我們發現,React性能優化并沒有做到極致,由于存在兩個fiber,eagerState策略并沒有達到最理想的狀態。
參考資料
[1]在線Demo地址:
https://codesandbox.io/s/frosty-cerf-mg64o5?file=/src/App.js:188-200。