![](https://img.haomeiwen.com/i10024246/178d7a6aa3bb5dd6.png)
如果你一直在努力理解
useMemo
和 useCallback
,你并不孤单!我已经和很多 React 开发者聊过,他们都对这两个钩子感到困惑。
在这篇博文中,我的目标是澄清这些混乱。我们将学习它们的功能、为什么有用以及如何最大限度地利用它们。
基本概念
好了,我们从 useMemo
开始。
useMemo
的基本思想是,它允许我们在渲染之间“记住”计算过的值。
这个定义需要进一步解释。实际上,它要求我们对 React 的工作机制有一个相当复杂的心理模型!所以我们先来解决这个问题。
React 的主要功能是使我们的 UI 与应用程序状态保持同步。它使用的工具叫做“重新渲染”。
每次重新渲染都是应用程序在某一时刻基于当前状态的 UI 快照。我们可以把它想象成一堆照片,每张照片捕捉到每个状态变量在特定值下的样子。
![](https://img.haomeiwen.com/i10024246/1b145ccdedaf4282.png)
每次“重新渲染”会生成当前状态下 DOM 应该呈现的样子。在上面的演示中,它显示为 HTML,但实际上是一些 JavaScript 对象。如果你听说过“虚拟 DOM”这个词,就是在说这个概念。
我们不会直接告诉 React 哪些 DOM 节点需要更改。相反,我们告诉 React,基于当前状态,UI 应该是什么样子。通过重新渲染,React 创建了一个新快照,并通过比较快照,像玩“找不同”游戏一样,找出需要更改的地方。
React 本身经过了高度优化,因此一般来说,重新渲染并不是什么大问题。但是在某些情况下,生成这些快照可能需要一段时间。这会导致性能问题,例如用户执行操作后 UI 无法及时更新。
从根本上说,useMemo
和 useCallback
是帮助我们优化重新渲染的工具。它们通过两种方式做到这一点:
- 减少在给定渲染中需要完成的工作量。
- 减少组件需要重新渲染的次数。
我们一个一个来讨论这些策略。
用例 1:繁重的计算
假设我们正在构建一个工具,帮助用户找到所有介于 0 和 selectedNum
(用户提供的值)之间的素数。素数是指只能被 1 和它本身整除的数字,例如 17。
这是一个可能的实现:
import React from 'react';
function App() {
// 我们将用户选择的数字保存在状态中。
const [selectedNum, setSelectedNum] = React.useState(100);
// 计算 0 到用户选择的数字 `selectedNum` 之间的所有素数:
const allPrimes = [];
for (let counter = 2; counter < selectedNum; counter++) {
if (isPrime(counter)) {
allPrimes.push(counter);
}
}
return (
<>
<form>
<label htmlFor="num">你的数字:</label>
<input
type="number"
value={selectedNum}
onChange={(event) => {
// 为了防止计算机崩溃,我们将最大值设置为 100k
let num = Math.min(100_000, Number(event.target.value));
setSelectedNum(num);
}}
/>
</form>
<p>
在 1 和 {selectedNum} 之间有 {allPrimes.length} 个素数:
<span className="prime-list">
{allPrimes.join(', ')}
</span>
</p>
</>
);
}
// 判断给定数字是否为素数的辅助函数。
function isPrime(n) {
const max = Math.ceil(Math.sqrt(n));
if (n === 2) {
return true;
}
for (let counter = 2; counter <= max; counter++) {
if (n % counter === 0) {
return false;
}
}
return true;
}
export default App;
我不希望你逐行阅读代码,所以这里是关键点:
- 我们有一个状态变量
selectedNum
,保存用户选择的数字。 - 使用
for
循环,我们手动计算 0 到selectedNum
之间的所有素数。 - 渲染一个受控的数字输入框,让用户可以更改
selectedNum
。 - 展示我们计算出的所有素数。
这段代码需要进行大量计算。如果用户选择了一个较大的 selectedNum
,我们将需要遍历数万个数字,检查它们是否是素数。即使有更高效的素数检查算法,这种计算始终会消耗大量资源。
我们确实需要在某些时候执行这个计算,比如当用户选择了一个新的 selectedNum
时。但如果我们不必要地重复这些工作,就可能会遇到性能问题。
例如,假设我们的示例还包含一个数字时钟:
import React from 'react';
const allPrimes = [];
for (let counter = 2; counter < selectedNum; counter++) {
if (isPrime(counter)) {
allPrimes.push(counter);
}
}
return (
<>
<p className="clock">
{format(time, 'hh:mm:ss a')}
</p>
<form>
<label htmlFor="num">你的数字:</label>
<input
type="number"
value={selectedNum}
onChange={(event) => {
let num = Math.min(100_000, Number(event.target.value));
setSelectedNum(num);
}}
/>
</form>
<p>
在 1 和 {selectedNum} 之间有 {allPrimes.length} 个素数:
<span className="prime-list">
{allPrimes.join(', ')}
</span>
</p>
</>
);
}
function useTime() {
const [time, setTime] = React.useState(new Date());
React.useEffect(() => {
const intervalId = window.setInterval(() => {
setTime(new Date());
}, 1000);
return () => {
window.clearInterval(intervalId);
}
}, []);
return time;
}
function isPrime(n) {
const max = Math.ceil(Math.sqrt(n));
if (n === 2) {
return true;
}
for (let counter = 2; counter <= max; counter++) {
if (n % counter === 0) {
return false;
}
}
return true;
}
export default App;
我们的应用程序现在有两个状态变量:selectedNum
和 time
。每秒,time
变量都会更新一次,以反映当前时间,并使用该值在右上角渲染一个数字时钟。
问题是:每当这两个状态变量中的任何一个发生变化时,我们都会重新执行所有昂贵的素数计算。而且由于时间每秒都会变化,这意味着即使用户没有更改数字,我们也在不断地重新生成素数列表!
![](https://img.haomeiwen.com/i10024246/d7fa6d8f5879f8ce.png)
在 JavaScript 中,我们只有一个主线程,通过每秒反复运行这段代码,使其非常繁忙。这意味着当用户尝试执行其他操作时,应用程序可能会感觉到缓慢,特别是在低端设备上。
但如果我们能“跳过”这些计算呢?如果我们已经拥有某个数字的素数列表,为什么不重复使用这个值,而是每次都从头开始计算?
这正是 useMemo
所允许我们做的。来看一下它的用法:
const allPrimes = React.useMemo(() => {
const result = [];
for (let counter = 2; counter < selectedNum; counter++) {
if (isPrime(counter)) {
result.push(counter);
}
}
return result;
}, [selectedNum]);
useMemo
接受两个参数:
- 一段需要执行的工作,封装在一个函数中。
- 一个依赖项列表。
在组件首次挂载时,React 会调用这个函数来执行所有逻辑,计算所有素数。函数的返回值会被赋给 allPrimes
变量。
对于后续的每次渲染,React 需要做一个决定:
- 是重新调用该函数以重新计算值,还是
- 复用上次计算的结果。
为了做出这个决定,React 会查看提供的依赖项列表。依赖项自上次渲染以来是否发生了变化?如果是,React 将重新运行函数来计算新值。否则,它将跳过所有这些工作,并复用之前计算的值。
useMemo
本质上像一个小型缓存,依赖项则是缓存失效策略。
在这种情况下,我们的意思是:“仅当 selectedNum
发生变化时才重新计算素数列表。”当组件由于其他原因重新渲染(例如 time
状态变量的变化)时,useMemo
会忽略函数并传递缓存的值。
这种技术通常被称为“记忆化”,这也是该钩子被称为 useMemo
的原因。
以下是该解决方案的一个在线版本:
import React from 'react';
import format from 'date-fns/format';
function App() {
const [selectedNum, setSelectedNum] = React.useState(100);
const time = useTime();
const allPrimes = React.useMemo(() => {
const result = [];
for (let counter = 2; counter < selectedNum; counter++) {
if (isPrime(counter)) {
result.push(counter);
}
}
return result;
}, [selectedNum]);
return (
<>
<p className="clock">
{format(time, 'hh:mm:ss a')}
</p>
<form>
<label htmlFor="num">你的数字:</label>
<input
type="number"
value={selectedNum}
onChange={(event) => {
let num = Math.min(100_000, Number(event.target.value));
setSelectedNum(num);
}}
/>
</form>
<p>
在 1 和 {selectedNum} 之间有 {allPrimes.length} 个素数:
<span className="prime-list">
{allPrimes.join(', ')}
</span>
</p>
</>
);
}
function useTime() {
const [time, setTime] = React.useState(new Date());
React.useEffect(() => {
const intervalId = window.setInterval(() => {
setTime(new Date());
}, 1000);
return () => {
window.clearInterval(intervalId);
}
}, []);
return time;
}
function isPrime(n) {
const max = Math.ceil(Math.sqrt(n));
if (n === 2) {
return true;
}
for (let counter = 2; counter <= max; counter++) {
if (n % counter === 0) {
return false;
}
}
return true;
}
export default App;
另一种方法
所以,useMemo
确实可以帮助我们避免不必要的计算……但它真的是这里的最佳解决方案吗?
我们经常可以通过重新组织应用程序的结构来避免使用 useMemo
。
以下是我们可以做的一种方法:
import React from 'react';
import Clock from './Clock';
import PrimeCalculator from './PrimeCalculator';
function App() {
return (
<>
<Clock />
<PrimeCalculator />
</>
);
}
export default App;
我将两个新组件 Clock
和 PrimeCalculator
提取出来。通过从 App
中分离出来,这两个组件各自管理自己的状态。一个组件的重新渲染不会影响另一个组件。
这是显示这种动态关系的图表。每个框代表一个组件实例,当它们重新渲染时会闪烁。可以尝试点击“Increment”按钮来查看效果:
![](https://img.haomeiwen.com/i10024246/2b2aac843cdb7739.png)
我们常常听到“提升状态”的说法,但有时候,更好的方法是“下推状态”! 每个组件应该有单一的责任,而在上述示例中,
App
做了两件完全无关的事情。
当然,这并不总是一个选择。在一个大型的真实应用程序中,有许多状态需要提升到较高的层级,不能被下推。
我在这种情况下还有另一个技巧。
让我们看一个例子。假设我们需要将 time
变量提升到 PrimeCalculator
之上:
import React from 'react';
import { getHours } from 'date-fns';
import Clock from './Clock';
import PrimeCalculator from './PrimeCalculator';
// 将 PrimeCalculator 转换为纯组件:
const PurePrimeCalculator = React.memo(PrimeCalculator);
function App() {
const time = useTime();
// 根据时间选择合适的背景色:
const backgroundColor = getBackgroundColorFromTime(time);
return (
<div style={{ backgroundColor }}>
<Clock time={time} />
<PurePrimeCalculator />
</div>
);
}
const getBackgroundColorFromTime = (time) => {
const hours = getHours(time);
if (hours < 12) {
// 早晨使用浅黄色
return 'hsl(50deg 100% 90%)';
} else if (hours < 18) {
// 下午使用暗蓝色
return 'hsl(220deg 60% 92%)'
} else {
// 晚上使用深蓝色
return 'hsl(220deg 100% 80%)';
}
}
function useTime() {
const [time, setTime] = React.useState(new Date());
React.useEffect(() => {
const intervalId = window.setInterval(() => {
setTime(new Date());
}, 1000);
return () => {
window.clearInterval(intervalId);
}
}, []);
return time;
}
更新图表
这里是更新后的图表,显示发生了什么:
![](https://img.haomeiwen.com/i10024246/77c217a7ca5c178b.png)
像一个力场一样,React.memo
将我们的组件包裹起来,保护它不受无关更新的影响。我们的 PurePrimeCalculator
仅在接收到新数据或其内部状态更改时重新渲染。
这被称为纯组件。基本上,我们是在告诉 React,这个组件在相同输入下总会产生相同的输出,我们可以跳过没有变化的重新渲染。
我在最近的博文《为什么 React 会重新渲染》中详细讨论了 React.memo
的工作原理。
更传统的方法
在上述示例中,我将React.memo
应用到了导入的PrimeCalculator
组件上。
实际上,这有点不寻常。我选择这样结构化,以便在同一个文件中看到所有内容,从而更容易理解。
在实际应用中,我通常将React.memo
应用于组件导出,如下所示:// PrimeCalculator.js function PrimeCalculator() { /* 组件逻辑 */ } export default React.memo(PrimeCalculator);
现在我们的
PrimeCalculator
组件将始终是纯的,而我们在使用它时不必进行调整。
如果我们需要非纯版本的PrimeCalculator
,我们可以将底层组件作为命名导出。我认为我从未需要这样做。
这里有一个有趣的观点转变:之前我们是对特定计算的结果进行记忆化,计算素数。然而在这个例子中,我对整个组件进行了记忆化。
无论哪种方式,昂贵的计算工作只会在用户选择新的 selectedNum
时重新运行。但我们优化了父组件,而不是特定的慢代码行。
我并不是说一种方法比另一种更好;每种工具都有其在工具箱中的位置。但在这个特定的案例中,我更喜欢这种方法。
现在,如果你曾经尝试在真实环境中使用纯组件,你可能注意到一个奇特的现象:即使看起来没有变化,纯组件通常也会重新渲染很多次!😬
这为我们引出了 useMemo
解决的第二个问题。
更多替代方案
在他的博文“在你使用 memo() 之前”,Dan Abramov 分享了另一种基于使用子组件重构应用程序的方法,以避免需要进行任何记忆化。
感谢 Yuval Shimoni 提出这一点!
使用案例 2:保留引用
在下面的示例中,我创建了一个 Boxes
组件。它显示了一组多彩的盒子,供某种装饰用途使用。
我还有一个不相关的状态,即用户的姓名。
import React from 'react';
import Boxes from './Boxes';
function App() {
const [name, setName] = React.useState('');
const [boxWidth, setBoxWidth] = React.useState(1);
const id = React.useId();
// 尝试改变其中一些值!
const boxes = [
{
flex: boxWidth,
background: 'hsl(345deg 100% 50%)',
},
{
flex: 3,
background: 'hsl(260deg 100% 40%)',
},
{
flex: 1,
background: 'hsl(50deg 100% 60%)',
},
];
return (
<>
<Boxes boxes={boxes} />
<section>
<div className="row">
<label htmlFor={`${id}-name`}>
Name:
</label>
<input
id={`${id}-name`}
type="text"
value={name}
onChange={(event) => {
setName(event.target.value);
}}
/>
</div>
<label htmlFor={`${id}-box-width`}>
First box width:
</label>
<input
id={`${id}-box-width`}
type="range"
min={1}
max={5}
step={0.01}
value={boxWidth}
onChange={(event) => {
setBoxWidth(Number(event.target.value));
}}
/>
</section>
</>
);
}
export default App;
Boxes
是一个纯组件,因为它在 Boxes.js
中用 React.memo()
包装的默认导出。这意味着它应该仅在其 props 发生变化时重新渲染。
然而,每当用户更改他们的名字时,Boxes
也会重新渲染!
这里有一个图表显示了这种动态。尝试在文本输入框中输入内容,注意两个组件是如何重新渲染的:
![](https://img.haomeiwen.com/i10024246/302d2c3778166e30.png)
这是怎么回事?为什么我们的
React.memo()
在这里没有保护我们?
Boxes
组件只有一个 prop:boxes
,并且看起来我们在每次渲染时都给它提供了相同的数据。它总是相同的:一个红色的盒子,一个宽宽的紫色盒子,一个黄色的盒子。我们确实有一个影响 boxes
数组的 boxWidth
状态变量,但我们并没有改变它!
问题在于:每次 React 重新渲染时,我们都在生成一个全新的数组。它们在值上是等价的,但在引用上却不是。
我认为如果我们先忘掉 React,聊聊普通的 JavaScript 会有帮助。让我们看一个类似的情况:
function getNumbers() {
return [1, 2, 3];
}
const firstResult = getNumbers();
const secondResult = getNumbers();
console.log(firstResult === secondResult);
你觉得呢?firstResult
是否等于 secondResult
?
从某种意义上说,它们是相等的。两个变量持有相同的结构 [1, 2, 3]
。但这并不是 ===
运算符实际上在检查的内容。
相反,===
正在检查两个表达式是否是同一个东西。
我们创建了两个不同的数组。它们可能包含相同的内容,但它们不是同一个数组,就像两个相同的双胞胎不是同一个人一样。
![](https://img.haomeiwen.com/i10024246/f808064232cb21f1.png)
每次我们调用 getNumbers
函数时,我们都会创建一个全新的数组,计算机内存中持有的独特事物。如果我们多次调用它,我们将在内存中存储多个该数组的副本。
注意,简单数据类型(如字符串、数字和布尔值)可以通过值进行比较。但对于数组和对象,它们只能通过引用进行比较。有关这种区别的更多信息,请查看 Dave Ceddia 的精彩博文:JavaScript 中引用的视觉指南。
回到 React: 我们的 Boxes
React 组件也是一个 JavaScript 函数。当我们渲染它时,我们调用该函数:
// 每次我们渲染这个组件时,我们调用这个函数...
function App() {
// ...并生成一个全新的数组...
const boxes = [
{ flex: boxWidth, background: 'hsl(345deg 100% 50%)' },
{ flex: 3, background: 'hsl(260deg 100% 40%)' },
{ flex: 1, background: 'hsl(50deg 100% 60%)' },
];
// ...然后将其作为 prop 传递给该组件!
return (
<Boxes boxes={boxes} />
);
}
当 name
状态改变时,我们的 App
组件重新渲染,这会重新运行所有代码。我们构建了一个全新的 boxes
数组,并将其传递给 Boxes
组件。
而 Boxes
会重新渲染,因为我们给了它一个全新的数组!
盒子数组的结构在渲染之间没有改变,但这并不相关。React 所知道的就是 boxes
prop 收到了一个新创建的、前所未见的数组。
为了解决这个问题,我们可以使用 useMemo
钩子:
const boxes = React.useMemo(() => {
return [
{ flex: boxWidth, background: 'hsl(345deg 100% 50%)' },
{ flex: 3, background: 'hsl(260deg 100% 40%)' },
{ flex: 1, background: 'hsl(50deg 100% 60%)' },
];
}, [boxWidth]);
与之前看到的素数示例不同,我们在这里并不担心计算开销。我们的唯一目标是保留对特定数组的引用。
我们将 boxWidth
列为依赖,因为我们希望 Boxes
组件在用户调整红色盒子的宽度时重新渲染。
我认为一个快速的草图将有助于说明。在之前的情况下,我们每次都会创建一个全新的数组,作为每个快照的一部分:
![](https://img.haomeiwen.com/i10024246/9359c308d30beafd.png)
然而,通过 useMemo
,我们可以重用先前创建的 boxes
数组:
![](https://img.haomeiwen.com/i10024246/8b285dfaf398d884.png)
通过在多个渲染中保留相同的引用,我们允许纯组件按我们希望的方式运行,忽略不影响用户界面的渲染。
useCallback
钩子
好吧,那么我们已经涵盖了 useMemo
……那 useCallback
呢?
简而言之:它与 useMemo
完全相同,但用于函数而不是数组/对象。
与数组和对象类似,函数也是通过引用进行比较,而不是通过值:
const functionOne = function() {
return 5;
};
const functionTwo = function() {
return 5;
};
console.log(functionOne === functionTwo); // false
这意味着,如果我们在组件内部定义一个函数,它将在每次渲染时重新生成,产生一个相同但唯一的函数。
让我们看一个例子:
import React from 'react';
import MegaBoost from './MegaBoost';
function App() {
const [count, setCount] = React.useState(0);
function handleMegaBoost() {
setCount((currentValue) => currentValue + 1234);
}
return (
<>
Count: {count}
<button onClick={() => setCount(count + 1)}>
Click me!
</button>
<MegaBoost handleClick={handleMegaBoost} />
</>
);
}
export default App;
这个沙盒描绘了一个典型的计数器应用程序,但有一个特殊的“Mega Boost”按钮。这个按钮可以大幅增加计数,以防你赶时间,不想多次点击标准按钮。
MegaBoost
组件是一个纯组件,得益于 React.memo
。它不依赖于 count
……但每当 count
改变时,它都会重新渲染!
和我们看到的 boxes
数组一样,这里问题在于我们在每次渲染时都在生成一个全新的函数。如果我们渲染 3 次,我们将创建 3 个独立的 handleMegaBoost
函数,突破 React.memo
的保护。
使用我们关于 useMemo
学到的知识,我们可以这样解决问题:
const handleMegaBoost = React.useMemo(() => {
return function() {
setCount((currentValue) => currentValue + 1234);
}
}, []);
我们不是返回一个数组,而是返回一个函数。这个函数然后被存储在 handleMegaBoost
变量中。
这样可以工作……但还有更好的方法:
const handleMegaBoost = React.useCallback(() => {
setCount((currentValue) => currentValue + 1234);
}, []);
useCallback
与 useMemo
的作用相同,但专门为函数构建。我们直接将一个函数传递给它,它会将该函数进行记忆,以便在渲染之间进行传递。
换句话说,这两个表达式具有相同的效果:
// 这个:
React.useCallback(function helloWorld(){}, []);
// ...在功能上等同于这个:
React.useMemo(() => function helloWorld(){}, []);
useCallback
是语法糖。它纯粹是为了在尝试记忆回调函数时让我们的生活变得更加轻松。
何时使用这些钩子
好吧,我们已经看到 useMemo
和 useCallback
如何允许我们在多个渲染中传递引用,以重用复杂的计算或避免打破纯组件。问题是:我们应该多频繁使用它?
在我个人看来,把每一个对象/数组/函数都包裹在这些钩子中是浪费时间。大多数情况下,收益微不足道;React 的优化非常好,重新渲染往往并没有我们想象的那么慢或昂贵!
使用这些钩子的最佳方法是针对具体问题。如果你注意到你的应用变得有点迟缓,可以使用 React Profiler 来查找缓慢的渲染。在某些情况下,你能够通过重构应用来提高性能。在其他情况下,useMemo
和 useCallback
可以帮助加速。
(如果你不确定如何使用 React Profiler,我在最近的博客文章《为什么 React 会重新渲染》中进行了详细介绍!)
尽管如此,有几个场景我确实会预先应用这些钩子。
这可能会在未来发生变化!
React 团队正在积极调查在编译阶段是否可能“自动记忆”代码。这仍处于研究阶段,但早期实验结果似乎很有希望。
在未来,所有这些事情都可能会自动为我们完成。不过在那之前,我们仍然需要自己进行优化。
有关更多信息,请查看 Xuan Huang 的讲座《没有记忆的 React》。
通用自定义钩子内部
我最喜欢的小自定义钩子之一是 useToggle
,这是一个友好的助手,几乎与 useState
完全相同,但只能在 true
和 false
之间切换状态变量:
function App() {
const [isDarkMode, toggleDarkMode] = useToggle(false);
return (
<button onClick={toggleDarkMode}>
Toggle color theme
</button>
);
}
这是如何定义这个自定义钩子的:
function useToggle(initialValue) {
const [value, setValue] = React.useState(initialValue);
const toggle = React.useCallback(() => {
setValue(v => !v);
}, []);
return [value, toggle];
}
注意 toggle
函数是用 useCallback
进行记忆的。
当我构建像这样的自定义可重用钩子时,我喜欢尽可能使它们高效,因为我不知道它们将来会在哪里被使用。虽然在 95% 的情况下可能是过度设计,但如果我使用这个钩子 30 或 40 次,这很可能会帮助提高我的应用性能。
在上下文提供者内部
当我们使用上下文在应用程序中共享数据时,通常会将一个大对象作为 value
属性传递。
通常,记忆化这个对象是个好主意:
const AuthContext = React.createContext({});
function AuthProvider({ user, status, forgotPwLink, children }) {
const memoizedValue = React.useMemo(() => {
return {
user,
status,
forgotPwLink,
};
}, [user, status, forgotPwLink]);
return (
<AuthContext.Provider value={memoizedValue}>
{children}
</AuthContext.Provider>
);
}
为什么这样做是有益的?:可能有很多纯组件会消费这个上下文。如果不使用 useMemo
,那么当 AuthProvider
的父组件重新渲染时,所有这些组件都将被迫重新渲染。
React 的乐趣
呼!你终于到了最后。我知道这个教程涉及了一些相当复杂的内容。😅
我知道这两个钩子很棘手,React 本身可能会让人感到非常压倒和困惑。这是一个困难的工具!
但问题是:如果你能克服最初的障碍,使用 React 将是一种绝对的乐趣。
我从 2015 年开始使用 React,它已成为我构建复杂用户界面和 web 应用程序的绝对最爱。我尝试过几乎所有的 JS 框架,但我在使用它们时的生产力不如使用 React 时高。
网友评论