useMemo 在多次渲染之间缓存计算结果。React 在第一次渲染时运行你的函数并保存结果,之后的渲染都返回这个结果,直到依赖数组中的某个值发生变化。
打开控制台,切换几次主题:没有任何日志输出,切换也是瞬间完成的。切换标签页,就会出现 filtering for active,并伴随短暂的停顿,因为 tab 是一个依赖。现在把 useMemo 那一行换成 const visible = filterTodos(todos, tab);,再切换主题:每次点击都会输出日志,并卡顿 100 毫秒。
语法
const cachedValue = useMemo(calculateValue, dependencies);
const visible = useMemo(() => filterTodos(todos, tab), [todos, tab]);
calculateValue是一个不接收参数的函数,返回你想缓存的值。React 在渲染期间调用它。dependencies列出该函数读取的、来自组件的所有值:props、state,以及在组件函数体中声明的变量。- 每次渲染时,React 用
Object.is把每个依赖与上一次渲染时的值进行比较。如果全部相同,就直接返回缓存的值,不调用你的函数。只要有一个不同,就调用函数并缓存新的结果。
useMemo 是一个 Hook,所以要遵守 Hook 的规则:在组件的顶层调用它,不要在循环或条件语句中调用。
依赖必须完整
如果数组里漏掉了某个值,缓存就会一直返回用它的旧值计算出的结果。下面的总价读取了 qty,但依赖只列了 price:
点击 Quantity:数量变成 2,但总价仍是 10,也没有日志输出。点击 Price,总价跳到 22,因为直到这时计算才再次运行,并读到了当前的 qty。把 qty 加进数组,两个按钮就都会更新总价。在真实项目中,react-hooks/exhaustive-deps lint 规则能发现这个错误。
相反的错误是依赖在每次渲染时都是新的,比如在组件函数体中创建的对象。它永远不会等于上一次的值,所以缓存永远用不上:
const filters = { tab, query }; // a new object every render
const visible = useMemo(() => applyFilters(todos, filters), [todos, filters]); // recomputes every time
// List the primitive values instead
const visible = useMemo(() => applyFilters(todos, { tab, query }), [todos, tab, query]);
为 memo 子组件保持对象稳定
使用 useMemo 的第二个理由和计算慢不慢无关。用 memo 包裹的子组件,只有在每个 prop 都和上一次相同时才会跳过渲染。在组件函数体中写的对象字面量每次渲染都是一个新对象,所以即使内容没变,memo 也会失效。useMemo 会一直保留同一个对象,直到它的输入发生变化。
在备注框里输入文字,Chart 保持安静;点击 Switch unit 则会输出一次渲染日志。把 useMemo 调用换成普通的 const options = { unit, max: unit === 'km' ? 100 : 62 };,备注框中的每次按键都会输出 render Chart。
依赖对象的 Effect 也是同样的道理。如果某个 Effect 的依赖中有 options,而 options 每次渲染都重新创建,那么这个 Effect 每次渲染后都会运行。useMemo 能解决这个问题,但更简单的办法往往是在 Effect 内部创建对象,并依赖那些原始值:
useEffect(() => {
const options = { serverUrl, roomId };
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]);
什么时候不该用 useMemo
组件中的大多数计算开销都很小:数字相加、拼接两个字符串、筛选几十个元素。缓存这些几乎省不下什么:React 每次渲染仍然要保存值、比较依赖,代码也更难读。
// No useMemo needed
const fullName = first + ' ' + last;
const doneCount = todos.filter((t) => t.done).length;
想知道某个计算是否慢,就给它计时。用 console.time('filter') 和 console.timeEnd('filter') 把计算包起来,执行一次交互,看看输出的数字。粗略地说,在你的电脑上耗时 1 毫秒或以上的计算值得考虑缓存,同时要记住,用户的设备可能比你的慢。在使用 useMemo 之前,也先想想这项工作是否本来就该放在渲染里:永远不变的数据可以在组件外部计算一次;只有页面一小部分用到的 state 可以移到一个更小的组件里(见渲染)。
计算派生值:useMemo 与 useEffect
一种常见的写法是在 Effect 中计算一个值,再把它存进 state:
// Two renders per change, and the first one has stale data
const [visible, setVisible] = useState([]);
useEffect(() => {
setVisible(filterTodos(todos, tab));
}, [todos, tab]);
// One render, always in sync
const visible = useMemo(() => filterTodos(todos, tab), [todos, tab]);
Effect 版本先用旧的 visible 渲染一次,然后运行 Effect、设置 state,再渲染一次。这多了一次渲染,而且第一次渲染时列表和标签页对不上。用 useMemo 在渲染期间计算(如果计算开销很小,干脆不用 Hook),在同一次渲染中就能得到正确的值。Effect 是用来与 React 之外的东西同步的,不是用来从一个 state 推导出另一个 state 的。
是性能提示,不是保证
useMemo 告诉 React 它可以复用某个结果,但并不承诺一定会复用。正常运行时 React 会保留缓存的值,但也可能丢弃它,比如在开发环境中编辑文件时,或者组件在首次挂载时挂起(suspend)时。代码要写得即使计算再次运行也依然正确:函数必须是纯函数,不能有副作用,比如发送请求、订阅或修改函数外部的变量。副作用应该放在事件处理函数或 Effect 中。
在开发环境的 StrictMode 下,React 会调用你的计算函数两次,帮你发现不纯的代码。这里的预览按生产构建的方式运行,所以每条 filtering for 日志你只会看到一次。
useMemo 与 React Compiler
React Compiler 1.0 是一个构建工具,它会分析你的组件并自动添加记忆化,在 useMemo、useCallback 和 memo 能起作用的地方缓存计算结果和 JSX。在使用它的项目中,你需要手写的这类代码会少很多。手写的 useMemo 仍然可以和编译器一起使用;没有配置编译器时,它仍然是你该用的工具;而且在大多数现有的 React 代码中,你都会遇到它。
常见问题
React 中的 useMemo 有什么用?
它在第一次渲染时调用你的计算函数并保存结果;在之后的渲染中,只要依赖数组里的每个值都和上一次相同(用 Object.is 比较),就直接返回保存的结果。
什么时候应该用 useMemo?
当某个计算明显很慢、而它的输入很少变化时;或者当某个对象或数组需要在多次渲染之间保持同一个引用,因为有 memo 子组件或 Effect 依赖它时。开销很小的计算就不必用它。
useMemo 和 useCallback 有什么区别?
useMemo 缓存的是函数返回的值。useCallback 缓存的是函数本身。useCallback(fn, deps) 等同于 useMemo(() => fn, deps)。
useMemo 能保证值永远不会重新计算吗?
不能。它只是一个性能提示。React 在某些情况下可能丢弃缓存的值,比如在开发环境中编辑文件时,所以即使计算再次运行,你的代码也必须能正确工作。
用了 React Compiler 还需要 useMemo 吗?
如果你的构建流程使用了 React Compiler,它可以自动添加记忆化。没有使用编译器时,或者遇到编译器跳过的情况,手写的 useMemo 照样有效,在现有代码中也仍然很常见。