UseMemo依賴沒變,回調(diào)還會(huì)反復(fù)執(zhí)行?

大家好,我卡頌。
經(jīng)常使用React的同學(xué)都知道,有些hook被設(shè)計(jì)為:「依賴項(xiàng)數(shù)組 + 回調(diào)」的形式,比如:
- useEffect
- useMemo
通常來說,當(dāng)「依賴項(xiàng)數(shù)組」中某些值變化后,回調(diào)會(huì)重新執(zhí)行。
我們知道,React的寫法十分靈活,那么有沒有可能,在「依賴項(xiàng)數(shù)組」不變的情況下,回調(diào)依然重新執(zhí)行?
本文就來探討一個(gè)這樣的場(chǎng)景。
描述下Demo
在這個(gè)示例中,存在兩個(gè)文件:
- App.tsx
- Lazy.tsx
在App.tsx中,會(huì)通過React.lazy的形式懶加載Lazy.tsx導(dǎo)出的組件:
// 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導(dǎo)出的LazyComponent大體代碼如下:
// Lazy.tsx
function LazyComponent() {
const ChildComponent = useMemo(() => {
// ...省略邏輯
}, []);
return ChildComponent;
}
export default LazyComponent;可以發(fā)現(xiàn),LazyComponent組件的子組件是useMemo的返回值,而這個(gè)useMemo的依賴項(xiàng)是[](沒有依賴項(xiàng)),理論上來說useMemo的回調(diào)只會(huì)執(zhí)行一次。
再來看看useMemo回調(diào)中的詳細(xì)代碼:
const ChildComponent = useMemo(() => {
const LazyCpn = lazy(
() => Promise.resolve({ default: () => <div>子組件</div>})
)
return (
<Suspense fallback={<div>內(nèi)層加載...</div>}>
<LazyCpn />
</Suspense>
);
}, []);簡單來說,useMemo會(huì)返回一個(gè)「被Suspense包裹的懶加載組件」。
是不是看起來比較繞,沒關(guān)系,我們看看整個(gè)Demo的結(jié)構(gòu)圖:
- 整個(gè)應(yīng)用有兩層Suspense,兩層React.lazy。
- 第二層Suspense是useMemeo回調(diào)的返回值。

這里是在線Demo地址[1]
應(yīng)用渲染的結(jié)果如下:

現(xiàn)在問題來了,如果我們?cè)趗seMemo回調(diào)中打印個(gè)log,記錄下執(zhí)行情況,那么log會(huì)打印多少次?
const ChildComponent = useMemo(() => {
console.log("useMemo回調(diào)執(zhí)行啦")
// ...省略代碼
}, []);再次重申,這個(gè)useMemo的依賴項(xiàng)是不會(huì)變的
在我的電腦中,log大概會(huì)打印4000~6000次,也就是說,useMemo回調(diào)會(huì)執(zhí)行4000~6000次,即使依賴不變。

why?
原理分析
首先,我們要明確一點(diǎn):「hook依賴項(xiàng)變化,回調(diào)重新執(zhí)行」是針對(duì)不同更新來說的。
而我們的Demo中useMemo回調(diào)雖然會(huì)執(zhí)行幾千次,但他們都是同一次更新中執(zhí)行的。
如果你對(duì)這一點(diǎn)有疑問,可以在LazyComponent(也就是Demo中的第一層React.lazy)中增加2個(gè)log:
- 一個(gè)在useEffect回調(diào)中。
- 一個(gè)在LazyComponent render函數(shù)中。
function LazyComponent() {
console.log("LazyComponent render")
useEffect(() => {
console.log("LazyComponent mount");
}, []);
const ChildComponent = useMemo(() => {
// ...省略邏輯
}, []);
return ChildComponent;
}會(huì)發(fā)現(xiàn):
- LazyComponent render執(zhí)行次數(shù)和useMemo回調(diào)執(zhí)行啦一致(都是幾千次)
- LazyComponent mount只會(huì)執(zhí)行一次
也就是說,LazyComponent組件會(huì)render幾千次,但只會(huì)首屏渲染一次。
而「hook依賴項(xiàng)變化,回調(diào)重新執(zhí)行」這條規(guī)則,只適用于不同更新之間(比如「首屏渲染」和「再次更新」之間),不適用于同一次更新的不同render之間(比如Demo中是首屏渲染的幾千次render)。
搞明白上面這些,我們還得解答一個(gè)問題:為啥首屏渲染LazyComponent組件會(huì)render幾千次?
unwind機(jī)制
在正常情況下,一次更新,同一個(gè)組件只會(huì)render一次。但還有兩種情況,一次更新同一個(gè)組件可能render多次:
情況1 并發(fā)更新
在并發(fā)更新下,存在「低優(yōu)先級(jí)更新進(jìn)行到中途,被高優(yōu)先級(jí)更新打斷」的情況,這種情況下,同一個(gè)組件可能經(jīng)歷2次更新:
- 低優(yōu)先級(jí)更新(被打斷)
- 高優(yōu)先級(jí)更新(沒打斷)
在Demo中render幾千次,顯然不屬于這種情況。
情況2 unwind情況
在React中,有一類組件,在render時(shí)是不能確定渲染內(nèi)容的,比如:
- Error Boundray
- Suspense
對(duì)于Error Boundray,在render進(jìn)行到Error Boundray時(shí),React不知道是否應(yīng)該渲染「報(bào)錯(cuò)對(duì)應(yīng)的UI」,只有繼續(xù)遍歷Error Boundray的子孫組件,遇到了報(bào)錯(cuò),才知道最近的Error Boundray需要渲染成「報(bào)錯(cuò)對(duì)應(yīng)的UI」。
比如,對(duì)于下述組件結(jié)構(gòu):
<ErrorBoundary>
<A>
<B/>
</A>
</ErrorBoundary>更新進(jìn)行到ErrorBoundary時(shí),是不知道是否應(yīng)該渲染「報(bào)錯(cuò)對(duì)應(yīng)的UI」,只有繼續(xù)遍歷A、B,報(bào)錯(cuò)以后,才知道ErrorBoundary需要渲染成「報(bào)錯(cuò)對(duì)應(yīng)的UI」。
同理,對(duì)于下述組件結(jié)構(gòu):
<Suspense fallback={<div>加載...</div>}>
<A>
<B/>
</A>
</Suspense>更新進(jìn)行到Suspense時(shí),是不知道是否應(yīng)該渲染「fallback對(duì)應(yīng)的UI」,只有繼續(xù)遍歷A、B,發(fā)生掛起后,才知道Suspense需要渲染成「fallback對(duì)應(yīng)的UI」。
對(duì)于上述兩種情況,React中存在一種「在同一個(gè)更新中的回溯,重試機(jī)制」,被稱為unwind流程。
在Demo中,就是遭遇了上千次的unwind。
那unwind流程是如何進(jìn)行的呢?以下述代碼為例:
<ErrorBoundary>
<A>
<B/>
</A>
</ErrorBoundary>正常更新流程是:

