useMemo
是一个 React Hook,它允许您在重新渲染之间缓存计算结果。
const cachedValue = useMemo(calculateValue, dependencies)
参考
useMemo(calculateValue, dependencies)
在组件的顶层调用 useMemo
以缓存重新渲染之间的计算结果
import { useMemo } from 'react';
function TodoList({ todos, tab }) {
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
// ...
}
参数
-
calculateValue
:计算要缓存的值的函数。它应该是纯函数,不应接受任何参数,并且应该返回任何类型的值。React 会在初始渲染期间调用您的函数。在下次渲染时,如果自上次渲染以来dependencies
没有更改,React 将再次返回相同的值。否则,它将调用calculateValue
,返回其结果,并将其存储起来以便以后重用。 -
dependencies
:calculateValue
代码中引用的所有响应式值的列表。响应式值包括 props、state 以及直接在组件主体中声明的所有变量和函数。如果您的 linter 为 React 配置,它将验证每个响应式值是否都正确指定为依赖项。依赖项列表必须具有恒定的项目数,并且像[dep1, dep2, dep3]
这样内联编写。React 将使用Object.is
比较将每个依赖项与其先前值进行比较。
返回值
在初始渲染时,useMemo
返回调用不带参数的 calculateValue
的结果。
在下一次渲染期间,它会返回上次渲染中已存储的值(如果依赖项未更改),或者再次调用 calculateValue
,并返回 calculateValue
返回的结果。
注意事项
useMemo
是一个 Hook,因此您只能在 组件的顶层 或您自己的 Hook 中调用它。您不能在循环或条件语句中调用它。如果需要这样做,请提取一个新的组件并将状态移入其中。- 在严格模式下,React 会 调用您的计算函数两次,以便 帮助您发现意外的副作用。 这是仅限开发环境的行为,不会影响生产环境。如果您的计算函数是纯函数(应该如此),这不会影响您的逻辑。其中一次调用的结果将被忽略。
- React 不会丢弃缓存的值,除非有特定的理由这样做。 例如,在开发环境中,当您编辑组件的文件时,React 会丢弃缓存。在开发环境和生产环境中,如果您的组件在初始挂载期间挂起,React 都会丢弃缓存。将来,React 可能会添加更多利用丢弃缓存的功能 - 例如,如果 React 将来添加对虚拟列表的内置支持,则丢弃滚动到虚拟表格视口之外的项目的缓存是有意义的。如果您仅仅将
useMemo
作为性能优化,那么这样做应该没问题。否则,状态变量 或 ref 可能更合适。
用法
跳过昂贵的重新计算
要在重新渲染之间缓存计算结果,请将其包装在组件顶层的 useMemo
调用中
import { useMemo } from 'react';
function TodoList({ todos, tab, theme }) {
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
// ...
}
您需要将两件事传递给 useMemo
- 一个不带参数的 计算函数,例如
() =>
,它返回您想要计算的内容。 - 一个 依赖项列表,其中包括组件中在计算内部使用的每个值。
在初始渲染时,您从 useMemo
获得的 值 将是调用您的 计算 的结果。
在每次后续渲染时,React 都会将 依赖项 与您在上一次渲染期间传递的依赖项进行比较。如果没有依赖项发生更改(使用 Object.is
进行比较),则 useMemo
将返回您之前已经计算的值。否则,React 将重新运行您的计算并返回新值。
换句话说,useMemo
会在重新渲染之间缓存计算结果,直到其依赖项发生更改。
让我们看一个例子,看看什么时候这很有用。
默认情况下,React 会在每次重新渲染时重新运行组件的整个主体。例如,如果这个 TodoList
更新了它的状态或从它的父组件接收了新的 props,那么 filterTodos
函数将重新运行
function TodoList({ todos, tab, theme }) {
const visibleTodos = filterTodos(todos, tab);
// ...
}
通常情况下,这不是问题,因为大多数计算都非常快。但是,如果您正在过滤或转换大型数组,或者正在进行一些昂贵的计算,那么您可能希望在数据没有更改的情况下跳过再次执行。如果 todos
和 tab
都与上次渲染时相同,则像之前一样将计算包装在 useMemo
中,可以让您重用之前已经计算的 visibleTodos
。
这种类型的缓存称为 记忆化搜索。
深入探讨
一般来说,除非您正在创建或循环数千个对象,否则它可能并不昂贵。如果您想获得更准确的判断,可以添加一个控制台日志来测量一段代码的耗时
console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');
执行您正在测量的交互(例如,在输入框中输入内容)。然后,您将在控制台中看到类似 filter array: 0.15ms
的日志。如果记录的总时间加起来很大(例如 1ms
或更多),则对该计算进行记忆化可能是有意义的。作为实验,您可以将计算包装在 useMemo
中,以验证该交互的总记录时间是否减少。
console.time('filter array');
const visibleTodos = useMemo(() => {
return filterTodos(todos, tab); // Skipped if todos and tab haven't changed
}, [todos, tab]);
console.timeEnd('filter array');
useMemo
不会使*第一次*渲染更快。它只帮助您在更新时跳过不必要的计算工作。
请记住,您的机器可能比用户的机器更快,因此最好通过人为减慢速度来测试性能。例如,Chrome 为此提供了 CPU 节流 选项。
另请注意,在开发环境中测量性能不会为您提供最准确的结果。(例如,当 严格模式 打开时,您将看到每个组件渲染两次而不是一次。)为了获得最准确的时间,请为生产环境构建您的应用并在用户使用的设备上进行测试。
深入探讨
如果您的应用像本网站一样,并且大多数交互都很粗糙(例如替换页面或整个部分),则通常不需要记忆化。另一方面,如果您的应用更像一个绘图编辑器,并且大多数交互都是粒度的(例如移动形状),那么您可能会发现记忆化非常有帮助。
仅在以下几种情况下,使用 useMemo
进行优化才有价值。
- 您放入
useMemo
的计算明显缓慢,并且其依赖项很少更改。 - 您将其作为 prop 传递给包装在
memo
中的组件。如果值没有改变,您希望跳过重新渲染。记忆化使您的组件仅在依赖项不同时才重新渲染。 - 您传递的值稍后将用作某些 Hook 的依赖项。例如,也许另一个
useMemo
计算值依赖于它。或者,您可能依赖于useEffect
中的此值。
在其他情况下,将计算包装在 useMemo
中没有任何好处。这样做也没有什么明显的坏处,因此有些团队选择不去考虑个别情况,而是尽可能多地进行记忆化。这种方法的缺点是代码变得难以阅读。此外,并非所有记忆化都是有效的:单个“始终是新”的值足以破坏整个组件的记忆化。
在实践中,您可以通过遵循一些原则来消除许多不必要的记忆化。
- 当一个组件在视觉上包装其他组件时,让它 接受 JSX 作为子组件。这样,当包装器组件更新其自身状态时,React 知道其子组件不需要重新渲染。
- 首选本地状态,并且不要将 状态提升 到不必要的层级。例如,不要将临时状态(如表单和项目是否悬停)保存在树的顶部或全局状态库中。
- 保持您的 渲染逻辑的纯净性。如果重新渲染组件会导致问题或产生一些明显的视觉伪像,那么这就是组件中的错误!修复错误,而不是添加记忆化。
- 避免 不必要的更新状态的 Effect。React 应用中的大多数性能问题都是由 Effect 引起的更新链导致的,这些 Effect 会导致您的组件一遍又一遍地渲染。
- 尝试 从 Effect 中删除不必要的依赖项。例如,与其进行记忆化,不如将某些对象或函数移入 Effect 内部或组件外部通常更简单。
如果某个特定的交互仍然感觉滞后,请 使用 React 开发者工具分析器 查看哪些组件最能从记忆化中受益,并在需要的地方添加记忆化。这些原则使您的组件更易于调试和理解,因此在任何情况下都应该遵循这些原则。从长远来看,我们正在研究 自动执行粒度记忆化,以一劳永逸地解决这个问题。
示例 1的 2: 使用 useMemo
跳过重新计算
在此示例中,filterTodos
的实现被人为地*减慢了速度*,以便您可以看到在渲染期间调用某些 JavaScript 函数确实很慢时会发生什么。尝试切换选项卡和主题。
切换选项卡感觉很慢,因为它强制重新执行速度较慢的 filterTodos
。这是预期的,因为 tab
已更改,因此*需要*重新运行整个计算。(如果您好奇为什么它运行了两次,请参阅 此处 的解释。)
切换主题。 感谢 useMemo
,尽管人为地降低了速度,但它仍然很快! 由于 todos
和 tab
(您将它们作为依赖项传递给 useMemo
)自上次渲染后没有发生变化,因此跳过了缓慢的 filterTodos
调用。
import { useMemo } from 'react'; import { filterTodos } from './utils.js' export default function TodoList({ todos, theme, tab }) { const visibleTodos = useMemo( () => filterTodos(todos, tab), [todos, tab] ); return ( <div className={theme}> <p><b>Note: <code>filterTodos</code> is artificially slowed down!</b></p> <ul> {visibleTodos.map(todo => ( <li key={todo.id}> {todo.completed ? <s>{todo.text}</s> : todo.text } </li> ))} </ul> </div> ); }
跳过组件的重新渲染
在某些情况下,useMemo
还可以帮助您优化重新渲染子组件的性能。为了说明这一点,假设这个 TodoList
组件将 visibleTodos
作为 prop 传递给子组件 List
export default function TodoList({ todos, tab, theme }) {
// ...
return (
<div className={theme}>
<List items={visibleTodos} />
</div>
);
}
您可能已经注意到,切换 theme
prop 会导致应用程序冻结片刻,但如果从 JSX 中删除 <List />
,则感觉很快。这说明您值得尝试优化 List
组件。
默认情况下,当组件重新渲染时,React 会递归地重新渲染其所有子组件。 这就是为什么当 TodoList
使用不同的 theme
重新渲染时,List
组件*也会*重新渲染。对于不需要太多计算即可重新渲染的组件来说,这很好。但是,如果您已经确认重新渲染速度很慢,则可以通过将其包裹在 memo
中来告诉 List
在其 props 与上次渲染时相同时跳过重新渲染:
import { memo } from 'react';
const List = memo(function List({ items }) {
// ...
});
通过此更改,如果 List
的所有 props 都与其上次渲染时*相同*,则 List
将跳过重新渲染。 这就是缓存计算变得重要的地方!假设您在没有 useMemo
的情况下计算了 visibleTodos
export default function TodoList({ todos, tab, theme }) {
// Every time the theme changes, this will be a different array...
const visibleTodos = filterTodos(todos, tab);
return (
<div className={theme}>
{/* ... so List's props will never be the same, and it will re-render every time */}
<List items={visibleTodos} />
</div>
);
}
在上面的示例中,filterTodos
函数始终创建一个*不同的*数组,类似于 {}
对象字面量始终创建一个新对象。通常情况下,这不是问题,但这意味着 List
props 将永远不会相同,并且您的 memo
优化将不起作用。这就是 useMemo
派上用场的地方
export default function TodoList({ todos, tab, theme }) {
// Tell React to cache your calculation between re-renders...
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab] // ...so as long as these dependencies don't change...
);
return (
<div className={theme}>
{/* ...List will receive the same props and can skip re-rendering */}
<List items={visibleTodos} />
</div>
);
}
通过将 visibleTodos
计算包装在 useMemo
中,您可以确保它在重新渲染之间具有*相同*的值(直到依赖项发生变化)。您*不必*将计算包装在 useMemo
中,除非您出于某种特定原因这样做。在此示例中,原因是您将它传递给了一个包裹在 memo
中的组件,这使其可以跳过重新渲染。添加 useMemo
还有其他一些原因,本页稍后将对此进行说明。
深入探讨
您可以将 <List />
JSX 节点本身包装在 useMemo
中,而不是将 List
包装在 memo
中
export default function TodoList({ todos, tab, theme }) {
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
const children = useMemo(() => <List items={visibleTodos} />, [visibleTodos]);
return (
<div className={theme}>
{children}
</div>
);
}
行为将是相同的。如果 visibleTodos
没有改变,List
将不会重新渲染。
像 <List items={visibleTodos} />
这样的 JSX 节点是一个像 { type: List, props: { items: visibleTodos } }
这样的对象。创建此对象的成本非常低,但 React 不知道其内容是否与上次相同。这就是为什么默认情况下,React 会重新渲染 List
组件。
但是,如果 React 看到与上次渲染时完全相同的 JSX,它将不会尝试重新渲染您的组件。这是因为 JSX 节点是 不可变的。 JSX 节点对象不会随时间而改变,因此 React 知道跳过重新渲染是安全的。但是,为此,节点必须*实际上是同一个对象*,而不仅仅是在代码中看起来相同。这就是 useMemo
在此示例中的作用。
手动将 JSX 节点包装到 useMemo
中并不方便。例如,您不能有条件地执行此操作。这通常是您要使用 memo
包装组件而不是包装 JSX 节点的原因。
示例 1的 2: 使用 useMemo
和 memo
跳过重新渲染
在此示例中,List
组件是人为减慢的,以便您可以看到在您渲染的 React 组件确实很慢时会发生什么。尝试切换选项卡并切换主题。
切换选项卡感觉很慢,因为它会强制减慢的 List
重新渲染。这是预期的,因为 tab
已更改,因此您需要在屏幕上反映用户的最新选择。
接下来,尝试切换主题。由于 useMemo
与 memo
的结合使用,尽管人为减慢了速度,但它仍然很快! List
跳过了重新渲染,因为自上次渲染后 visibleTodos
数组没有改变。 visibleTodos
数组没有改变,因为自上次渲染后 todos
和 tab
(您将其作为依赖项传递给 useMemo
)都没有改变。
import { useMemo } from 'react'; import List from './List.js'; import { filterTodos } from './utils.js' export default function TodoList({ todos, theme, tab }) { const visibleTodos = useMemo( () => filterTodos(todos, tab), [todos, tab] ); return ( <div className={theme}> <p><b>Note: <code>List</code> is artificially slowed down!</b></p> <List items={visibleTodos} /> </div> ); }
记忆另一个 Hook 的依赖项
假设您有一个计算依赖于直接在组件主体中创建的对象
function Dropdown({ allItems, text }) {
const searchOptions = { matchMode: 'whole-word', text };
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // 🚩 Caution: Dependency on an object created in the component body
// ...
依赖于这样的对象违背了记忆的意义。当组件重新渲染时,组件主体中所有代码都会再次运行。创建 searchOptions
对象的代码行也会在每次重新渲染时运行。由于 searchOptions
是您的 useMemo
调用的依赖项,并且每次都不同,因此 React 知道依赖项不同,并且每次都会重新计算 searchItems
。
要解决此问题,您可以在将其作为依赖项传递之前记忆 searchOptions
对象本身
function Dropdown({ allItems, text }) {
const searchOptions = useMemo(() => {
return { matchMode: 'whole-word', text };
}, [text]); // ✅ Only changes when text changes
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // ✅ Only changes when allItems or searchOptions changes
// ...
在上面的示例中,如果 text
没有改变,则 searchOptions
对象也不会改变。但是,更好的解决方法是将 searchOptions
对象声明移至 useMemo
计算函数内部
function Dropdown({ allItems, text }) {
const visibleItems = useMemo(() => {
const searchOptions = { matchMode: 'whole-word', text };
return searchItems(allItems, searchOptions);
}, [allItems, text]); // ✅ Only changes when allItems or text changes
// ...
现在,您的计算直接依赖于 text
(它是一个字符串,并且不会“意外地”变得不同)。
记忆函数
假设 Form
组件包装在 memo
中。您想将一个函数作为 prop 传递给它
export default function ProductPage({ productId, referrer }) {
function handleSubmit(orderDetails) {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
}
return <Form onSubmit={handleSubmit} />;
}
正如 {}
创建不同的对象一样,像 function() {}
这样的函数声明和像 () => {}
这样的表达式在每次重新渲染时都会产生一个不同的函数。就其本身而言,创建一个新函数并不是问题。这不是要避免的事情!但是,如果 Form
组件被记忆,则当没有 prop 更改时,您可能希望跳过重新渲染它。始终不同的 prop 会违背记忆的意义。
要使用 useMemo
记忆函数,您的计算函数必须返回另一个函数
export default function Page({ productId, referrer }) {
const handleSubmit = useMemo(() => {
return (orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
};
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
这看起来很笨拙!记忆函数非常普遍,因此 React 有一个专门用于此的内置 Hook。将您的函数包装到 useCallback
而不是 useMemo
中,以避免编写额外的嵌套函数
export default function Page({ productId, referrer }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
上面的两个例子是完全等价的。 useCallback
的唯一好处是它可以让您避免在内部编写额外的嵌套函数。它没有做任何其他事情。 阅读有关 useCallback
的更多信息。
故障排除
我的计算在每次重新渲染时都会运行两次
在严格模式下,React 会调用你的某些函数两次而不是一次。
function TodoList({ todos, tab }) {
// This component function will run twice for every render.
const visibleTodos = useMemo(() => {
// This calculation will run twice if any of the dependencies change.
return filterTodos(todos, tab);
}, [todos, tab]);
// ...
这是预期的行为,不会破坏你的代码。
这种仅在开发环境下的行为可以帮助你保持组件的纯净性。React 会使用其中一次调用的结果,而忽略另一次调用的结果。只要你的组件和计算函数是纯的,这就不会影响你的逻辑。但是,如果它们意外地不纯,这有助于你注意到并修复错误。
例如,这个不纯的计算函数会改变你作为 prop 接收到的数组:
const visibleTodos = useMemo(() => {
// 🚩 Mistake: mutating a prop
todos.push({ id: 'last', text: 'Go for a walk!' });
const filtered = filterTodos(todos, tab);
return filtered;
}, [todos, tab]);
React 会调用你的函数两次,所以你会注意到待办事项被添加了两次。你的计算不应更改任何现有对象,但可以更改你在计算过程中创建的任何*新*对象。例如,如果filterTodos
函数始终返回一个*不同*的数组,则可以改为改变*该*数组:
const visibleTodos = useMemo(() => {
const filtered = filterTodos(todos, tab);
// ✅ Correct: mutating an object you created during the calculation
filtered.push({ id: 'last', text: 'Go for a walk!' });
return filtered;
}, [todos, tab]);
阅读保持组件的纯净性以了解更多关于纯净性的信息。
我的useMemo
调用应该返回一个对象,但返回了 undefined
这段代码不起作用:
// 🔴 You can't return an object from an arrow function with () => {
const searchOptions = useMemo(() => {
matchMode: 'whole-word',
text: text
}, [text]);
在 JavaScript 中,() => {
表示箭头函数体的开始,因此 {
大括号不是你对象的一部分。这就是它不返回对象的原因,并且会导致错误。你可以通过添加像 ({
和 })
这样的括号来修复它:
// This works, but is easy for someone to break again
const searchOptions = useMemo(() => ({
matchMode: 'whole-word',
text: text
}), [text]);
但是,这仍然令人困惑,并且很容易被别人通过删除括号而破坏。
为了避免这种错误,请显式地编写一个 return
语句:
// ✅ This works and is explicit
const searchOptions = useMemo(() => {
return {
matchMode: 'whole-word',
text: text
};
}, [text]);
每次我的组件渲染时,useMemo
中的计算都会重新运行
确保你已将依赖项数组指定为第二个参数!
如果你忘记了依赖项数组,useMemo
会在每次渲染时都重新运行计算:
function TodoList({ todos, tab }) {
// 🔴 Recalculates every time: no dependency array
const visibleTodos = useMemo(() => filterTodos(todos, tab));
// ...
这是传递依赖项数组作为第二个参数的正确版本:
function TodoList({ todos, tab }) {
// ✅ Does not recalculate unnecessarily
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
// ...
如果这没有帮助,则说明至少有一个依赖项与之前的渲染不同。你可以通过手动将依赖项记录到控制台来调试此问题:
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
console.log([todos, tab]);
然后,你可以右键单击控制台中来自不同渲染的数组,并为它们选择“存储为全局变量”。假设第一个数组保存为 temp1
,第二个数组保存为 temp2
,然后你可以使用浏览器控制台检查两个数组中的每个依赖项是否相同:
Object.is(temp1[0], temp2[0]); // Is the first dependency the same between the arrays?
Object.is(temp1[1], temp2[1]); // Is the second dependency the same between the arrays?
Object.is(temp1[2], temp2[2]); // ... and so on for every dependency ...
当你找到哪个依赖项破坏了记忆化时,要么找到一种方法删除它,要么也记忆化它。
我需要在循环中为每个列表项调用 useMemo
,但不允许这样做
假设 Chart
组件被包裹在 memo
中。当 ReportList
组件重新渲染时,你想跳过列表中每个 Chart
的重新渲染。但是,你不能在循环中调用 useMemo
:
function ReportList({ items }) {
return (
<article>
{items.map(item => {
// 🔴 You can't call useMemo in a loop like this:
const data = useMemo(() => calculateReport(item), [item]);
return (
<figure key={item.id}>
<Chart data={data} />
</figure>
);
})}
</article>
);
}
相反,为每个项提取一个组件,并记忆化单个项的数据:
function ReportList({ items }) {
return (
<article>
{items.map(item =>
<Report key={item.id} item={item} />
)}
</article>
);
}
function Report({ item }) {
// ✅ Call useMemo at the top level:
const data = useMemo(() => calculateReport(item), [item]);
return (
<figure>
<Chart data={data} />
</figure>
);
}
或者,你可以删除 useMemo
,而是在 memo
中包裹 Report
本身。如果 item
prop 没有改变,Report
将跳过重新渲染,因此 Chart
也将跳过重新渲染:
function ReportList({ items }) {
// ...
}
const Report = memo(function Report({ item }) {
const data = calculateReport(item);
return (
<figure>
<Chart data={data} />
</figure>
);
});