前言:英语学渣,高考24分,因为工作原因不方便电脑直接编辑简章,所以有些图片和代码都略过了哦~
此次翻译的外文博客来自印记中文周刊 - React 303期
如果你很难理解 useMemo 和 useCallback ,那你并不是一个人!我与很多使用 React 开发人员交谈过,他们也为这两个 Hooks 而挠头。
我的这篇博客文章的目的是消除这些困惑,我们将学习它们的作用,为什么使用,以及如何精准高效地使用它们。
注:本教程旨在帮助 React 初/中级开发人员更熟悉 React,如果你还是初学者,可以将本博客标记为书签,并在碰上这个问题的时候再回来!
基本概念
首先让我们从 useMemo 开始。
useMemo 的基本概念是:它允许我们在程序渲染的时候“记住”结果。
这个定义需要进一步解释,事实上它需要一个非常复杂的思想来解释 React 的工作原理,因此让我们先来解决这个问题。
React 所做的主要事情是使页面与代码保持同步渲染,而实现这一操作的工具叫做“re-render”(re-render 我还是别翻译了,百翻是:重新渲染 的意思)
每次重新渲染时都是基于当前代码状态的页面在给定时刻的快照,打个比方,可以想象为一堆照片,每张照片都记录了每个变量不同状态值时的情况。
每次重新渲染都会根据当前状态生成 DOM 结构,在上面的小 demo 中,它被渲染为 HTML,但实际上它是一堆 JS 代码,如果你听说过这个术语,那么有时它被称为“虚拟 DOM ”
我们不会直接告诉 React 哪些 DOM 节点需要修改,相反会通知 React UI 应该基于当前状态渲染,重新渲染时,React 创建一个新快照,它可以通过比较快照来快速找出修改的内容,就像“找不同”游戏一样。
等等!什么?
我最近发表了一篇博客解释了什么是“重新渲染”,以及为什么会出现这种情况,如果此时你感到有些迷茫,那么先看看那篇文章,再回来接着看这篇吧!
=> ("Why React Re-Renders")
React 是经过高度优化且开箱即用的,因此重新渲染并不是什么大问题,但在某种情况下,创造这些快照确实需要一些时间。
这可能导致出现性能问题,如用户操作数据后,UI 的更新速度不够快。
(个人觉得这种情况比如为需要大数量的循环渲染,这很花费时间;或者需要请求接口的数据更新,如果没有参考组件那种加载展示时,这种现象就很明显)
从根本上讲:useMemo 和 useCallback 是用来帮助我们优化重新渲染的工具,它们通过两种方式做到这一点:
1. 减少给定渲染中需要的工作量
2. 减少组件需要重新渲染的次数
让我们来逐一讨论这两点。
用例1:大量的计算
假如我们正在编写一个工具来帮助用户找到 0 到 指定数 之间所有素数,其中指定数是用户给定的值。质数是一个只能除以 1 和 它本身 的数,如 17 。
下面是一种实现:
【代码略】
我不希望你看完每一行代码,这里是整个功能的关键点:
1. 设置一个 state,变量名叫做 selectedNum,类型是 number;
2. 使用 for 循环,手动计算 0 到 selectedNum 中间的所有质数;
3. 设置一个 input 输入,以便用户可以手动控制 selectedNum 的值;
4. 在页面向用户展示我们计算出的所有质数;
*此代码需要大量的计算。*如果用户输入了一个很大的数字,我们需要遍历成百上千个数字,来找出其中满足条件的质数。即便有比我上面使用的更有效检测质数的方法,这也需要大量的计算。
有时候当用户重新输入 selectedNum 的数字时,我们需要重新执行此方法,但如果在 selectedNum 没有变动时我们又去执行此方法,会遇到一些性能问题。(这段的意思大概是,当其他 state 发生变化,计算质数的函数也会重新渲染,浪费性能)
假设我们在这段代码里加入数字时钟(即定时器):
【代码略】
现在我们的应用程序中有两个状态值:selectedNum 和 time,每秒更新一次时间变量以反应当前时间,该值用于渲染右上角的数字时钟。
问题是:无论何时,这些状态变量发生改变时,我们都会重新运行一次昂贵(消耗大量性能)的素数计算。因为时间每改变一次,意味着我们会不断地生成一次素数列表,即使用户选择的数字没有发生改变!
在 JavaScript 中,我们只有一个主线程,我们每秒反复运行此代码来让它保持超级繁忙,这意味着当用户尝试做其他操作时,应用程序可能会感觉迟缓,在低端设备上尤为明显。
但如果我们可以“跳过”这些计算呢?假如我们已经有了一个给定数字的素数数组,为什么不重复使用这个值,而不是每次都从头开始计算呢?
这正是 useMemo 允许我们这样做的,它看起来是这样的:
【代码略】
useMemo 有两个参数:
1. 需要执行的功能,包含在函数中;
2. 依赖项数组;
在挂载时,当这个组件第一次渲染时,React 将调用这个函数来运行所有的逻辑,计算所有的素数。无论我们从这个函数中拿到什么,都被分配给 allPrimes 变量。
但对于后续的每次渲染,React 能做出选择,比如:
1. 再次调用函数计算新的值,或
2. 重新使用上次执行计算出的结果
为了回答这个问题,React 查看了提供的依赖项数组,自上次渲染以来是否有任何更改?如果是,React 将重新运行并计算出新的值,否则它将跳过所有的工作并复用先前的计算值。
useMemo 本质上就像一个 lil' 缓存,依赖项是缓存无效策略。
在这种情况下,本质上是“仅当 selectedNum 改变时才重新计算素数数组”。当组件由于其他原因(如时间发生改变)需要重新呈现时,useMemo 会忽略该函数并传递缓存的值。
这通常被成为记忆(memoization),这也是为什么这个钩子叫做 useMemo。
以下是解决方案的实现方式:
【代码略】
另一种方法
因此,useMemo 挂钩确实是可以帮助我们避免不必要的计算……但它在这里真的是最优解吗?
通常,我们可以通过重组应用程序中的内容来避免使用 useMemo。
我们可以这样做:
【代码略,分别创建了素数组件和时间组件】
我提取了两个新组件,Clock 和 PrimeCalculator,这两个组件各自管理自己的状态,在一个组件中重新渲染不会影响另一个组件。
这是一张显示这种动态的图表。每个框表示一个组件实例,它们在重新渲染时闪烁。尝试单击“增加”按钮以查看其操作:
【案例效果略】
我们听到很多关于提升状态的说法,但有时,更好的方法是将状态向下推!每个组件都应该有一个单独的职责,在上面的例子中,App做了两件完全无关的事情。
现在这并不总是一种解决办法,在一个真实的大型项目中,有许多状态需要提升到相当高的程度并且不能被降低。
对于这种情况,我有一个窍门。
让我们看一个例子,假设我们需要提升 PrimeCalculator 上方的时间变量:
【代码略】
这是一个更新的图表,显示了这里发生的事情:
【图略】
React 像领地一样,memo 包裹着我们的组件,保护它不受无关更新的影响。我们的 PurePrimeCalculator 只有在接收到新数据或其内部状态发生变化时才会重新渲染。
这是纯粹的组件,本质上是我们告诉 React,在给定相同输入的情况下,此组件将始终生成相同的输出,并且我们可以跳过没有任何更改的重新渲染。
我在最近的博客文章“为什么要重新渲染”中写了更多关于 React.memo 的工作原理。
一个更传统的方法
在上面的例子中,我将 React.memo 应用于导入的 PrimeCalculator 组件。
事实上,这有点特殊。我选择了这样的结构,以便所有内容都可以在同一个文件中看到,从而更容易理解。
在实践中,我经常将 React.memo 应用于组件导出,如下所示:
【代码略】
我们的PrimeCalculator组件现在将永远是纯净的,我们在使用它时无需对其进行修改。
如果我们需要非纯版本的PrimeCalculator,我们可以将底层组件导出为命名导出。我认为我从来没有必要这样做。
这里有一个有趣的视角转换:之前,我们在记忆特定计算的结果:计算素数。然而,在这种情况下,我已经记住了整个组件。
不管怎样,昂贵的计算工具只有在用户选择新的 selectedNum 时才会重新运行。但我们优化了父组件,而不是特定的慢行代码。
我并不是说这种方法比另一种更好;每个工具在工具库里都有它的用处,但在这种情况下,我更中意这种方法。
现在你如果曾尝试在真实开发中使用纯净组件,你可能会注意到一些奇怪的事情:纯净组件通常会渲染好几遍,即使看起来什么都没有改变!
那这就能够很好地引导我们进入 useMemo 解决的第二个问题。
更多选择:
丹·阿布拉莫夫(Dan Abramov)在他的博客文章“Before you memo()”中分享了另一种方法,该方法基于使用子组件来重构应用程序,以避免需要进行任何记忆。
感谢Yuval Shimoni指出这一点!
用例2:保留的引用
在下面的示例中,我创建了一个Boxes组件。它展示了一组彩色盒子,用于某种装饰目的。
我还有一些不相关的状态:用户名。
Boxes 是一个纯净组件,这是因为 Boxes.js 默认导出时使用了 React.memo() 。这意味着它只会在状态改变时重新渲染。
然而每当用户更改名称时,Boxes 也会重新渲染!
这是一个显示这种动态的图表。尝试键入文本输入,并注意两个组件如何重新渲染:
【图略】
搞什么鬼??为什么我们的 React.memo() 没有生效??
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]。但这不是 === 运算符实际检查的内容。
相反,=== 检查两个表达式是否相同。
(个人:看到这儿你是否已经反应过来了?前面提到过,修改状态变量值后,当前整个组件会重新渲染一遍,而代码中的 boxes 数组只是单纯的普通变量,这样在重新渲染的时候,Boxes 组件会用来和之前的 boxes 数组进行比较,这个比较是 全等于 条件的,正如上面 js 代码举例的一样,两个数组即使内容相同,但本质上并不是相同,所以会导致更新其他组件内容时 Boxes 组件也会重新渲染。)
我们创建了两个不同的数组。它们可能持有相同的内容,但它们不是相同的数组,就像两个同卵双胞胎不是同一个人一样。
每次调用 getNumbers 函数时,我们都会创建一个全新的数组,一个保存在计算机内存中的独特的东西。如果我们多次调用它,我们将在内存中存储此数组的多个副本。
请注意,简单的数据类型(如字符串、数字和布尔值)可以通过值进行比较。但当涉及到数组和对象时,它们只是通过引用进行比较。有关这一区别的更多信息,请查看Dave Ceddia的这篇精彩的博客文章: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} />
);
}
当名称状态更改时,我们的应用程序组件会重新渲染,这会重新运行所有代码。我们构建了一个全新的 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数组:
【图片】
通过在多个渲染中保持相同的引用,我们允许纯净组件按我们希望的方式运行,忽略不影响UI的渲染。
useCallBack挂钩
好吧,所以这只是覆盖useMemo……那useCallback怎么样?
这是简短的概念版本:这两个是完全相同的东西,但对于函数而不是数组/对象。
与数组和对象类似,函数按引用而不是按值进行比较:
const functionOne = function() {
return 5;
};
const functionTwo = function() {
return 5;
};
console.log(functionOne === functionTwo); // false
这意味着,如果我们在组件中定义一个函数,它将在每次渲染时重新生成,每次生成一个相同但唯一的函数。
让我们看一个例子:
【代码略】
这个示例描述了一个典型的计数器应用程序,但带有一个特殊的“Mega Boost”按钮。此按钮将大量增加计数,以防您匆忙且不想多次单击标准按钮。
得益于 React.memo,MegaBoost 组件是一个纯净组件。它不依赖于计数…但每当计数发生变化时,它会重新渲染!
就像我们在 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是语法糖。它的存在纯粹是为了在尝试记忆回调函数时让我们的生活变得更美好。
什么时候使用这些钩子
好了,我们已经看到了useMemo和useCallback如何允许我们在多个渲染中线程化引用,以重用复杂的计算或避免破坏纯净组件。问题是:我们应该多久使用一次?
在我个人看来,将每个对象/数组/函数封装在这些挂钩中是浪费时间。在大多数情况下,好处是微不足道的;React是经过高度优化过的,重新渲染通常不像我们想象中那样慢或昂贵。
使用这些挂钩的最佳方式是响应问题。如果你注意到你的应用程序有点迟钝,你可以使用 React Profiler 来搜索慢渲染。在某些情况下,您可以通过重组应用程序来提高性能。在其他情况下,useMemo 和 useCallback 可以帮助加快速度。
(如果你不知道如何使用 React Profiler,我在最近的博客文章“Why React Re-Renders”中介绍了它!)
也就是说,在一些情况下,我会提前应用这些挂钩。
这在未来可能会改变!
React 团队正在积极研究是否有可能在编译步骤中“自动记忆”代码。现在它仍处于研究阶段,但早期实验似乎很有希望。
可能在未来,所有的这些事情都会自动为我们完成。然而在那之前,我们仍然需要自己优化代码。
欲了解更多信息,请查看黄宣的这篇题为“React without memo”的演讲。
在自定义的钩子内部使用
我最喜欢的 lil' 自定义钩子之一是 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];
}
请注意,切换函数是用useCallback来记忆的。
当我构建这样的自定义可重用挂钩时,我希望尽可能地提高它们的效率,因为我不知道它们将来会在哪里使用。在95%的情况下,这可能是过分的,但如果我使用这个钩子30或40次,这将有助于提高应用程序的性能。
在context中应用
当我们使用 context 在应用程序中共享数据时,通常会传递一个大对象作为 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>
);
}
为什么这样是好的? 可能有几十个纯净组件使用这个 context。 如果没有 useMemo,如果 AuthProvider 的父级碰巧重新渲染,所有这些组件都将被迫重新渲染。
React 的乐趣
呸! 你坚持到了最后。 我知道本教程涵盖了一些非常困难的领域。
我知道这两个钩子很棘手,React 本身可能会存在让人感到不知所措和困惑的地方。 这是一个难用的工具!
但实际上:如果你能克服最初的困难,React 绝对能让你在使用中感到愉悦。
我早在 2015 年就开始使用 React,它已经成为我构建复杂用户界面和 Web 应用程序的最喜欢的方式。 我已经尝试了几乎所有的 JS 框架,但我使用它们的效率不如使用 React 的效率高。
如果您在使用 React 时遇到过困难,我会在这里为您提供帮助!
在过去的一年里,我一直在努力学习一门名为 The Joy of React 的课程。 这是一个适合初学者的自定进度交互式在线课程,教你如何使用 React 构建更酷的东西。
如果您发现这篇博文对您有所帮助,我想您会从我的课程中学到很多东西。 与这篇博文不同的是,该课程将像这样的互动文章与视频、练习、迷你游戏,甚至是一些真实世界风格的项目相结合。 这是一次动手冒险。
该课程尚未发布,但您可以在课程主页上了解更多信息并注册更新:
=> joy of react
网友评论