经常使用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 地址
应用渲染的结果如下:
现在问题来了,如果在useMemo
回调中打印个log
,记录下执行情况,那么log
会打印多少次?
const ChildComponent = useMemo(() => {
console.log("useMemo回调执行啦");
// ...省略代码
}, []);
再次重申,这个
useMemo
的依赖项是不会变的
在电脑中,log
大概会打印 4000 ~ 6000 次,也就是说,useMemo
回调会执行 4000 ~ 6000 次,即使依赖不变。
原理分析
首先,要明确一点: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
依赖没变,回调也会重新执行。因为,这是同一次更新的反复执行,而不是执行了不同更新。