首页 / React 学习笔记 / 11

REACT · Vol.II · LESSON 11 · Hooks 精讲

useMemo 与 useCallback:何时真正需要?

进阶#Hooks#性能

一切的前提:React 只认「引用相等」

第 10 篇结尾留了个尾巴:诚实补全依赖之后,新的麻烦来了——有些依赖「看起来没变,每次渲染却都变」。要理解这件事,得先补一块地基:React 判断「相等」的方式

React 判断依赖变没变、props 变没变、state 变没变,用的都是严格相等Object.is,可以粗略当成 ===):原始值(number、string、boolean)比「内容」;对象、数组、函数比「引用」——是不是同一个东西,而不是长得一样不一样。

后端类比:== 与 equals

Java 里 == 比引用、equals() 比内容,分工明确。JS 的 === 遇到对象时,行为更像 Java 的 =={ name: "Tom" } === { name: "Tom" }false——两个不同的对象。更麻烦的是,JS 没有内置的「内容相等」:没有 equals,也没有 hashCode,React 也不会深比较你的对象(逐字段比较太贵)。它只认引用。

equality.ts · 原始值比内容,对象比引用
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 篇说过:每次渲染都重新执行一遍组件函数。现在结合引用相等,可以推出一个必然结论——函数体里现场创建的一切,每次渲染都是全新的引用

FilterList.tsx · 看似平静,实则每次都「换新」
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 只能判定「变了」。

对象、数组、函数三兄弟全中招:只要它们是在组件函数体里「现场创建」的,就逃不过「每次渲染都是新引用」的命运。原始值则完全没事——值相同,就是「没变」。

这就是第 10 篇「诚实补全依赖」策略的边界:对原始值,补全又诚实又高效;对引用类型的依赖,诚实补全会让 effect 高频重建。出路只有两条:要么别把「每次新建」的东西当依赖,要么让它的引用稳定下来——后者正是本篇主角 useMemo/useCallback 的本职工作。

useMemo:缓存「值」,只在两种场合出场

useMemo(fn, deps) 的语义:deps 不变时,不重新执行 fn,直接返回上次缓存的结果。它缓存的是「计算结果」。真实需要它的场合只有两类。

场景一:计算真的很重

Stats.tsx · 十万行数据的过滤与汇总
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 会怎样?每敲一个字、每勾一次「紧凑模式」,组件重渲染,十万行的过滤汇总就重跑一次。包上之后,rowsquery 不变,重算直接跳过。判断标准很朴素:没有 useMemo,这个计算每次渲染都会跑——它有多贵?渲染有多频繁?对几十条数据做一次 filter,微秒级,包了纯属浪费;对十万行做统计,才值得包。

「贵不贵」不靠猜,量一下就知道。最顺手的手法是 console.time

Stats.tsx · 测量一次计算的真实耗时
console.time("stats");
const stats = computeStats(rows, query);
console.timeEnd("stats");  // 控制台打印:stats: 37.4ms

经验刻度:1ms 以内(多数小数据的 filter/map)放心裸算;几毫秒到十几毫秒,若伴随高频输入(每敲一个字就重算)就该包;几十毫秒以上,不包等于每次渲染卡一帧。量出来的数字比直觉可靠——后端「先 profile 再优化」的纪律,前端一字不差地适用(第 41 篇讲正式的 Profiler)。

场景二:结果不贵,但「引用稳定」很重要

两个子场景,都和第一站的引用相等有关:

  1. 作为其它 Hook 的依赖:像上一站那个 filtered,用 useMemo 包住,引用稳定,依赖它的 effect 不再每次渲染都跑;
  2. 传给 memo 化的子组件React.memo 包裹的子组件靠「浅比较 props」跳过重渲染,而对象/函数 props 每次都是新引用,浅比较永远失败——memo 等于白包。用 useMemo/useCallback 把 props 的引用稳住,memo 才能真正生效。
UserList.tsx · useMemo 与 React.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 重渲染,但 userskeyword 没变 → filtered 引用不变 → 每个 UserRowuser prop 都「没变」→ memo 判定跳过,控制台不会再刷出一排 render 日志。注意这是一套组合拳:只包 memo 不稳引用、或只稳引用但子组件没 memo,都可能白忙。

后端类比:方法级本地缓存,deps 就是缓存键

useMemo(fn, deps) 活脱脱是一个「方法级本地缓存」:deps缓存键fn回源计算。键没变 → 命中缓存直接返回;键变了 → 重新计算并回填。你也懂缓存的铁律:键选漏了(少声明真依赖)会读到脏数据,键选太敏感(多了无关依赖)则命中率惨淡。这里的键必须是「参与计算的全部输入」,一个都不能少——和第 10 篇 exhaustive-deps 的纪律完全同源。

useCallback:缓存「函数」的语法糖

函数也是值,也遵循引用相等。所以「把函数缓存住」和「把对象缓存住」是同一件事——useCallback(fn, deps) 就是 useMemo 的语法糖:

sugar.tsx · 两者完全等价
// 假设外层组件作用域里有 id 和 save:

// 下面两行完全等价(deps 相同的前提下):
const handler  = useCallback(() => save(id), [id]);
const handler2 = useMemo(() => () => save(id), [id]);

// 规律:useCallback(fn, deps) ≈ useMemo(() => fn, deps)
// 「要缓存的东西恰好是函数」时,useCallback 只是少包一层

它最常见的用途,是稳住「传给 memo 子组件的回调」:

TodoList.tsx · 稳住回调,让 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,性能起飞?」——恰恰相反。缓存本身是有成本的

  1. 内存成本:每份缓存都要一直占着内存,直到 deps 变化才释放旧值;
  2. 簿记成本:React 每次渲染都要保存旧 deps、逐项比较、维护缓存链表——包得越多,这套簿记越重;
  3. 代码成本:满屏的 useMemo/useCallback 让依赖关系更难读,还常常掩盖真正的性能问题。

对一次微秒级的 filter:不包,渲染时直接算,便宜又干净;包上,React 要为它保存依赖、比较引用、维护缓存——簿记开销可能比计算本身还贵。这就是「过早优化」的 React 版,和后端同一个道理:没有 profile 数据,别加缓存(第 41 篇会讲怎么用 Profiler 测量)。

后端类比:给每张表都套一层 Redis

你不会给每个查询都套 Redis:查询本身 2ms,引入缓存后多一次网络往返、多一份一致性维护,可能变成 5ms。useMemo 同理——它是给「贵的计算」和「必须稳的引用」准备的工具,不是荣誉勋章,不是每个函数体都该穿的衣服。

还有一条容易被忽略的「免责条款」:useMemo 的缓存只是性能提示,不是正确性保证。官方明确允许 React 在必要时丢弃缓存、重新计算(比如为释放内存),语义上它也可能在组件渲染失败重试时重跑。所以:绝不能把「只执行一次」的语义托付给 useMemo——发请求、建连接这类副作用必须进 effect(第 9 篇的规矩);缓存里只放「重算一遍也无所谓」的纯计算结果。后端对照:这就像缓存穿透兜底——缓存没了,回源必须总能重新算出同样的东西。

顺带一个「免费的 useMemo」常识:把常量提到组件外面,比任何 Hook 都干净——

FilterBar.tsx · 常量外提,天生引用稳定
// ✅ 写在组件函数外面:无论渲染多少次,都是同一个引用
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 · 评论