奇怪,useMemo依赖没变,回调还会反复执行?

大家好,我卡颂。

经常使用React的同学都知道,有些hook被设计为:依赖项数组 + 回调的形式,比如:

  • useEffect

  • useMemo

通常来说,当依赖项数组中某些值变化后,回调会重新执行。

我们知道,React的写法十分灵活,那么有没有可能,在依赖项数组不变的情况下,回调依然重新执行?

本文就来探讨一个这样的场景。

欢迎围观朋友圈、加入人类高质量前端交流群,带飞

描述下Demo

在这个示例中,存在两个文件:

  • App.tsx

  • Lazy.tsx

App.tsx中,会通过React.lazy的形式懒加载Lazy.tsx导出的组件:

js 复制代码
// 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大体代码如下:

js 复制代码
// Lazy.tsx

function LazyComponent() {

  const ChildComponent = useMemo(() => {
   // ...省略逻辑
  }, []);

  return ChildComponent;
}

export default LazyComponent;

可以发现,LazyComponent组件的子组件是useMemo的返回值,而这个useMemo的依赖项是[](没有依赖项),理论上来说useMemo的回调只会执行一次。

再来看看useMemo回调中的详细代码:

js 复制代码
const ChildComponent = useMemo(() => {
  const LazyCpn = lazy(
    () => Promise.resolve({ default: () => <div>子组件</div>})
  )

  return (
    <Suspense fallback={<div>内层加载...</div>}>
      <LazyCpn />
    </Suspense>  
  );
}, []);

简单来说,useMemo会返回一个被Suspense包裹的懒加载组件

是不是看起来比较绕,没关系,我们看看整个Demo的结构图:

  • 整个应用有两层Suspense,两层React.lazy

  • 第二层SuspenseuseMemeo回调的返回值

这里是在线Demo地址

应用渲染的结果如下:

现在问题来了,如果我们在useMemo回调中打印个log,记录下执行情况,那么log会打印多少次?

js 复制代码
const ChildComponent = useMemo(() => {
  console.log("useMemo回调执行啦")
  // ...省略代码
}, []);

再次重申,这个useMemo的依赖项是不会变的

在我的电脑中,log大概会打印4000~6000次,也就是说,useMemo回调会执行4000~6000次,即使依赖不变。

why????????

原理分析

首先,我们要明确一点:hook依赖项变化,回调重新执行是针对不同更新来说的。

而我们的DemouseMemo回调虽然会执行几千次,但他们都是同一次更新中执行的。

如果你对这一点有疑问,可以在LazyComponent(也就是Demo中的第一层React.lazy)中增加2个log

  • 一个在useEffect回调中

  • 一个在LazyComponent render函数中

js 复制代码
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次更新:

  • 低优先级更新(被打断)

  • 高优先级更新(没打断)

Demorender几千次,显然不属于这种情况。

情况2 unwind情况

React中,有一类组件,在render时是不能确定渲染内容的,比如:

  • Error Boundray

  • Suspense

对于Error Boundray,在render进行到Error Boundray时,React不知道是否应该渲染报错对应的UI ,只有继续遍历Error Boundray的子孙组件,遇到了报错,才知道最近的Error Boundray需要渲染成报错对应的UI

比如,对于下述组件结构:

html 复制代码
<ErrorBoundary>
  <A>
    <B/>
  </A>
</ErrorBoundary>

更新进行到ErrorBoundary时,是不知道是否应该渲染报错对应的UI ,只有继续遍历AB,报错以后,才知道ErrorBoundary需要渲染成报错对应的UI

同理,对于下述组件结构:

html 复制代码
<Suspense fallback={<div>加载...</div>}>
  <A>
    <B/>
  </A>
</Suspense>

更新进行到Suspense时,是不知道是否应该渲染fallback对应的UI ,只有继续遍历AB,发生挂起后,才知道Suspense需要渲染成fallback对应的UI

对于上述两种情况,React中存在一种在同一个更新中的回溯,重试机制 ,被称为unwind流程。

Demo中,就是遭遇了上千次的unwind

unwind流程是如何进行的呢?以下述代码为例:

html 复制代码
<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是在模块中定义的:

js 复制代码
// App.tsx
const LazyCpn = lazy(() => import("./Lazy"));

内层的React.lazy是在useMemo回调中定义的:

js 复制代码
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的场景(比如SuspenseError Boundary)下,一次更新会重复执行很多次。

在这种情况下,即使hook依赖没变,回调也会重新执行。因为,这是同一次更新的反复执行,而不是执行了不同更新。

相关推荐
无我Code1 天前
前端-2025年末个人总结
前端·年终总结
文刀竹肃1 天前
DVWA -SQL Injection-通关教程-完结
前端·数据库·sql·安全·网络安全·oracle
LYFlied1 天前
【每日算法】LeetCode 84. 柱状图中最大的矩形
前端·算法·leetcode·面试·职场和发展
Bigger1 天前
Tauri(21)——窗口缩放后的”失焦惊魂”,游戏控制权丢失了
前端·macos·app
Bigger1 天前
Tauri (20)——为什么 NSPanel 窗口不能用官方 API 全屏?
前端·macos·app
bug总结1 天前
前端开发中为什么要使用 URL().origin 提取接口根地址
开发语言·前端·javascript·vue.js·html
zwjapple1 天前
全栈开发面试高频算法题
算法·面试·职场和发展
程序员爱钓鱼1 天前
Node.js 编程实战:Redis缓存与消息队列实践
后端·面试·node.js
一招定胜负1 天前
网络爬虫(第三部)
前端·javascript·爬虫
San301 天前
现代前端工程化实战:从 Vite 到 React Router demo的构建之旅
react.js·前端框架·vite