如果你做过 React 代码评审,大概率见过这种场面:一个组件里密密麻麻十几个 useMemo,连 !data.length 这种一步到位的布尔判断都要包一层。
问写的人为什么,答案通常是:"性能优化嘛,包一下更稳。"
但 useMemo 不是免费的保险——它自己也有成本。
一、useMemo 到底在干什么
一句话:在重新渲染之间,缓存上一次的计算结果,如果依赖没变就直接复用。
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
首次渲染时,React 执行 filterTodos(todos, tab) 并记住结果。后续每次渲染,React 用 Object.is 逐个比较 todos 和 tab 是否和上次一样——都没变就直接返回缓存值,跳过计算。
注意两个关键词: "缓存"和"比较" ——这两件事本身都不免费。
二、缓存也有成本——经济学的机会成本视角
经济学有一条铁律:每个决策都有机会成本。 选择缓存一个值,你同时付出了三项成本:
| 成本类型 | 具体表现 |
|---|---|
| 内存开销 | 缓存结果常驻内存,直到依赖变化 |
| 比较开销 | 每次渲染逐个 Object.is 比较依赖数组 |
| 认知负担 | 开发者要手动维护依赖数组,写错就是 bug |
所以判断要不要用 useMemo,本质是一道经济学的比较题:
计算成本 > 缓存成本? → 用。
计算成本 ≤ 缓存成本? → 别用,直接算更快。
一个 data.length > 0 是 O(1) 操作,耗时约 0.001ms。用 useMemo 包它?比较依赖数组的成本可能比计算本身还高。
不是所有值得记住的东西都值得缓存。
三、什么时候该用——三个合理场景
| 场景 | 示例 | 为什么要缓存 |
|---|---|---|
| 计算确实昂贵 | 大数组 .filter().sort().map() 链式操作 | 耗时 ≥ 1ms 的计算,跳过有实际收益 |
配合 memo 稳定子组件 props | useMemo 返回的数组/对象传给 memo(Child) | 保持引用稳定,否则 memo 白包 |
| 作为其他 Hook 的依赖 | 计算结果是 useEffect 依赖数组的一项 | 引用不稳定会导致 Effect 反复触发 |
第二个场景最容易被忽略。看这个例子:
import { memo, useMemo } from 'react';
const List = memo(function List({ items }) {
// items 引用不变 → 跳过重新渲染
return items.map(item => <li key={item.id}>{item.text}</li>);
});
function TodoApp({ todos, tab, theme }) {
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
return (
<div className={theme}>
<List items={visibleTodos} />
</div>
);
}
没有 useMemo 时,filterTodos 每次都返回一个新数组——引用变了,memo 包裹的 List 照样重新渲染。useMemo 在这里的作用不是"省计算",而是 "稳引用" 。
useMemo 的两个价值:省计算、稳引用。大部分人只知道前者。
四、什么时候不该用——反模式清单
❌ 包裹极轻量计算
// 这是在浪费 Hook 调用
const hasData = useMemo(() => data.length > 0, [data]);
// ✅ 直接写
const hasData = data.length > 0;
❌ 依赖引用不稳定
// options 每次渲染都是新对象 → useMemo 永远命中不了缓存
const options = { matchMode: 'whole-word', text };
const result = useMemo(() => search(items, options), [items, options]);
options 是内联对象,每次渲染都产生新引用。Object.is 一比就是 false,缓存形同虚设。
正确做法:把对象创建移到 useMemo 内部,依赖原始值:
// ✅ 直接依赖原始值 text,不依赖对象引用
const result = useMemo(() => {
const options = { matchMode: 'whole-word', text };
return search(items, options);
}, [items, text]);
❌ 用 useMemo 掩盖架构问题
如果一个组件因为父组件频繁 re-render 而不断执行昂贵计算,第一反应不应该是"加 useMemo",而是问:这个状态是不是放错位置了?
React 官方文档原话:
先把组件写纯、把状态放对位置、删掉不必要的 Effect,再考虑 useMemo。
这就像建筑——如果承重墙位置不对,刷再好的墙漆也救不了歪的结构。 useMemo 是装修材料,不是地基。
架构问题,不是一个 Hook 能补救的。
五、useMemo vs useCallback vs React.memo——三者关系
这三个经常被混淆,一张表理清楚:
| 工具 | 缓存的是什么 | 触发条件 | 典型用途 |
|---|---|---|---|
useMemo | 计算结果(任意值) | 依赖数组变化 | 缓存昂贵计算、稳定对象/数组引用 |
useCallback | 函数引用 | 依赖数组变化 | 稳定回调函数引用,配合 memo 使用 |
React.memo | 组件渲染结果 | props 变化(浅比较) | 跳过 props 未变的子组件重渲染 |
一个关键等式:
useCallback(fn, deps) === useMemo(() => fn, deps)
useCallback 就是 useMemo 的语法糖——专门用来缓存函数。如果你需要缓存一个值用 useMemo,缓存一个函数用 useCallback,防止子组件无效渲染用 React.memo。
它们三个经常要配合使用:memo 是盾,useMemo/useCallback 是让盾生效的稳定引用。
六、依赖数组——缓存失效的唯一裁判
useMemo 的缓存何时失效?完全取决于依赖数组。
| 情况 | 行为 |
|---|---|
所有依赖都没变(Object.is 通过) | 返回缓存值 |
| 任一依赖变了 | 重新执行计算函数 |
| 忘了传依赖数组 | 每次渲染都重新计算(白写了) |
传了空数组 [] | 永不失效,只计算一次(类似常量) |
最常见的 bug 是忘传依赖数组:
// ❌ 没有第二个参数 → 每次渲染都执行,等于没写 useMemo
const result = useMemo(() => expensiveCalculation(data));
// ✅ 记得传依赖
const result = useMemo(() => expensiveCalculation(data), [data]);
还有一个容易踩坑的:依赖数组里放了对象/函数。JavaScript 中 {} !== {},即使内容完全一样,引用不同就算"变了"。所以依赖数组尽量放原始值(string、number、boolean),不要放每次渲染都重新创建的对象。
七、缓存边界意识——跨越 useMemo 的思维模型
用户提出了一个很精准的观察:useMemo 训练的其实是缓存边界意识。
任何缓存策略的核心,都是同一道判断题:重新计算更便宜,还是存起来复用更便宜?
这和设计模式的思路完全一致:
| 模式 | 权衡 | 核心问题 |
|---|---|---|
| Builder | 重新组装 vs 克隆已有对象 | 构建成本高不高? |
| Prototype | 深拷贝 vs 从头 new | 创建成本高不高? |
| useMemo | 重新计算 vs 复用缓存 | 计算成本高不高? |
它们的底层逻辑是同一个:在"重建"和"复用"之间画一条线,线画在哪里,取决于你对成本的判断。
八、实战判断清单
最后给一份速查流程。下次纠结要不要加 useMemo 时,过一遍这个:
| 步骤 | 问题 | 答案 → 行动 |
|---|---|---|
| 1 | 组件结构合理吗?状态放对位置了吗? | 没有 → 先改结构 |
| 2 | 有不必要的 Effect 在更新 state 吗? | 有 → 先删 Effect |
| 3 | 这个计算耗时 ≥ 1ms 吗?(用 console.time 量) | 没到 → 不用 memo |
| 4 | 这个值是传给 memo 子组件的 prop 吗? | 是 → 用 useMemo 稳引用 |
| 5 | 这个值是其他 Hook 的依赖吗? | 是 → 用 useMemo 稳引用 |
| 6 | 依赖数组里有对象/函数吗? | 有 → 改成依赖原始值 |
如果你只想带走一句话,我建议记这个:
先改结构,再谈缓存——useMemo 是性能优化的最后一步,不是第一步。
参考原文:
• React 官方文档 — useMemo