简介
如果你对useMemo
和useCallback
感到困惑,那么你并不孤单!我和许多React开发者交流过,他们对这两个钩子感到困惑。我和很多React开发者交流过,他们都对这两个钩子感到头疼。
我在这篇博文中的目标是澄清所有这些困惑。我们将学习它们的作用,为什么它们是有用的,以及如何最大限度地利用它们。
(#the-basic-idea)基本理念
好吧,让我们从useMemo
开始。
useMemo
的基本思想是,它允许我们在渲染之间*"记住 "*一个计算值。
这个定义需要进行一些解读。事实上,它需要一个关于React如何工作的相当复杂的心理模型!所以让我们先解决这个问题。所以让我们先解决这个问题。
React所做的主要事情是让我们的用户界面与我们的应用程序状态保持同步。它用来做这件事的工具被称为 "重新渲染"。
每次重新渲染都是基于当前应用状态,对应用的用户界面在某一时刻应该是什么样子的一个快照。我们可以把它想象成一叠照片,每张照片都记录了在每个状态变量的特定值下事物的样子。
每一次 "重新渲染 "都会产生一个基于当前状态的DOM的心理图片。在上面的小演示中,它被描绘成HTML,但实际上它是一堆JS对象。这有时被称为*"虚拟DOM",*如果你听说过这个术语的话。
我们不直接告诉React哪些DOM节点需要改变。相反,我们告诉React基于当前状态的UI应该是什么。通过重新渲染,React创建了一个新的快照,它可以通过比较快照找出需要改变的地方,就像玩一个 "找不同 "的游戏。
React开箱后就进行了大量的优化,所以一般来说,重新渲染并不是什么大问题。但是,在某些情况下,这些快照确实需要一段时间来创建。这可能会导致性能问题,比如在用户执行完一个动作后,用户界面更新得不够快。
从根本上说,useMemo
和useCallback
是用来帮助我们优化重现的工具。它们通过两种方式做到这一点。
-
减少在一次渲染中需要完成的工作量。
-
减少一个组件需要重新渲染的次数。
(#use-case-1-heavy-computations)用例1:重型计算
假设我们正在构建一个工具,帮助用户找到0和selectedNum
之间的所有素数,其中selectedNum
是用户提供的数值。一个质数是一个只能被1和它自己整除的数字,比如17。
下面是一个可能的实现。
Code Playground
import React from 'react';
function App() {
// We hold the user's selected number in state.
const [selectedNum, setSelectedNum] = React.useState(100);
// We calculate all of the prime numbers between 0 and the
// user's chosen number, `selectedNum`:
const allPrimes = [];
for (let counter = 2; counter < selectedNum; counter++) {
if (isPrime(counter)) {
allPrimes.push(counter);
}
}
return (
<>
<form>
<label htmlFor="num">Your number:</label>
<input
type="number"
value={selectedNum}
onChange={(event) => {
// To prevent computers from exploding,
// we'll max out at 100k
let num = Math.min(100_000, Number(event.target.value));
setSelectedNum(num);
}}
/>
</form>
<p>
There are {allPrimes.length} prime(s) between 1 and {selectedNum}:
{' '}
<span className="prime-list">
{allPrimes.join(', ')}
</span>
</p>
</>
);
}
// Helper function that calculates whether a given
// number is prime or not.
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
。但是,如果我们在不需要的时候无偿地进行这种工作,就有可能遇到一些性能问题。
例如,让我们假设我们的例子也有一个数字时钟。
Code Playground
import React from 'react';
import format from 'date-fns/format';
function App() {
const [selectedNum, setSelectedNum] = React.useState(100);
// `time` is a state variable that changes once per second,
// so that it's always in sync with the current time.
const time = useTime();
// Calculate all of the prime numbers.
// (Unchanged from the earlier example.)
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">Your number:</label>
<input
type="number"
value={selectedNum}
onChange={(event) => {
// To prevent computers from exploding,
// we'll max out at 100k
let num = Math.min(100_000, Number(event.target.value));
setSelectedNum(num);
}}
/>
</form>
<p>
There are {allPrimes.length} prime(s) between 1 and {selectedNum}:
{' '}
<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
变量被更新以反映当前的时间,这个值被用来在右上角呈现一个数字时钟。
**问题就在这里:**每当这些状态变量发生变化时,我们都要重新进行那些昂贵的初值计算。因为time
,这意味着我们要不断地重新生成素数列表,即使用户选择的数字没有变化
在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
忽略了这个函数,并将缓存的值传递给它。
这通常被称为记忆化,这就是为什么这个钩子被称为 "使用记忆"。
下面是这个解决方案的一个实时版本。
Code Playground
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">Your number:</label>
<input
type="number"
value={selectedNum}
onChange={(event) => {
// To prevent computers from exploding,
// we'll max out at 100k
let num = Math.min(100_000, Number(event.target.value));
setSelectedNum(num);
}}
/>
</form>
<p>
There are {allPrimes.length} prime(s) between 1 and {selectedNum}:
{' '}
<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;
(#an-alternative-approach)一种替代方法
所以,useMemo
钩子确实可以帮助我们在这里避免不必要的计算......但它真的是这里的最佳解决方案吗?
通常情况下,我们可以通过调整应用程序中的结构来避免对useMemo
。
下面是我们可以做的一个方法。
Code Playground
import React from 'react';
import Clock from './Clock';
import PrimeCalculator from './PrimeCalculator';
function App() {
return (
<>
<Clock />
<PrimeCalculator />
</>
);
}
export default App;
我已经提取了两个新的组件,Clock
和PrimeCalculator
。通过从App
分支,这两个组件各自管理自己的状态。一个组件的重新渲染不会影响另一个。
下面是一个显示这一动态的图表。每个盒子代表一个组件实例,当它们重新渲染时,它们会闪烁。试着点击 "增量 "按钮来看看它的作用。
我们听到很多关于提升状态的说法,但有时,更好的方法是将状态往下推每个组件都应该有一个单一的责任,在上面的例子中,App
,做着两件完全不相关的事情。
现在,这并不总是一种选择。在一个大型的、真实世界的应用程序中,有很多状态需要被提升到很高的位置,而不能被推下去。
对于这种情况,我还有一个小技巧。
让我们看一个例子。假设我们需要把time
这个变量抬高,在PrimeCalculator
上面。
Code Playground
import React from 'react';
import { getHours } from 'date-fns';
import Clock from './Clock';
import PrimeCalculator from './PrimeCalculator';
// Transform our PrimeCalculator into a pure component:
const PurePrimeCalculator = React.memo(PrimeCalculator);
function App() {
const time = useTime();
// Come up with a suitable background color,
// based on the time of day:
const backgroundColor = getBackgroundColorFromTime(time);
return (
<div style={{ backgroundColor }}>
<Clock time={time} />
<PurePrimeCalculator />
</div>
);
}
const getBackgroundColorFromTime = (time) => {
const hours = getHours(time);
if (hours < 12) {
// A light yellow for mornings
return 'hsl(50deg 100% 90%)';
} else if (hours < 18) {
// Dull blue in the afternoon
return 'hsl(220deg 60% 92%)'
} else {
// Deeper blue at night
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;
}
export default App;
这里有一个更新的图表,显示了这里发生的情况。
就像一个力场,React.memo
包裹着我们的组件,保护它不受无关的更新影响。我们的PurePrimeCalculator
,只有当它收到新的数据,或者它的内部状态发生变化时,才会重新渲染。
这就是所谓的纯组件。从本质上讲,我们告诉React,这个组件在相同的输入下将总是产生相同的输出,我们可以跳过没有变化的重新渲染。
我在最近的博文"Why React Re-Renders "中详细介绍了React.memo
。
这里有一个有趣的观点转变。之前,我们是在记忆一个特定的计算结果,计算素数。然而,在这种情况下,我把整个组件都备忘了。
不管怎么说,昂贵的计算结果只有在用户选择一个新的selectedNum
,才会重新运行。但我们优化的是父组件,而不是特定的慢速代码行。
我并不是说一种方法比另一种方法好;每种工具在工具箱中都有其位置。但在这个特定的案例中,我更喜欢这种方法。
现在,如果你曾经试图在真实世界的环境中使用纯组件,你可能已经注意到一些奇怪的事情。**纯组件经常会重新渲染,**甚至在看起来没有任何变化的情况下也是如此😬
这使我们很好地进入了useMemo
所解决的第二个问题。
(#use-case-2-preserved-references)用例2:保留的引用
在下面的例子中,我创建了一个Boxes
组件。它显示了一组五颜六色的盒子,用于某种装饰性的用途。
我也有一点不相关的状态,即用户的名字。
Code Playground
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();
// Try changing some of these values!
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>
<label htmlFor={`${id}-name`}>
Name:
</label>
<input
id={`${id}-name`}
type="text"
value={name}
onChange={(event) => {
setName(event.target.value);
}}
/>
<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
是一个纯粹的组件,这要归功于 ,它包裹了 中的默认导出。这意味着它应该只在其道具发生变化时重新渲染。React.memo()
Boxes.js
*然而,*每当用户改变他们的名字时,Boxes
也会重新渲染。
下面是一个显示这一动态的图表。试着在文本输入中键入,注意这两个组件是如何重新渲染的。
RedBoxes
Props: {boxes }
纯组件
这到底是怎么回事!?为什么我们的React.memo()
力场没有在这里保护我们呢?
Boxes
组件只有一个道具,即boxes
,而且每次渲染时我们给它的数据似乎都是完全一样的。它总是同样的东西:一个红框,一个宽的紫框,一个黄框。我们确实有一个boxWidth
状态变量来影响boxes
数组,但我们并没有改变它!
**问题就在这里:**每次React重新渲染时,我们都会产生一个全新的数组。它们在值上是等价的,但在引用上不是。
我想,如果我们暂时忘掉React,谈谈普通的旧JavaScript,会有帮助。让我们看一下类似的情况。
function getNumbers() {
return [1, 2, 3];
}
const firstResult = getNumbers();
const secondResult = getNumbers();
console.log(firstResult === secondResult);
你怎么看?firstResult
是否等于secondResult
?
从某种意义上说,它们是。两个变量都持有一个相同的结构,[1, 2, 3]
。但这并不是===
算子实际要检查的内容。
相反,===
正在检查两个表达式是否是同一个东西。
我们已经创建了两个不同的数组。它们可能包含相同的内容,但它们不是同一个数组,就像两个同卵双胞胎不是同一个人一样。
每次我们调用getNumbers
函数,我们都会创建一个全新的数组,一个在计算机内存中保存的不同的东西。如果我们多次调用它,我们将在内存中存储这个数组的多个副本。
请注意,简单的数据类型--像字符串、数字和布尔值--可以通过值进行比较。但是当涉及到数组和对象时,它们只能通过引用进行比较。关于这一区别的更多信息,请查看Dave Ceddia的这篇精彩博文。A Visual Guide to References in JavaScript.
把这个问题带回React。我们的Boxes
React组件也是一个JavaScript函数。当我们渲染它时,我们会调用这个函数。
// Every time we render this component, we call this function...
function App() {
// ...and wind up creating a brand new array...
const boxes = [
{ flex: boxWidth, background: 'hsl(345deg 100% 50%)' },
{ flex: 3, background: 'hsl(260deg 100% 40%)' },
{ flex: 1, background: 'hsl(50deg 100% 60%)' },
];
// ...which is then passed as a prop to this component!
return (
<Boxes boxes={boxes} />
);
}
当name
状态发生变化时,我们的App
组件会重新渲染,从而重新运行所有的代码。我们构建一个全新的boxes
数组,并将其传递给我们的Boxes
组件。
而Boxes
,因为我们给了它一个全新的数组,所以它就重新运行了。
boxes
数组的结构在两次渲染之间没有变化,但这并不重要。React所知道的是,boxes
道具收到了一个新创建的、从未见过的数组。
为了解决这个问题,我们可以使用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
组件能够重新渲染。
我想一个快速的草图将有助于说明问题。之前,我们创建了一个全新的数组,作为每个快照的一部分。
然而,在useMemo
,我们重新使用了之前创建的boxes
数组。
通过在多次渲染中保留相同的引用,我们允许纯组件以我们想要的方式运作,忽略那些不影响用户界面的渲染。
(#the-usecallback-hook)useCallback钩子
好了,这差不多涵盖了useMemo
...那么useCallback
呢?
这里有一个简短的版本。这是完全相同的事情,只是针对函数而不是数组/对象。
与数组和对象类似,函数是通过引用而不是通过值进行比较的。
const functionOne = function() {
return 5;
};
const functionTwo = function() {
return 5;
};
console.log(functionOne === functionTwo); // false
这意味着,如果我们在组件中定义了一个函数,它将在每次渲染时被重新生成,每次都会产生一个相同但唯一的函数。
让我们看一个例子。
Code Playground
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
换句话说,这两个表达式的效果是一样的。
// This:
React.useCallback(function helloWorld(){}, []);
// ...Is functionally equivalent to this:
React.useMemo(() => function helloWorld(){}, []);
useCallback是语法上的糖。它的存在纯粹是为了让我们在试图记忆回调函数时生活得更舒适一些。
(#when-to-use-these-hooks)何时使用这些钩子
好了,我们已经看到了useMemo
和useCallback
是如何让我们将一个引用贯穿于多个渲染中的,以重用复杂的计算或避免破坏纯组件。问题是:我们应该多长时间使用一次?
在我个人看来,用这些钩子来包裹每一个对象/数组/函数是浪费时间的。在大多数情况下,好处是可以忽略不计的;React是高度优化的,重新渲染往往并不像我们经常认为的那样缓慢或昂贵
使用这些钩子的最佳方式是对问题的回应。如果你发现你的应用程序变得有点迟钝,你可以使用React Profiler来寻找缓慢的渲染。在某些情况下,你将能够通过重组你的应用程序来提高性能。在其他情况下,useMemo
和useCallback
可以帮助提高速度。
也就是说,在一些情况下,我确实先发制人地应用这些钩子。
(#inside-generic-custom-hooks)在通用的自定义钩子里面
我最喜欢的一个小的自定义钩子是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次,很有可能这将有助于提高我的应用程序的性能。
(#inside-context-providers)在上下文提供者内部
当我们用上下文在整个应用程序中共享数据时,通常会传递一个大对象作为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
's parent happens to re-render,所有这些组件都会被迫重新渲染。
(#the-joy-of-react)React的乐趣
吁!你做到了。你已经走到了最后。我知道这个教程涵盖了一些相当坎坷的地方。😅
我知道这两个钩子很棘手,React本身会让人感到非常压抑和迷惑。这是个困难的工具!
但问题是:如果你能克服最初的困难,React使用起来绝对是一种乐趣。
我在2015年开始使用React,它已经成为我构建复杂用户界面和网络应用的绝对最喜欢的方式。我试过太阳底下所有的JS框架,但我用它们的效率都不如用React。