大家好,我卡頌。
對於如下這個常見互動步驟:
- 點選按鈕,觸發
狀態更新
- 元件
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地址
首次渲染,列印:
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
策略並沒有達到最理想的狀態。