大家好,我卡頌。
當一個React
應用邏輯變得複雜後,元件render花費的時間會顯著增長。如果從元件render到檢視渲染期間消耗的時間過長,使用者就會感知到頁面卡頓。
為了解決這個問題,有兩個方法:
- 讓元件render的過程從同步變為非同步,這樣
render
過程頁面不會卡死。這就是併發更新的原理 - 減少需要
render
的元件數量,這就是常說的React
效能最佳化
通常,對於不同型別元件,我們會採取以上不同的方法。比如,對於下面這樣的有耗時邏輯的輸入框,方法1更合適(因為併發更新能減少輸入時的卡頓):
function ExpensiveInput({onChange, value}) {
// 耗時的操作
const cur = performance.now();
while (performance.now() - cur < 20) {}
return <input onChange={onChange} value={value}/>;
}
那麼,能不能在整個應用層面同時兼顧這2種方式呢?答案是 —— 不太行。
這是因為,對於複雜應用,併發更新與效能最佳化通常是相悖的。就是本文要聊的 —— 併發悖論。
歡迎加入人類高質量前端交流群,帶飛
從效能最佳化聊起
對於一個元件,如果希望他非必要時不render
,需要達到的基本條件是:props
的引用不變。
比如,下面程式碼中Child
元件依賴fn props
,由於fn
是內聯形式,所以每次App
元件render
時引用都會變,不利於Child
效能最佳化:
function App() {
return <Child fn={() => {/* xxx */}}/>
}
為了Child
效能最佳化,可以將fn
抽離出來:
const fn = () => {/* xxx */}
function App() {
return <Child fn={fn}/>
}
當fn
依賴某些props
或者state
時,我們需要使用useCallback
:
function App({a}) {
const fn = useCallback(() => a + 1, [a]);
return <Child fn={fn}/>
}
類似的,其他型別變數需要用到useMemo
。
也就是說,當涉及到效能最佳化時,React
的程式碼邏輯會變得複雜(需要考慮引用變化問題)。
當應用進一步複雜,會面臨更多問題,比如:
- 複雜的
useEffect
邏輯 - 狀態如何共享
這些問題會與效能最佳化問題互相疊加,最終導致應用不僅邏輯複雜,效能也欠佳。
效能最佳化的解決之道
好在,這些問題有個共同的解決方法 —— 狀態管理。
上文我們聊到,對於效能最佳化,關鍵的問題是 —— 保持props
引用不變。
在原生React
中,如果a
依賴b
,b
依賴c
。那麼,當a
變化後,我們需要透過各種方法(比如useCallback
、useMemo
)保持b
、c
引用的穩定。
做這件事情本身(保持引用不變)對開發者來說就是額外的心智負擔。那麼,狀態管理是如何解決這個問題的呢?
答案是:狀態管理庫自己管理所有原始狀態以及派生狀態。
比如:
- 在
Recoil
中,基礎狀態型別被稱為Atom
,其他派生狀態都是基於Atom
組合而來 - 在
Zustand
中,基礎狀態都是create
方法建立的例項 - 在
Redux
中,維護了一個全域性狀態,對於需要用到的狀態透過selector
從中摘出來
這些狀態管理方案都會自己維護所有的基礎狀態與派生狀態。當開發者從狀態管理庫中引入狀態時,就能最大限度保持props
引用不變。
比如,下例用Zustand
改造上面的程式碼。由於狀態a
和依賴a
的fn
都是由Zustand
管理,所以fn
的引用始終不變:
const useStore = create(set => ({
a: 0,
fn: () => set(state => ({ a: state.a + 1 })),
}))
function App() {
const fn = useStore(state => state.fn)
return <Child fn={fn}/>
}
併發更新的問題
現在我們知道,效能最佳化的通用解決途徑是 —— 透過狀態管理庫,維護一套邏輯自洽的外部狀態(這裡的外部是區別於React
自身的狀態),保持引用不變。
但是,這套外部狀態最終一定會轉化為React
的內部狀態(再透過內部狀態的變化驅動檢視更新),所以就存在狀態同步時機的問題。即:什麼時候將外部狀態與內部狀態同步?
在併發更新之前的React
中,這並不是個問題。因為更新是同步、不會被打斷的。所以對於同一個外部狀態,在整個更新過程中都能保持不變。
比如,在如下程式碼中,由於List
元件的render
過程不會打斷,所以list
在遍歷過程中是穩定的:
function List() {
const list = useStore(state => state.list)
return (
<ul>
{list.map(item => <Item key={item.id} data={item}/>}
</ul>
)
}
但是,對於開啟併發更新的React
,更新流程可能中斷,不同的Item
元件可能是在中斷前後不同的宏任務中render
,傳遞給他們的data props
可能並不相同。這就導致同一次更新,同一個狀態(例子中的list
)前後不一致的情況。
這種情況被稱為tearing
(檢視撕裂)。
可以發現,造成tearing
的原因是 —— 外部狀態(狀態管理庫維護的狀態)與React
內部狀態的同步時機出問題。
這個問題在當前React
中是很難解決的。退而求其次,為了讓這些狀態庫能夠正常使用,React
專門出了個hook
—— useSyncExternalStore
。用於將狀態管理庫觸發的更新都以同步的方式執行,這樣就不會有同步時機的問題。
既然是以同步的方式執行,那肯定沒法併發更新啦~~~
總結
實際上,凡是涉及到自己維護了一個外部狀態的庫(比如動畫庫),都涉及到狀態同步的問題,很有可能無法相容併發更新。
所以,你會更傾向下面哪種選擇呢:
- 不
care
併發更新,以前React
怎麼用,現在就怎麼用 - 根據專案情況,平衡併發更新與效能最佳化的訴求