REACT · Vol.II · LESSON 11 · Hooks 精讲
useMemo 与 useCallback:何时真正需要?
一切的前提:React 只认「引用相等」
第 10 篇结尾留了个尾巴:诚实补全依赖之后,新的麻烦来了——有些依赖「看起来没变,每次渲染却都变」。要理解这件事,得先补一块地基:React 判断「相等」的方式。
React 判断依赖变没变、props 变没变、state 变没变,用的都是严格相等(Object.is,可以粗略当成 ===):原始值(number、string、boolean)比「内容」;对象、数组、函数比「引用」——是不是同一个东西,而不是长得一样不一样。
Java 里 == 比引用、equals() 比内容,分工明确。JS 的 === 遇到对象时,行为更像 Java 的 ==:{ name: "Tom" } === { name: "Tom" } 是 false——两个不同的对象。更麻烦的是,JS 没有内置的「内容相等」:没有 equals,也没有 hashCode,React 也不会深比较你的对象(逐字段比较太贵)。它只认引用。
1 === 1; // true:原始值,比值
"abc" === "abc"; // true:原始值,比值
{ name: "Tom" } === { name: "Tom" }; // false:两个不同的对象!
[1, 2] === [1, 2]; // false:两个不同的数组!
const a = { name: "Tom" };
const b = a; // b 和 a 指向同一个对象
a === b; // true:同一个引用
把这条规则记牢,下一站你会亲眼看到:什么都不做,依赖也会天天变。
每次渲染,一切都「焕然一新」
第 10 篇说过:每次渲染都重新执行一遍组件函数。现在结合引用相等,可以推出一个必然结论——函数体里现场创建的一切,每次渲染都是全新的引用:
import { useEffect, useState } from "react";
function FilterList({ keyword }: { keyword: string }) {
const [items] = useState(["ant", "bee", "cat"]);
// 每次渲染,这都是一个新数组(新引用)
const filtered = items.filter(i => i.includes(keyword));
useEffect(() => {
console.log("effect 执行了");
}, [filtered]); // 😱 filtered 每次渲染都是新引用 → effect 每次渲染都跑
return <ul>{filtered.map(i => <li key={i}>{i}</li>)}</ul>;
}
诡异的事情发生了:keyword 明明没变、items 明明没变,effect 却每次渲染都执行。原因就藏在引用相等里——filtered 这个数组每次渲染都是现场 filter 出来的新对象,哪怕内容和上一次一模一样,引用也永远不同,React 只能判定「变了」。
对象、数组、函数三兄弟全中招:只要它们是在组件函数体里「现场创建」的,就逃不过「每次渲染都是新引用」的命运。原始值则完全没事——值相同,就是「没变」。
useMemo:缓存「值」,只在两种场合出场
useMemo(fn, deps) 的语义:deps 不变时,不重新执行 fn,直接返回上次缓存的结果。它缓存的是「计算结果」。真实需要它的场合只有两类。
场景一:计算真的很重
import { useMemo, useState } from "react";
type Row = { name: string; amount: number };
function Stats({ rows }: { rows: Row[] }) {
const [query, setQuery] = useState("");
const [compact, setCompact] = useState(false); // 无关的 UI 开关
const stats = useMemo(() => {
// 十万行:过滤 + 汇总,一次几十毫秒
const matched = rows.filter(r => r.name.includes(query));
return { total: matched.length, sum: matched.reduce((a, r) => a + r.amount, 0) };
}, [rows, query]); // 只有数据源变化才重算
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<label><input type="checkbox" checked={compact}
onChange={e => setCompact(e.target.checked)} />紧凑模式</label>
<p>共 {stats.total} 条,合计 {stats.sum}</p>
</>
);
}
不包 useMemo 会怎样?每敲一个字、每勾一次「紧凑模式」,组件重渲染,十万行的过滤汇总就重跑一次。包上之后,rows、query 不变,重算直接跳过。判断标准很朴素:没有 useMemo,这个计算每次渲染都会跑——它有多贵?渲染有多频繁?对几十条数据做一次 filter,微秒级,包了纯属浪费;对十万行做统计,才值得包。
「贵不贵」不靠猜,量一下就知道。最顺手的手法是 console.time:
console.time("stats");
const stats = computeStats(rows, query);
console.timeEnd("stats"); // 控制台打印:stats: 37.4ms
经验刻度:1ms 以内(多数小数据的 filter/map)放心裸算;几毫秒到十几毫秒,若伴随高频输入(每敲一个字就重算)就该包;几十毫秒以上,不包等于每次渲染卡一帧。量出来的数字比直觉可靠——后端「先 profile 再优化」的纪律,前端一字不差地适用(第 41 篇讲正式的 Profiler)。
场景二:结果不贵,但「引用稳定」很重要
两个子场景,都和第一站的引用相等有关:
- 作为其它 Hook 的依赖:像上一站那个
filtered,用 useMemo 包住,引用稳定,依赖它的 effect 不再每次渲染都跑; - 传给 memo 化的子组件:
React.memo包裹的子组件靠「浅比较 props」跳过重渲染,而对象/函数 props 每次都是新引用,浅比较永远失败——memo 等于白包。用 useMemo/useCallback 把 props 的引用稳住,memo 才能真正生效。
import { memo, useMemo, useState } from "react";
type User = { id: number; name: string };
// 子组件:memo 化,props 浅比较相同 → 跳过重渲染
const UserRow = memo(function UserRow({ user }: { user: User }) {
console.log("render:", user.name);
return <li>{user.name}</li>;
});
function UserList({ users, keyword }: { users: User[]; keyword: string }) {
const [tab, setTab] = useState("all");
// ✅ 过滤结果被缓存:users/keyword 没变时,filtered 引用不变
const filtered = useMemo(
() => users.filter(u => u.name.includes(keyword)),
[users, keyword]
);
return (
<>
<button onClick={() => setTab("fav")}>切 Tab</button>
<ul>{filtered.map(u => <UserRow key={u.id} user={u} />)}</ul>
</>
);
}
点「切 Tab」时 UserList 重渲染,但 users、keyword 没变 → filtered 引用不变 → 每个 UserRow 的 user prop 都「没变」→ memo 判定跳过,控制台不会再刷出一排 render 日志。注意这是一套组合拳:只包 memo 不稳引用、或只稳引用但子组件没 memo,都可能白忙。
useMemo(fn, deps) 活脱脱是一个「方法级本地缓存」:deps 是缓存键,fn 是回源计算。键没变 → 命中缓存直接返回;键变了 → 重新计算并回填。你也懂缓存的铁律:键选漏了(少声明真依赖)会读到脏数据,键选太敏感(多了无关依赖)则命中率惨淡。这里的键必须是「参与计算的全部输入」,一个都不能少——和第 10 篇 exhaustive-deps 的纪律完全同源。
useCallback:缓存「函数」的语法糖
函数也是值,也遵循引用相等。所以「把函数缓存住」和「把对象缓存住」是同一件事——useCallback(fn, deps) 就是 useMemo 的语法糖:
// 假设外层组件作用域里有 id 和 save:
// 下面两行完全等价(deps 相同的前提下):
const handler = useCallback(() => save(id), [id]);
const handler2 = useMemo(() => () => save(id), [id]);
// 规律:useCallback(fn, deps) ≈ useMemo(() => fn, deps)
// 「要缓存的东西恰好是函数」时,useCallback 只是少包一层
它最常见的用途,是稳住「传给 memo 子组件的回调」:
import { memo, useCallback, useState } from "react";
type Todo = { id: number; text: string; done: boolean };
const TodoItem = memo(function TodoItem({ todo, onToggle }: {
todo: Todo;
onToggle: (id: number) => void;
}) {
return <li onClick={() => onToggle(todo.id)}>{todo.text}</li>;
});
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
// 不包 useCallback:每次重渲染 onToggle 都是新函数
// → 所有 TodoItem 的 props 都「变了」→ memo 集体失效
const onToggle = useCallback((id: number) => {
setTodos(ts => ts.map(t => (t.id === id ? { ...t, done: !t.done } : t)));
}, []); // ✅ 用函数式更新、不读外部值,deps 才能是 []
return (
<ul>
{todos.map(t => (
<TodoItem key={t.id} todo={t} onToggle={onToggle} />
))}
</ul>
);
}
注意回调里用的是函数式更新 setTodos(ts => …)——第 4 篇的「连续 set 正确姿势」、第 10 篇的「修复板斧一」,这里是它第三次出场:正因为不直接读 todos,deps 才能老实写 [],函数引用真正稳定。函数式更新的隐藏价值:让依赖更少、缓存更稳。
引用稳定性的高发区还有几处:传给 Provider 的 value 对象(第 15 篇 useContext 会遇到)、自定义 Hook 返回的对象(第 13 篇)——套路一致:会变的声明进 deps,不变的用缓存稳住。
反模式:到处包 useMemo,反而更慢
看完前两站,很容易走火入魔:「缓存在后端总是好东西,那我给每个变量、每个函数都包上 useMemo/useCallback,性能起飞?」——恰恰相反。缓存本身是有成本的:
- 内存成本:每份缓存都要一直占着内存,直到 deps 变化才释放旧值;
- 簿记成本:React 每次渲染都要保存旧 deps、逐项比较、维护缓存链表——包得越多,这套簿记越重;
- 代码成本:满屏的 useMemo/useCallback 让依赖关系更难读,还常常掩盖真正的性能问题。
对一次微秒级的 filter:不包,渲染时直接算,便宜又干净;包上,React 要为它保存依赖、比较引用、维护缓存——簿记开销可能比计算本身还贵。这就是「过早优化」的 React 版,和后端同一个道理:没有 profile 数据,别加缓存(第 41 篇会讲怎么用 Profiler 测量)。
你不会给每个查询都套 Redis:查询本身 2ms,引入缓存后多一次网络往返、多一份一致性维护,可能变成 5ms。useMemo 同理——它是给「贵的计算」和「必须稳的引用」准备的工具,不是荣誉勋章,不是每个函数体都该穿的衣服。
还有一条容易被忽略的「免责条款」:useMemo 的缓存只是性能提示,不是正确性保证。官方明确允许 React 在必要时丢弃缓存、重新计算(比如为释放内存),语义上它也可能在组件渲染失败重试时重跑。所以:绝不能把「只执行一次」的语义托付给 useMemo——发请求、建连接这类副作用必须进 effect(第 9 篇的规矩);缓存里只放「重算一遍也无所谓」的纯计算结果。后端对照:这就像缓存穿透兜底——缓存没了,回源必须总能重新算出同样的东西。
顺带一个「免费的 useMemo」常识:把常量提到组件外面,比任何 Hook 都干净——
// ✅ 写在组件函数外面:无论渲染多少次,都是同一个引用
const FILTER_OPTIONS = ["all", "done", "todo"];
function FilterBar() {
return <select>{FILTER_OPTIONS.map(o => <option key={o}>{o}</option>)}</select>;
}
判断清单:到底要不要包?
| 遇到的情况 | 要不要包 |
|---|---|
| 计算很重(大列表过滤/排序/统计、复杂数值计算),且渲染频繁 | useMemo,先确认它确实在每次渲染重复执行 |
| 计算结果作为其它 Hook 的依赖,希望 effect 别乱跑 | useMemo 稳住引用 |
| 对象/数组/函数要传给 memo 子组件 | useMemo / useCallback 稳住引用,否则 memo 失效 |
| 函数作为其它 Hook 的依赖(effect 里要调用它) | useCallback,或干脆把函数挪进 effect 内部定义 |
| 普通的小计算、小对象、事件处理器 | 都不包,直接写 |
| 子组件几乎每次渲染 props 都在变 | 先别急着 memo——先查是不是「状态放得太低」或该拆组件(第 27 篇) |
- React 只认引用相等;组件体里每次新建的对象/数组/函数,引用必然不同。
- useMemo 缓存值,useCallback 缓存函数——后者就是
useMemo(() => fn, deps)的语法糖。 - 真正需要的场景只有两类:计算重、引用要稳(供依赖数组 / memo 子组件用)。
- 与
React.memo是组合拳,单独用常常白忙。 - 缓存只是性能提示:React 可丢弃缓存重算,「只执行一次」的语义永远托付给 effect,不托付给 useMemo。
- 缓存有成本:先用
console.time或 Profiler(第 41 篇)测量再优化,别无脑全包。
本章回顾
这一篇你补上了「引用相等」这块地基:React 的世界里没有 equals(),只有「是不是同一个引用」。由此看透了 useMemo / useCallback 的本质——不是性能装饰品,而是「引用稳定性」工具,只在「计算贵」和「引用必须稳」两种场合出场,并且和 React.memo 要配合使用。至此,第 9、10、11 篇把 effect 三件套讲完了:何时跑(第 9 篇)、读到哪一版的值(第 10 篇)、依赖怎么稳(本篇)。而 useRef 在第 10 篇的板斧三里已经露过一面——它可不只是「存最新值」这么简单。下一篇第 12 篇,我们就把这个「跨渲染的可变盒子」讲透:DOM 引用、定时器句柄、不触发重渲染的任意值,全是它的舞台。
Comments · 评论