banner
NEWS LETTER

从一次页面卡死说起:搞懂 React 的 useMemo 与引用陷阱

Scroll down

最近在做项目的时候,我遇到了一个极其让人抓狂的 Bug:页面只要稍微操作一下无关的状态,网络请求就会疯狂发送,直接把浏览器卡死。

在排查这个 Bug 的过程中,我重新审视了 React 中 useEffect 的依赖机制,以及被无数人奉为性能优化神器的 useMemo。今天就来复盘一下,希望能帮大家避开这个“初学者必踩”的大坑。

一、现场代码:问题其实很隐蔽

业务很简单:父组件维护一个计数器,子组件根据查询条件发请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useEffect, useState } from 'react';

function DataList({ queryConfig }) {
useEffect(() => {
console.log('请求触发', queryConfig);
// 这里假设会发起请求
}, [queryConfig]);

return <div>商品列表</div>;
}

export default function App() {
const [count, setCount] = useState(0);

const config = { status: 'active', keyword: 'react' };

return (
<div>
<button onClick={() => setCount(count + 1)}>
点击无关按钮:{count}
</button>
<DataList queryConfig={config} />
</div>
);
}

表面上看,config 的内容根本没变。可只要 count 一更新,DataList 里的 useEffect 就会重新执行。

这里先说严谨一点:这段代码本身会导致的是“effect 重跑”,不一定自动变成“无限请求”。

真正把页面拖到卡死的,往往是 effect 里还夹着别的副作用,比如:

  • 请求成功后又触发了某个 setState
  • 触发了上报、轮询或重试逻辑
  • 父子组件之间还有别的联动更新

这些东西叠在一起,才会放大成请求风暴。

可以把这个过程理解成下面这张图:

引用不同

点击按钮,count 更新

App 重新执行

重新创建 config

新引用传给 DataList

依赖比较

依赖已变化

effect 重跑,请求再次触发

二、根因不复杂:变的不是内容,是引用

React 函数组件每次重新渲染,函数体都会重新执行。

所以这行代码:

1
const config = { status: 'active', keyword: 'react' };

会在每次渲染时重新创建一个新对象。

虽然它看起来和上一次一模一样,但对 JavaScript 来说,这是一个新的引用。

useEffect 的依赖数组会逐项用 Object.is() 比较。对对象这种引用类型来说,比较的不是字段内容是否相同,而是“是不是同一个对象”。

于是 React 会得到这样的结论:

  • 上一次的 queryConfig 是对象 A
  • 这一次的 queryConfig 是对象 B
  • A !== B
  • 所以依赖变了,effect 重新执行

问题到这里,其实已经讲完了。它和“React 神秘机制”没关系,就是一次很典型的引用稳定性问题。

三、第一反应用 useMemo,不算错,但也别上头

很多人看到这里,第一反应就是:那我把对象 memo 一下不就解决了。

1
2
3
const config = useMemo(() => {
return { status: 'active', keyword: 'react' };
}, []);

这个写法能不能解决问题?能。

但它是不是这个场景下最好的写法?未必。

原因不在于 useMemo “性能特别差”,这话说重了。大多数场景里,useMemo 那点开销根本不是瓶颈。真正的问题是:你为一个本来可以写得更直接的静态值,引入了额外的心智负担。

你需要多维护一层 Hook;你需要保证依赖数组永远正确;你还把“这是个静态配置”这个事实藏了起来。

换句话说,在这个例子里,useMemo 不是错,而是绕。

四、这个场景更直接的解法:把静态常量移出组件

如果一个值根本不依赖组件内的 stateprops,那它就不该跟着组件一起重新创建。

最简单的写法其实是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useEffect, useState } from 'react';

const DEFAULT_CONFIG = { status: 'active', keyword: 'react' };

function DataList({ queryConfig }) {
useEffect(() => {
console.log('请求触发', queryConfig);
}, [queryConfig]);

return <div>商品列表</div>;
}

export default function App() {
const [count, setCount] = useState(0);

return (
<div>
<button onClick={() => setCount(count + 1)}>
点击无关按钮:{count}
</button>
<DataList queryConfig={DEFAULT_CONFIG} />
</div>
);
}

这样改的好处很直接:

  • 引用天然稳定
  • 不需要额外 Hook
  • 代码意图更清楚
  • 后续维护时不容易漏依赖

所以这里更准确的经验不是“别用 useMemo”,而是:能外置的静态值,优先外置。

改成组件外常量后,流程就会简单很多:

只创建一次

引用相同

组件外常量

DEFAULT_CONFIG

点击按钮,count 更新

App 重新执行

DataList 收到同一个引用

依赖比较

依赖未变化

effect 不重跑

五、那什么时候真的该用 useMemo?

真正适合 useMemo 的,通常是下面两类场景。

第一类:计算结果确实昂贵。

比如你要基于一个大数组做排序、过滤、分组,而且输入没变时你不想重复算。

1
2
3
4
5
const visibleList = useMemo(() => {
return list
.filter(item => item.status === status)
.sort((a, b) => b.score - a.score);
}, [list, status]);

第二类:你确实需要一个稳定引用,但这个值又不能移到组件外。

比如它依赖当前组件的 state/props,并且:

  • 要传给 React.memo 包裹的子组件
  • 要作为另一个 Hook 的依赖
  • 你已经确认频繁重建它会带来实际问题

这种情况下,useMemo 是合理的:

1
2
3
const config = useMemo(() => {
return { status, keyword };
}, [status, keyword]);

注意这里和前面的区别:这个 config 真的依赖动态值,所以它不能直接外置。此时 useMemo 才是“稳定引用”的正经方案。

六、顺手纠正一个常见误区

很多文章喜欢把 useMemo 讲成“性能优化神器”,也有一些文章喜欢反过来把它打成“负优化元凶”。这两种说法都太满。

更稳的理解是:

  • useMemo 是一个工具,不是默认选项
  • 它主要解决“重复计算”或“引用稳定性”问题
  • 用不用,取决于值的来源、使用位置和真实收益

如果一个值是静态常量,外置通常比 useMemo 更干净。
如果一个值依赖运行时数据,而且你确实需要稳定它,useMemo 就很合适。

七、总结成三条判断规则

  1. 如果值不依赖组件内状态,优先移到组件外。
  2. 如果值依赖运行时数据,但创建成本低、也不会影响子组件或 Hook,就别急着 memo
  3. 只有在“重复计算昂贵”或“稳定引用确实有收益”时,再用 useMemo

让我们回看这次问题,真正该记住的并不是“useMemo 好不好用”,而是另一句话:
先判断值应该存在于哪里,再决定要不要缓存它。
这比一上来就套 Hook,更接近工程上的最佳实践。

最后,如果你有什么好的想法或建议,欢迎在评论区留言,与我一起探讨。

其他文章
目录导航 置顶
  1. 1. 一、现场代码:问题其实很隐蔽
  2. 2. 二、根因不复杂:变的不是内容,是引用
  3. 3. 三、第一反应用 useMemo,不算错,但也别上头
  4. 4. 四、这个场景更直接的解法:把静态常量移出组件
  5. 5. 五、那什么时候真的该用 useMemo?
  6. 6. 六、顺手纠正一个常见误区
  7. 7. 七、总结成三条判断规则