React 学习站
React›进阶›进阶

记忆化:useMemo / useCallback / memo

进阶进阶

一句话定义

三个 API 共享同一思想——缓存:useMemo 缓存计算结果、useCallback 缓存函数引用、memo 缓存组件渲染结果;触发缓存失效的唯一开关是依赖数组。

为什么重要

它们是 React 面试与代码评审争议的旋涡中心:有人到处包、有人一律不用。正确姿势来自对成本模型的理解——缓存本身有成本(内存、依赖数组维护、引用比较),所以优化的前提永远是「先测量」(kp-019),React Compiler 的出现又改变了这条权衡线(见本篇末节)。

前置知识

  • kp-008 渲染模型与「父渲染 → 子渲染」的默认行为。
  • 引用相等性({} !== {})。

核心概念

  • useMemo(fn, deps):deps 不变 → 返回上次 fn 的结果,跳过 fn 执行。
  • useCallback(fn, deps):等价于 useMemo(() => fn, deps);deps 不变 → 同一个函数引用。
  • memo(Component):浅比较 props,全部相等 → 跳过整个子树渲染。
  • 浅比较的坑:props 里的对象/数组/函数每次渲染都是新引用 → memo 无效;这就是记忆化三件套必须配合使用的原因。
  • Compiler 视角(React 19 时代):React Compiler 尝试自动记忆化,人工使用这些 API 的必要性下降,但理解原理仍必要。

原理 / 机制

三件套协同的标准场景:

jsxconst Row = memo(function Row({ item, onSelect }) {
  return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
});

function List({ items, query }) {
  // 1. 缓存计算:昂贵过滤不随无关 state 重跑
  const filtered = useMemo(
    () => items.filter(i => i.name.includes(query)),
    [items, query]
  );

  // 2. 缓存引用:不因 List 重渲染而让 Row 的 memo 失效
  const handleSelect = useCallback(id => {
    setSelectedId(id);
  }, []); // 只用了 setter(稳定) → 空依赖安全

  return (
    <ul>
      {filtered.map(i => <Row key={i.id} item={i} onSelect={handleSelect} />)}
    </ul>
  );
}

只包 memo(Row) 而不包 useCallback 会怎样:List 每次渲染创建新 handleSelect → 浅比较失败 → Row 照样重渲染 → memo 白做。三者是一条链,断一环全失效。

useMemo 的另一个独立价值——引用稳定性(不只是省计算):

jsx// kp-012 的 Context value 必须稳定,否则全体消费者陪跑
const value = useMemo(() => ({ theme, setTheme }), [theme]);

memo 的浅比较语义与豁免:

jsx// 浅比较:Object.is 逐个比 props
//   ✅ 相等:数字/字符串/布尔/同一个函数引用
//   ❌ 不等:任何内联对象/数组/箭头函数

// 豁免姿势:children 会绕过浅比较(父渲染 → children 新元素)
const Item = memo(function Item({ children }) { ... });
// <Item><b>x</b></Item> 中 <b>x</b> 元素每次渲染都是新对象 → memo 失效
// 修法:把内容作为字符串/数据传,或接受它总是重渲染

成本模型的量化直觉:

textuseMemo(fn):  保存结果 + 比较依赖数组      换取「跳过 fn」
memo(C):      保存上次输出 + 浅比较 props  换取「跳过 C 子树渲染」
两者收益 > 成本的条件:fn/渲染真的贵 && deps 变化频率低

直观类比

useMemo 像保险柜里的成品:钥匙没换(deps 不变)就直接开柜取货,不再进车间重做。useCallback 像专线电话号码:号码不变,对方(memo 子组件)就知道「还是同一个人打来的」,不用每次重新登记。memo 像前台闸机:工牌(props)没变就放行免检——但工牌上贴了张手写便条(内联对象/函数),每次都算新工牌,闸机形同虚设。

实例 / 案例

先测量、再优化的完整案例(学习路径第三阶段项目的目标):

jsx// Profiler 发现:滚动时 1000 行 Row 全部重渲染,主线程掉帧
// 1) 打开 React DevTools Profiler → Flamegraph 确认 Row 重渲染原因:
//    "onSelect changed" / "item changed"
// 2) 若 item 是父组件 map 出来的稳定对象 → 只需 memo + useCallback
// 3) 若父组件每次渲染都重建 items 数组 → useMemo(items)
// 4) 若 query 输入导致高频过滤 5 万条 → useMemo + 虚拟化(react-window)

决策树(贴在脑子里):

text有性能问题吗?
 ├─ 没有 → 不用任何记忆化(默认干净代码)
 └─ 有 → Profiler 定位是谁渲染慢/渲染多
     ├─ 子组件因「函数引用」重渲染 → useCallback + memo
     ├─ 子组件因「对象/数组引用」重渲染 → useMemo + memo
     ├─ 计算本身慢 → useMemo(或上移到事件/Worker)
     └─ 列表长 → 虚拟化 > 记忆化

常见误区

  • 「到处 useMemo 更安全」:依赖数组写错反而引入 stale 值 bug;缓存过期比不缓存更危险。Compiler 的动机之一就是让人少手写。
  • useCallback 单独使用:没有 memo 子组件消费它,只是白存一份引用。
  • 依赖数组里塞函数调用:useMemo(() => x, [f()]) 依赖的是返回值而不是 f;依赖必须是数据引用。
  • 把 useMemo 当「保证只执行一次」:它不是「一次」,是「deps 变就重算」;副作用依然禁止(与 kp-016 reducer 同理)。
  • memo + 不稳定 children/Context:children 新元素、context 变化都会绕过 memo(见豁免姿势)。

自测题

  1. useCallback 的函数签名等价于哪种 useMemo 调用?两者缓存的东西有何不同?
  2. 为什么 memo 组件必须搭配 useCallback/useMemo 才有效?给出一个三者缺一即失效的最小反例。
  3. memo 组件接收 children 时为什么仍会重渲染?两种修法?
  4. 「有 useMemo 总比没有好」错在哪?举一个「记忆化引入 bug」的例子。
  5. React Compiler 的目标是什么?它上线后,人工记忆化 API 的定位变成了什么?

与其他知识点的关系

  • 向前:浅比较与 kp-008 的 diff、kp-012 的 value 稳定、kp-014 的闭包引用互相咬合。
  • 向后:kp-019 是「何时该用」的测量方法论主场;kp-022 的并发调度不要求记忆化(调优先级 ≠ 提速);kp-025 提及 Compiler 与 React 19 生态的关系。

延伸阅读

  • useMemo / useCallback / memo API 页(官方):<https://react.dev/reference/react/useMemo>
  • Memoization(官方 Learn 章节):<https://react.dev/learn/skipping-re-rendering>