useMemo
是一个 React 钩子函数,让你在每次重新渲染的时候能够缓存计算的结果。
const cachedValue = useMemo(calculateValue, dependencies)
参考
useMemo(calculateValue, dependencies)
在组件的顶层调用 useContext 来缓存一个在每次重新渲染中需要计算的结果。
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 和所有你直接在组件中定义的变量和函数。如果你的代码检查工具是 为 React 配置的 ,它将会确保每一个响应式数据都被正确的定义为依赖项。依赖项数组的长度必须是固定的并且必须写成这样[dep1, dep2, dep3]
。 React将使用Object.is
将每个依赖项与其之前的值进行比较。
返回值
在初始渲染时,useMemo
返回不带参数的调用calculateValue
的结果。
在接下来的渲染中,它将返回上次渲染中已经缓存的值(如果依赖项没有改变),或者再次调用calculateValue
,并返回calculateValue
返回的结果。
注意事项
useMemo
是一个React Hook, 所以你只能 在组件的顶层 或者你自己的Hook中调用它。你不能在循环或条件语句中调用它。如果需要,可以提取一个新组件并将state移动到其中。- 在严格模式下,为了 帮你发现意外的错误 , React将会 重复调用你的 calculation 函数两次 。这只是一个开发环境下的行为并不会影响到生产环境。 如果你的 calcalation 函数是一个纯函数(它本来就应该是), 这将不会影响到你的逻辑。其中一次调用的结果将会被忽略。
- React 不会丢弃缓存的值,除非有特定的原因。 例如,在开发过程中,React会在你编辑组件文件时丢弃缓存。无论是在开发环境还是在生产环境,如果你的组件在初始挂载期间被终止,React都会丢弃缓存。 在未来,React可能会添加更多利用丢弃缓存的特性—例如, 如果React在未来增加了对虚拟化列表的内置支持,那么丢弃那些滚出虚拟化表视口的缓存是有意义的. 如果你仅仅依赖“useMemo”作为性能优化手段,是没问题的。 否则, 一个 state 变量 或者一个 ref 可能更加合适。
用法
跳过代价昂贵的重新计算
要在重新渲染之间缓存计算结果,请在组件的顶层使用 useMemo
调用将其包装起来:
import { useMemo } from 'react';
function TodoList({ todos, tab, theme }) {
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
// ...
}
你需要给 useMemo
传递两样东西:
- 一个没有任何参数的 calculation 函数 , 像这样
() =>
, 并且返回任何你想要的计算结果。 - 一个由包含在你的组件中并在 calculation 中使用的所有值组成的 依赖列表
在初次渲染时, 你从 useMemo
得到的 值 将会是你的 calculation 函数执行的结果。
在随后的每一次渲染中, React 将会比较前后两次渲染中的 所有依赖项 是否相同。 如何所有依赖项都没有发生变化 (通过 Object.is
比较), useMemo
将会返回之前已经计算过的那个值。 否则, React 将会重新执行 calculation 并且返回一个新的值。
换句话说, useMemo
在多次重新渲染中缓存了一个 calculation 结果直到依赖项的值发生变化。
让我们通过一个示例来了解这在什么情况下是有用的。
默认情况下,React会在每次重新渲染时重新运行组件的整个主体。例如,如果这个 TodoList
更新了它的状态或从它的父元素接收到新的道具,filterTodos
函数将会重新运行:
function TodoList({ todos, tab, theme }) {
const visibleTodos = filterTodos(todos, tab);
// ...
}
通常,这不是问题,因为大多数计算都非常快。但是,如果您正在过滤或转换一个大型数组,或者进行一些昂贵的计算,如果数据没有改变,您可能希望跳过再次执行该操作。如果 todos
和 tab
都与上次渲染时相同,将计算包装在 useMemo
中,就像之前那样让你重用之前已经计算过的 visibleTodos
。
这种缓存行为叫做 memoization.
深入探讨
一般来说,除非要创建或循环遍历数千个对象,否则开销可能并不大。如果您想获得更详细的信息,您可以添加一个控制台日志来测量花费在一段代码上的时间:
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); // 如果 todos 和 tab 都没有变化,那么将会跳过渲染。
}, [todos, tab]);
console.timeEnd('filter array');
useMemo
不会让 第一次 渲染更快, 它只会帮助你跳过不必要的更新工作。
请记住,您的设备可能比用户的速度更快,因此最好通过人为的降低浏览器性能来测试. 例如, Chrome 提供了一个 CPU Throttling 选项来降低浏览器性能。
另请注意,在开发环境中测量性能不会为您提供最准确的结果。(例如, 当开启 严格模式 , 你会看到每个组件渲染两次而不是一次。) 要获得最准确的时间,请构建用于生产的应用程序并在用户使用的设备上对其进行测试。
深入探讨
如果您的应用程序类似于此站点,并且大多数交互都很粗糙(例如替换页面或整个部分),则通常不需要记忆。 另一方面,如果您的应用程序更像是绘图编辑器,并且大多数交互都是颗粒状的(如移动形状),那么您可能会发现记忆非常有用。
使用 useMemo
进行优化仅在少数情况下有价值:
- 您在
useMemo
中进行的计算明显很慢,而且它的依赖关系很少改变 - 您将它作为 prop 传递给包装在
memo
中的组件。如果值没有改变,你想跳过重新渲染。 Memoization 让您的组件仅在依赖项不同时才重新渲染。 - 您传递的值稍后用作某些 Hook 的依赖项。 例如, 也许另一个 useMemo 计算值依赖它。 或者
useEffect
依赖这个值。
在其他情况下,将计算过程包装在 useMemo 中没有任何好处。 这样做也没有重大危害, 所以一些团队选择不考虑个别情况,尽可能多地使用 useMemo
。 这种方法的缺点是降低了代码的可读性。此外,并不是所有的 useMemo
的使用都是有效的:一个“永远是新的”的单一值就足以破坏整个组件的缓存效果。
在实践中,你可以通过遵循一些原则来避免 useMemo
大量无效的使用:
- 当一个组件在视觉上包裹其他组件时, 让它 将 JSX 作为子组件传递 。 这样, 当包装器组件更新自己的状态时,React 知道它的子组件不需要重新渲染。
- 首选本地状态,非必要不要进行 状态提升 。 例如, 不要保持像表单这样的瞬时状态,也不要保持项目是否悬停在树的顶部或全局状态库中。
- 保持你的 渲染逻辑纯粹 。 如果重新渲染组件导致问题或产生一些明显的视觉伪像,那么它就是组件中的错误! 修复错误而不是添加记忆。
- 避免 不必要的更新 state 的 Effects 。React 应用程序中的大多数性能问题都是由 Effects 创造的更新链引起的,这些更新链导致组件反复渲染。
- 尽力 从 Effects 中移除不必要的依赖项 。例如, 相比于 memoization ,在 Effect 内部或组件外部移动某些对象或函数通常更简单。
如果某个特定的交互仍然感觉滞后, 使用 React 开发者工具分析器 查看哪些组件将从 memoization 中获益最多,并在需要的地方添加 memoization 。 这些原则使您的组件更易于调试和理解,因此在任何情况下都应该遵循它们。从长远来看,我们正在研究 自动进行粒度记忆 以一劳永逸地解决这个问题。
第 1 个示例 共 2 个挑战: 使用 useMemo
跳过重复计算
在这个例子中, filterTodos
的执行被 人为减速了 这样您就可以看到当您在渲染期间调用的某些 JavaScript 函数确实很慢时会发生什么。 尝试切换选项卡并切换主题。
切换选项卡感觉很慢,因为它迫使减速的 filterTodos
重新执行。这是预料之中的,因为“选项卡”已更改,因此整个计算 需要 重新运行。 (如果您好奇为什么它会运行两次,此处 对此进行了解释) 。
尝试切换主题。 多亏了 useMemo
,尽管被人为减速,它还是很快的! 缓慢的 filterTodos
调用被跳过,因为 todos
和 tab
(您将其作为依赖项传递给 useMemo
)自上次渲染以来都没有改变。
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
属性会使应用程序冻结片刻,但是如果您从 JSX 中删除 <List />
,感觉会很快。 这说明尝试优化 List
组件是值得的。
默认情况下,当一个组件重新渲染时,React 会递归地重新渲染它的所有子组件。 这就是为什么当 TodoList
使用不同的 theme
重新渲染时,List
组件也会重新渲染。 这对于不需要太多计算来重新渲染的组件来说很好。 但是如果你已经确认重新渲染很慢,你可以通过将它包装在 memo
中,这样当它的 props 跟上一次渲染相同的时候它就会跳过本次渲染:
import { memo } from 'react';
const List = memo(function List({ items }) {
// ...
});
通过此更改,如果 List
的所有道具都与上次渲染时 相同 ,则 List
将跳过重新渲染。 这就是缓存计算变得重要的地方!想象一下,您在没有 useMemo
的情况下计算了 visibleTodos
:
export default function TodoList({ todos, tab, theme }) {
// 每当主题发生变化时,这将是一个不同的数组...
const visibleTodos = filterTodos(todos, tab);
return (
<div className={theme}>
{/* ... 所以List的props永远不会一样,每次都会重新渲染 */}
<List items={visibleTodos} />
</div>
);
}
在上面的示例中,filterTodos
函数总是创建一个 不同 数组, 类似于 {}
总是创建一个新对象的方式。 通常,这不是问题,但这意味着 List
属性永远不会相同,并且您的 memo
优化将不起作用。 这就是 useMemo 派上用场的地方:
export default function TodoList({ todos, tab, theme }) {
// 告诉 React 在重新渲染之间缓存你的计算结果...
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab] // ...所以只要这些依赖项不变...
);
return (
<div className={theme}>
{/* ... List 也就会接受到相同的 props 并且会跳过重新渲染 */}
<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
跳过了重新渲染,因为 visibleItems
数组从上一次渲染之后就没有发生改变。 visibleItems
数组没有改变,是因为 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 的依赖
假设您有一个 calculation 依赖于直接在组件主体中创建的对象:
function Dropdown({ allItems, text }) {
const searchOptions = { matchMode: 'whole-word', text };
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // 🚩 提醒: 依赖于在组件主体中创建的对象
// ...
依赖这样的对象会破坏 memoization 。 当组件重新渲染时,组件主体内的所有代码都会再次运行。 创建 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]); // ✅ 只有当 allItems 或者 text 改变的时候才会重新计算
// ...
现在您的计算直接取决于 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
组件被记忆了,大概你想在没有 props 改变时跳过重新渲染它。 总是 不同的 prop 会破坏你的 memoization 。
要使用 useMemo 记忆函数,您的计算函数必须返回另一个函数:
export default function Page({ productId, referrer }) {
const handleSubmit = useMemo(() => {
return (orderDetails) => {
post('/product/' + product.id + '/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/' + product.id + '/buy', {
referrer,
orderDetails
});
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
上面两个例子是完全等价的。 useCallback
的唯一好处是它可以让您避免在内部编写额外的嵌套函数。 它没有做任何其他事情。 阅读更多关于 useCallback
的内容。
故障排除
我的计算函数在每次重新渲染时运行两次
在 严格模式 中,React 将调用你的某些函数两次而不是一次:
function TodoList({ todos, tab }) {
// 此组件函数将为每个渲染运行两次。
const visibleTodos = useMemo(() => {
// 如果任何依赖项发生更改,此计算将运行两次。
return filterTodos(todos, tab);
}, [todos, tab]);
// ...
这是符合预期的,不应对您的代码逻辑产生影响。
这种 仅限开发环境下的 行为可帮助您 保持组件纯净 。 React 使用其中一次调用的结果,而忽略另一次的结果。 只要您的组件和计算函数是纯函数,这就不会影响您的逻辑。 但是,如果你不小心写出带有副作用的代码,这可以帮助您发现并纠正错误。
例如, 这个不纯的计算函数会改变你作为 prop 收到的数组:
const visibleTodos = useMemo(() => {
// 🚩 错误: 改变了 prop
todos.push({ id: 'last', text: 'Go for a walk!' });
const filtered = filterTodos(todos, tab);
return filtered;
}, [todos, tab]);
React 调用你的函数两次,所以你会注意到 todo 被添加了两次。 您的计算不应更改任何现有对象,但可以更改您在计算期间创建的任何 新 对象。 例如,如果 filterTodos
函数总是返回一个 不同 数组,您可以改为改变 那个 数组:
const visibleTodos = useMemo(() => {
const filtered = filterTodos(todos, tab);
// ✅ 正确: 改变你在计算过程中创建的对象
filtered.push({ id: 'last', text: 'Go for a walk!' });
return filtered;
}, [todos, tab]);
阅读 保持组件纯粹 以了解有关纯粹组件的更多信息。
此外,请查看有关不通过对象或者数组的可变性直接 更新对象 和 更新数组 的指南。
我的 useMemo
调用应该返回一个对象,但返回 undefined
这段代码不起作用:
// 🔴 你不能像这样 `() => {` 在箭头函数中直接返回一个对象
const searchOptions = useMemo(() => {
matchMode: 'whole-word',
text: text
}, [text]);
在 JavaScript 中,() => {
是箭头函数体的开始标志,因此 {
大括号不是对象的一部分。 这就是它不返回对象并导致错误的原因。 你可以通过添加像 ({
and })
这样的括号来修复它:
// 这行得通,但很容易有人再次破坏
const searchOptions = useMemo(() => ({
matchMode: 'whole-word',
text: text
}), [text]);
然而,这仍然令人困惑,而且对于某些人来说,通过移除括号来破解它太容易了。
为避免此错误,请显式编写 return
语句:
// ✅ 这有效并且是明确的
const searchOptions = useMemo(() => {
return {
matchMode: 'whole-word',
text: text
};
}, [text]);
每次我的组件渲染时,useMemo
中的计算都会重新运行
确保您已将依赖项数组指定为第二个参数!
如果您忘记了依赖数组,useMemo
将每次重新运行计算:
function TodoList({ todos, tab }) {
// 🔴 每次都重新计算:没有依赖数组
const visibleTodos = useMemo(() => filterTodos(todos, tab));
// ...
这是将依赖项数组作为第二个参数传递的更正版本:
function TodoList({ todos, tab }) {
// ✅ 不会不必要地重新计算
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]); // 数组之间的第一个依赖项是否相同?
Object.is(temp1[1], temp2[1]); // 数组之间的第二个依赖项是否相同?
Object.is(temp1[2], temp2[2]); // ... 依此类推 ...
当您发现哪个依赖项破坏了 memoization 时,要么找到一种方法将其删除,要么 也对其进行记忆。
我需要为循环中的每个列表项调用 useMemo
,但这是不允许的
假设 Chart
组件被包裹在 memo
中。 当 ReportList
组件重新呈现时,您想跳过重新呈现列表中的每个 Chart
。 但是,您不能在循环中调用 useMemo
:
function ReportList({ items }) {
return (
<article>
{items.map(item => {
// 🔴 您不能像这样在循环中调用 useMemo:
const data = useMemo(() => calculateReport(item), [item]);
return (
<figure key={item.id}>
<Chart data={data} />
</figure>
);
})}
</article>
);
}
相反,为每个 item 提取一个组件并为单个 item 记忆数据:
function ReportList({ items }) {
return (
<article>
{items.map(item =>
<Report key={item.id} item={item} />
)}
</article>
);
}
function Report({ item }) {
// ✅ 在顶层调用 useMemo:
const data = useMemo(() => calculateReport(item), [item]);
return (
<figure>
<Chart data={data} />
</figure>
);
}
或者,您可以删除 useMemo
并将 Report
本身包装在 memo
中。 如果 item
prop 没有改变,Report
将跳过重新渲染,因此 Chart
也会跳过重新渲染:
function ReportList({ items }) {
// ...
}
const Report = memo(function Report({ item }) {
const data = calculateReport(item);
return (
<figure>
<Chart data={data} />
</figure>
);
});