先记住这个答案
useMemo 在需要计算时执行传入函数并返回结果,后续渲染逐项用 Object.is 比较依赖;依赖都相同且缓存仍可用时,返回之前的结果。依赖数组应在代码中内联写出、长度固定,并包含计算读取的全部响应式值。对象和数组按引用比较,原地修改内容不会自动触发失效。计算必须保持纯粹,缓存可以被丢弃,因此不能把它当作资源生命周期或业务持久化保证。
- 依赖按项比较,不会深度观察对象
- 遗漏依赖会把旧计算结果继续返回
- 纯计算可重做,副作用不能藏在计算里
依赖必须表达计算真正读取的数据
筛选函数读取 items 与 tag,因此两者都在依赖数组中。只写 tag 会使父级换了一批条目之后仍显示旧结果;只写 items 则会在切换标签后继续展示旧分类,这两种遗漏都不是合法优化。
依赖数组的长度和顺序应保持固定,让每个位置始终对应同一种输入。不要按条件拼接依赖,也不要通过禁用检查规则来隐藏缺项;如果依赖难以说明,通常需要先缩小计算职责。
import { useMemo, useState } from 'react';
type Item = Readonly<{ id: string; label: string; tag: string }>;
export default function FilteredItems({ items, tag }: {
items: readonly Item[];
tag: string;
}) {
const [revision, setRevision] = useState(0);
const visible = useMemo(() => items.filter(item => item.tag === tag), [items, tag]);
return <>
<button onClick={() => setRevision(n => n + 1)}>增加无关计数</button>
<output>计数:{revision}</output>
<ul>{visible.map(item => <li key={item.id}>{item.label}</li>)}</ul>
</>;
}增加计数不改变筛选依赖,替换条目数组或标签则改变依赖。readonly 类型帮助表达不可变输入约定,但它不是运行时冻结;调用方仍需要遵守不直接修改共享数据的规则。
相同内容的新数组与同数组突变恰好相反
父级用 map 或展开语法生成内容相同的新数组,引用已经变化,useMemo 会重新计算。缓存并不判断两个数组里的条目是否业务等价;如果重建是无关操作,可先改善数据流或结构共享。
反过来,向同一个数组直接追加条目再触发其他状态更新,引用没有变化,缓存可能继续返回旧筛选结果。应生成新数组交给组件,而不是把数组长度单独加入依赖来修补部分突变,因为修改已有字段仍会漏掉。
它不是多键缓存,也不负责副作用
标签从甲切换到乙,再切回甲时,不要假设最早那份甲结果会从历史表中自动取回。useMemo 的常规契约是围绕当前依赖与最近结果进行复用;若需要多参数缓存,应另行设计容量、失效和生命周期。
开发严格模式可以额外调用计算函数以检查纯度,初次挂载发生挂起等情况也可能丢弃缓存。函数应只计算并返回值,不能在里面创建必须只执行一次的连接、提交订单或累积外部计数。
容易答错的地方
- 用 JSON 字符串代替所有依赖
- 序列化会在每次渲染执行并分配字符串,也不能表达函数或任意对象的全部语义。应先维护不可变输入和明确字段,只有特定可序列化数据协议确实需要内容键时,才独立设计该协议。
- 把缓存结果当作可以随意修改的工作区
- 修改缓存返回的数组会污染后续复用结果,甚至在新依赖尚未到来时让界面行为取决于外部写入。缓存值应当按派生数据使用,需要用户编辑的数据应进入明确管理的状态。
面试官还会怎么问?
不传依赖数组会发生什么?
计算会在每次渲染执行,无法依靠依赖相等复用前一次结果。应传入完整且固定的依赖数组;如果计算本来很便宜,也可以直接计算,避免为没有收益的缓存增加阅读成本。
空数组可以保证只计算一次吗?
不能保证。它表示没有需要跟踪的响应式依赖,但严格模式、挂起重试、重新挂载或缓存失效仍可能重新计算。一次性业务行为应有显式的事件、资源生命周期或幂等机制。
useMemo 和 memo 应该同时使用吗?
二者解决不同边界:useMemo 复用计算结果,memo 复用组件渲染。稳定的派生数组传给 memo 子组件时可以配合,但单独昂贵的计算也可能值得缓存,不能把同时使用当作固定套餐。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。