假設(shè)B render時(shí)拋出錯(cuò)誤,則會(huì)從B往上回到最近的ErrorBoundary:

再重新往下更新:

其中,「從B回到ErrorBoundary」(途中紅色路徑)就是unwind流程。
Demo情況詳解
在Demo中完整的更新流程如下:
首先,首屏渲染遇到第一個(gè)React.lazy,開始請(qǐng)求Lazy.tsx的代碼:

更新無法繼續(xù)下去(Lazy.tsx代碼還沒請(qǐng)求回),進(jìn)入unwind流程,回到Suspense:

Suspense再重新往下更新,進(jìn)入fallback(即<div>外層加載...</div>)的渲染流程:

所以頁面首屏渲染會(huì)顯示<div>外層加載...</div>。
當(dāng)React.lazy請(qǐng)求回Lazy.tsx代碼后,開啟新的更新流程:

當(dāng)再次遇到React.lazy(請(qǐng)求<div>子組件</div>代碼),又會(huì)進(jìn)入unwind流程。
但是內(nèi)層的React.lazy與外層的React.lazy是不一樣的,外層的React.lazy是在模塊中定義的:
// App.tsx
const LazyCpn = lazy(() => import("./Lazy"));內(nèi)層的React.lazy是在useMemo回調(diào)中定義的:
const ChildComponent = useMemo(() => {
const LazyCpn = lazy(
() => Promise.resolve({ default: () => <div>子組件</div>})
)
return (
<Suspense fallback={<div>內(nèi)層加載...</div>}>
<LazyCpn />
</Suspense>
);
}, []);前者的引用是穩(wěn)定的,而后者每次執(zhí)行useMemo回調(diào)都會(huì)生成新的引用。
這意味著當(dāng)unwind進(jìn)入Suspense,重新往下更新,更新進(jìn)入到LazyComponent后,useMemo回調(diào)執(zhí)行,創(chuàng)建新的React.lazy,又會(huì)進(jìn)入unwind流程:

在同一個(gè)更新中,上圖藍(lán)色、紅色流程會(huì)循環(huán)出現(xiàn)上千次,直到命中邊界情況停止循環(huán)。
相對(duì)應(yīng)的,useMemo即使依賴不變,也會(huì)在一次更新中執(zhí)行上千次。
總結(jié)
「hook依賴項(xiàng)變化,回調(diào)重新執(zhí)行」是針對(duì)不同更新來說的。
在某些會(huì)觸發(fā)unwind的場(chǎng)景(比如Suspense、Error Boundary)下,一次更新會(huì)重復(fù)執(zhí)行很多次。
在這種情況下,即使hook依賴沒變,回調(diào)也會(huì)重新執(zhí)行。因?yàn)椋@是同一次更新的反復(fù)執(zhí)行,而不是執(zhí)行了不同更新。
參考資料
[1]在線Demo地址:https://codesandbox.io/s/unruffled-nightingale-thzv7z?file=/src/ImportComponent.js。























