UseMemo依賴沒變,回調還會反復執行?
大家好,我卡頌。
經常使用React的同學都知道,有些hook被設計為:「依賴項數組 + 回調」的形式,比如:
- useEffect
- useMemo
通常來說,當「依賴項數組」中某些值變化后,回調會重新執行。
我們知道,React的寫法十分靈活,那么有沒有可能,在「依賴項數組」不變的情況下,回調依然重新執行?
本文就來探討一個這樣的場景。
描述下Demo
在這個示例中,存在兩個文件:
- App.tsx
- Lazy.tsx
在App.tsx中,會通過React.lazy的形式懶加載Lazy.tsx導出的組件:
// App.tsx
import { Suspense, lazy } from "react";
const LazyCpn = lazy(() => import("./Lazy"));
function App() {
return (
<Suspense fallback={<div>外層加載...</div>}>
<LazyCpn />
</Suspense>
);
}
export default App;
Lazy.tsx導出的LazyComponent大體代碼如下:
// Lazy.tsx
function LazyComponent() {
const ChildComponent = useMemo(() => {
// ...省略邏輯
}, []);
return ChildComponent;
}
export default LazyComponent;
可以發現,LazyComponent組件的子組件是useMemo的返回值,而這個useMemo的依賴項是[](沒有依賴項),理論上來說useMemo的回調只會執行一次。
再來看看useMemo回調中的詳細代碼:
const ChildComponent = useMemo(() => {
const LazyCpn = lazy(
() => Promise.resolve({ default: () => <div>子組件</div>})
)
return (
<Suspense fallback={<div>內層加載...</div>}>
<LazyCpn />
</Suspense>
);
}, []);
簡單來說,useMemo會返回一個「被Suspense包裹的懶加載組件」。
是不是看起來比較繞,沒關系,我們看看整個Demo的結構圖:
- 整個應用有兩層Suspense,兩層React.lazy。
- 第二層Suspense是useMemeo回調的返回值。
這里是在線Demo地址[1]
應用渲染的結果如下:
現在問題來了,如果我們在useMemo回調中打印個log,記錄下執行情況,那么log會打印多少次?
const ChildComponent = useMemo(() => {
console.log("useMemo回調執行啦")
// ...省略代碼
}, []);
再次重申,這個useMemo的依賴項是不會變的
在我的電腦中,log大概會打印4000~6000次,也就是說,useMemo回調會執行4000~6000次,即使依賴不變。
why?
原理分析
首先,我們要明確一點:「hook依賴項變化,回調重新執行」是針對不同更新來說的。
而我們的Demo中useMemo回調雖然會執行幾千次,但他們都是同一次更新中執行的。
如果你對這一點有疑問,可以在LazyComponent(也就是Demo中的第一層React.lazy)中增加2個log:
- 一個在useEffect回調中。
- 一個在LazyComponent render函數中。
function LazyComponent() {
console.log("LazyComponent render")
useEffect(() => {
console.log("LazyComponent mount");
}, []);
const ChildComponent = useMemo(() => {
// ...省略邏輯
}, []);
return ChildComponent;
}
會發現:
- LazyComponent render執行次數和useMemo回調執行啦一致(都是幾千次)
- LazyComponent mount只會執行一次
也就是說,LazyComponent組件會render幾千次,但只會首屏渲染一次。
而「hook依賴項變化,回調重新執行」這條規則,只適用于不同更新之間(比如「首屏渲染」和「再次更新」之間),不適用于同一次更新的不同render之間(比如Demo中是首屏渲染的幾千次render)。
搞明白上面這些,我們還得解答一個問題:為啥首屏渲染LazyComponent組件會render幾千次?
unwind機制
在正常情況下,一次更新,同一個組件只會render一次。但還有兩種情況,一次更新同一個組件可能render多次:
情況1 并發更新
在并發更新下,存在「低優先級更新進行到中途,被高優先級更新打斷」的情況,這種情況下,同一個組件可能經歷2次更新:
- 低優先級更新(被打斷)
- 高優先級更新(沒打斷)
在Demo中render幾千次,顯然不屬于這種情況。
情況2 unwind情況
在React中,有一類組件,在render時是不能確定渲染內容的,比如:
- Error Boundray
- Suspense
對于Error Boundray,在render進行到Error Boundray時,React不知道是否應該渲染「報錯對應的UI」,只有繼續遍歷Error Boundray的子孫組件,遇到了報錯,才知道最近的Error Boundray需要渲染成「報錯對應的UI」。
比如,對于下述組件結構:
<ErrorBoundary>
<A>
<B/>
</A>
</ErrorBoundary>
更新進行到ErrorBoundary時,是不知道是否應該渲染「報錯對應的UI」,只有繼續遍歷A、B,報錯以后,才知道ErrorBoundary需要渲染成「報錯對應的UI」。
同理,對于下述組件結構:
<Suspense fallback={<div>加載...</div>}>
<A>
<B/>
</A>
</Suspense>
更新進行到Suspense時,是不知道是否應該渲染「fallback對應的UI」,只有繼續遍歷A、B,發生掛起后,才知道Suspense需要渲染成「fallback對應的UI」。
對于上述兩種情況,React中存在一種「在同一個更新中的回溯,重試機制」,被稱為unwind流程。
在Demo中,就是遭遇了上千次的unwind。
那unwind流程是如何進行的呢?以下述代碼為例:
<ErrorBoundary>
<A>
<B/>
</A>
</ErrorBoundary>
正常更新流程是:
假設B render時拋出錯誤,則會從B往上回到最近的ErrorBoundary:
再重新往下更新:
其中,「從B回到ErrorBoundary」(途中紅色路徑)就是unwind流程。
Demo情況詳解
在Demo中完整的更新流程如下:
首先,首屏渲染遇到第一個React.lazy,開始請求Lazy.tsx的代碼:
更新無法繼續下去(Lazy.tsx代碼還沒請求回),進入unwind流程,回到Suspense:
Suspense再重新往下更新,進入fallback(即<div>外層加載...</div>)的渲染流程:
所以頁面首屏渲染會顯示<div>外層加載...</div>。
當React.lazy請求回Lazy.tsx代碼后,開啟新的更新流程:
當再次遇到React.lazy(請求<div>子組件</div>代碼),又會進入unwind流程。
但是內層的React.lazy與外層的React.lazy是不一樣的,外層的React.lazy是在模塊中定義的:
// App.tsx
const LazyCpn = lazy(() => import("./Lazy"));
內層的React.lazy是在useMemo回調中定義的:
const ChildComponent = useMemo(() => {
const LazyCpn = lazy(
() => Promise.resolve({ default: () => <div>子組件</div>})
)
return (
<Suspense fallback={<div>內層加載...</div>}>
<LazyCpn />
</Suspense>
);
}, []);
前者的引用是穩定的,而后者每次執行useMemo回調都會生成新的引用。
這意味著當unwind進入Suspense,重新往下更新,更新進入到LazyComponent后,useMemo回調執行,創建新的React.lazy,又會進入unwind流程:
在同一個更新中,上圖藍色、紅色流程會循環出現上千次,直到命中邊界情況停止循環。
相對應的,useMemo即使依賴不變,也會在一次更新中執行上千次。
總結
「hook依賴項變化,回調重新執行」是針對不同更新來說的。
在某些會觸發unwind的場景(比如Suspense、Error Boundary)下,一次更新會重復執行很多次。
在這種情況下,即使hook依賴沒變,回調也會重新執行。因為,這是同一次更新的反復執行,而不是執行了不同更新。
參考資料
[1]在線Demo地址:https://codesandbox.io/s/unruffled-nightingale-thzv7z?file=/src/ImportComponent.js。