原文地址:www.developerway.com/posts/how-t...
原文作者:NADIA MAKAREVICH
原文发布于:2022 年 06 月 13 日
设计 useMemo 和 useCallback 的目的是什么,使用它们时的常见错误和最佳实践,以及为什么绝大部分的使用可以移除掉,并且可能是更好的选择。
如果您不是 React 的完全新手,您可能至少已经熟悉了 useMemo 和 useCallback Hook。如果您正在开发一个中型到大型应用程序,您很可能会把应用程序的某些部分描述为"一连串难以理解的 useMemo
和 useCallback
,其理解和调试难于登天"。这些 Hook 有一种不受控制地在代码中四处传播的能力,直到它们完全占据了代码,你会发现自己编写它们的原因只是因为它们无处不在,你周围的每个人都在编写它们。
你知道最可悲的是什么吗?所有这一切都完全没有必要。您现在就可以移除应用程序中 90% 的 useMemo
和 useCallback
,这样应用程序也不会有问题,甚至可能会稍微快一点。别误会,我并不是说 useMemo
或 useCallback
毫无用处。只是说它们的使用仅限于一些非常特殊和具体的情况。而且大多数情况下,我们只是无效的一直用它们来把东西包裹起来。
这就是我今天要讨论的话题:开发人员在使用 useMemo
和 useCallback
时会犯哪些错误,它们的实际用途是什么,以及如何正确使用它们。
这些 Hook 在应用程序中的病毒式传播主要有两个来源:
- 对 props 进行记忆,以避免重渲染。
- 对那些高开销计算得来的值进行记忆,避免每次重渲染中都需要重新计算。
我们将在文章的稍后部分了解它们,但首先:useMemo
和 useCallback
的目的究竟是什么?
为什么我们需要 useMemo 和 useCallback
答案很简单------在重新渲染之间记忆化。如果一个值或一个函数被封装在这些 Hook 中,React 会在初始渲染时缓存它,并在接下来重新渲染时返回对该保存值的引用。没有它,像数组、对象或函数这样的非原始值将在每次重新渲染时从头开始创建。不熟悉这个 javascript 机制的可以复习一下:
tsx
const a = { "test": 1 };
const b = { "test": 1 };
console.log(a === b); // 为 false
const c = a; // "c" 只是 "a" 的引用
console.log(a === c); // 为 true
换到 React 中:
tsx
const Component = () => {
const a = { test: 1 };
useEffect(() => {
// "a" 会在重新渲染之间进行比较
}, [a]);
// the rest of the code
};
a
的值是 useEffect
的依赖项。。每次 Component
重新渲染时,React 都会将其与之前的值进行比较。a
是定义在 Component
中的对象,这意味着每次重新渲染时,它都将从头开始重新创建。因此,将 "重新渲染前"的值与"重新渲染后"的值进行比较将返回 false
,并且每次重新渲染都会触发 useEffect
。
为了避免这种情况,我们通常会用 useMemo
将 a
包裹起来:
tsx
const Component = () => {
// 在每次重渲染之间保存 "a" 的引用
const a = useMemo(() => ({ test: 1 }), []);
useEffect(() => {
// this will be triggered only when "a" value actually changes
}, [a]);
// the rest of the code
};
现在,只有当 a
值实际发生变化时(在这个实例中永远不会发生变化),才会触发 useEffect
。
useCallback
的原理也是一样,只是它更适合用来对函数记忆化:
tsx
const Component = () => {
// 在每次重渲染之间保存函数引用
const fetch = useCallback(() => {
console.log('fetch some data here');
}, []);
useEffect(() => {
// 只有当 fetch 的值变化时才会触发
fetch();
}, [fetch]);
// the rest of the code
};
这里最重要的一点是,useMemo
和 useCallback
只在重新渲染阶段有用。在初始渲染时,它们不仅无用,甚至有害:它们会让 React 做一些额外的工作。 这意味着您的应用程序在初始渲染时会变得稍慢。如果您的应用程序中有成百上千个这样的过程,那么这种变慢甚至是可感知的。
通过记忆化 props 来阻止重新渲染
既然我们已经知道了这些 Hook 的用途,那就来看看它们的实际用法吧。其中最重要也是最常用的 Hook 之一就是将 props 记忆化,来阻止重新渲染。如果你在你的应用程序中看到过下面的代码,请大声说出来:
- 不得不将
onClick
包在useCallback
中,以阻止重新渲染:
tsx
const Component = () => {
const onClick = useCallback(() => {
/* do something */
}, []);
return (
<>
<button onClick={onClick}>Click me</button>
... // some other components
</>
);
};
- 不得不将
onClick
包在useCallback
中,以阻止重新渲染:
tsx
const Item = ({ item, onClick, value }) => <button onClick={onClick}>{item.name}</button>;
const Component = ({ data }) => {
const value = { a: someStateValue };
const onClick = useCallback(() => {
/* do something on click */
}, []);
return (
<>
{data.map((d) => (
<Item item={d} onClick={onClick} value={value} />
))}
</>
);
};
- 不得不将
value
包裹在useMemo
中,因为它是被记忆化的onClick
的依赖项:
tsx
const Item = ({ item, onClick }) => <button onClick={onClick}>{item.name}</button>;
const Component = ({ data }) => {
const value = useMemo(() => ({ a: someStateValue }), [someStateValue]);
const onClick = useCallback(() => {
console.log(value);
}, [value]);
return (
<>
{data.map((d) => (
<Item item={d} onClick={onClick} />
))}
</>
);
};
你有做过,或看到周围其他人做过这些优化吗?你是否同意这些用例以及 Hook 是如何解决这个问题的?如果这些问题的答案是"是",那么恭喜你:useMemo
和 useCallback
绑架了你,不必要地控制了你的生活。在所有例子中,这些 Hook 都是无用的,它们不必要地使代码复杂化,减慢了初始渲染速度,却什么也阻止不了。
要了解原因,我们需要记住 React 工作原理中的一件重要事情:组件重新渲染它自身的原因。
为什么组件会重新渲染
"当 state 或 props 发生变化时,组件会重新渲染自身"是一个常识。甚至 React 文档也是这样表述的。我认为正是这种说法导致了"如果 props 不改变(即记忆化它),就会阻止组件重新渲染"的错误结论。
因为组件重新渲染还有一个非常重要的原因:当它的父组件重新渲染自己时。或者,如果我们反其道而行之:当一个组件重新渲染它自己时,它也会重新渲染它的所有子组件。请看下面这段代码:
tsx
const App = () => {
const [state, setState] = useState(1);
return (
<div className="App">
<button onClick={() => setState(state + 1)}> click to re-render {state}</button>
<br />
<Page />
</div>
);
};
App
组件有一些状态和一些子组件,包括 Page
组件。点击这里的按钮会发生什么? state 会发生变化,触发 App 的重新渲染,并触发其所有子组件(包括 Page 组件)的重新渲染。Page
组件甚至没有接受 props!
现在,假如 Page
组件含有这样一些子组件:
tsx
const Page = () => <Item />;
它完全是空的,既没有 state 也没有 props。但当 App
重新渲染时,Page
的重新渲染将被触发,并因此触发其 Item
子项的重新渲染。App
组件状态的改变会触发整个应用的一连串重新渲染。请参阅 codesandbox 中的完整示例。
中断这个链条的唯一方法就是将其中的某些组件记忆化(memoized)。我们可以使用 useMemo
钩子,或者更好的是使用 React.memo 函数。只有当组件被包装后,React 才会通过检查它的 props 是否发生变化,阻止重新渲染。
记忆化(memoized)组件:
tsx
const Page = () => <Item />;
const PageMemoized = React.memo(Page);
在有状态变化的应用中使用:
tsx
const App = () => {
const [state, setState] = useState(1);
return (
... // same code as before
<PageMemoized />
);
};
只有在这种情况下,props 是否被记忆化才是最重要的。
举例说明,假设 Page
组件有一个 props onClick
,接受一个函数。如果我不先对其进行记忆化就将其传递给 Page
,会发生什么情况?
tsx
const App = () => {
const [state, setState] = useState(1);
const onClick = () => {
console.log('Do something on click');
};
return (
// 无论 onClick 是否被记忆化,Page 都会重新渲染
<Page onClick={onClick} />
);
};
App
将重新渲染时,React 将在其子节点中找到 Page
,并重新渲染它。至于 onClick
是否封装在 useCallback 中并不重要。
但是假如我对 Page
组件记忆化?
tsx
const PageMemoized = React.memo(Page);
const App = () => {
const [state, setState] = useState(1);
const onClick = () => {
console.log('Do something on click');
};
return (
// PageMemoized 会因为 onClick 没有记忆化而重渲染
<PageMemoized onClick={onClick} />
);
};
App
将重新渲染时,React 将在其子组件中找到 PageMemoized
,发现它已被 React.memo
封装,停止重新渲染链,并首先检查该组件上的 props 是否发生变化。在这种情况下,由于 onClick
是一个未被记忆化的函数,props 比较的结果是 false,PageMemoized
将重新渲染自己。接下来,这是是用了 useCallback
的用例:
tsx
const PageMemoized = React.memo(Page);
const App = () => {
const [state, setState] = useState(1);
const onClick = useCallback(() => {
console.log('Do something on click');
}, []);
return (
// PageMemoized 不会重新渲染
<PageMemoized onClick={onClick} />
);
};
现在,当 React 停止重新渲染链,并对 PageMemoized
进行 props 检查时,因为 onClick
的引用保持不变,检查结果为 true,PageMemoized
将不会重新渲染。
如果我在 PageMemoized
中添加另一个非记忆化值,会发生什么情况?其他代码是完全一样的:
tsx
const PageMemoized = React.memo(Page);
const App = () => {
const [state, setState] = useState(1);
const onClick = useCallback(() => {
console.log('Do something on click');
}, []);
return (
// PageMemoized 会重新渲染
<PageMemoized onClick={onClick} value={[1, 2, 3]} />
);
};
React 在停止重新渲染链,并对 PageMemoized
进行 props 检查时,onClick
保持不变,但 value
改变了。PageMemoized
将重新渲染。请参见此处的完整示例,尝试移除记忆化,看看一切是如何重新渲染的。
综上所述,只有一种情况下对组件上的 props 进行记忆化才有意义:即对每个 props 和组件本身都进行记忆化。其他情况都只是在浪费内存,并使代码不必要地复杂化。
如果需要,可随意删除这些代码中的所有 useMemo
和 useCallbacks
:
- 它们作为属性直接、或通过依赖链传递给 DOM 元素。
- 它们作为属性直接、或通过依赖链传递给未记忆化的组件。
- 它们作为属性直接、或通过依赖链传递给已经记忆化,但至少有一个属性入参未记忆化的组件。
为什么要删除而不只是修复记忆化?好吧,如果因为未记忆化而导致重新渲染出现了性能问题,你早就注意到并修复了,不是吗?😉 既然没有性能问题,就没有必要修复它。移除无用的 useMemo
和 useCallback
可以简化代码,加快初始渲染速度,但不会对现有的重新渲染性能产生负面影响。
避免每次渲染都进行昂贵的计算
根据 React 文档,useMemo 的主要目标是避免每次渲染都进行昂贵的计算。甚少有人仔细想过什么是"昂贵"的计算。因此,开发人员有时会在渲染函数中使用 useMemo
封装几乎所有计算。创建一个新日期?过滤、映射或排序数组?创建对象?通通都使用 useMemo
!
好吧,让我们来看一些数字。假设我们有一个国家数组(约 250 个),我们希望在屏幕上渲染这些国家,并允许用户对其进行排序。
tsx
const List = ({ countries }) => {
// sorting list of countries here
const sortedCountries = orderBy(countries, 'name', sort);
return (
<>
{sortedCountries.map((country) => (
<Item country={country} key={country.id} />
))}
</>
);
};
问题是:对 250 个元素的数组进行排序是一项昂贵的操作吗?感觉是的,不是吗?我们或许应该用 useMemo
将其封装起来,以避免每次重新渲染时都要重新计算,对吗?嗯,很容易判断:
tsx
const List = ({ countries }) => {
const before = performance.now();
const sortedCountries = orderBy(countries, 'name', sort);
// this is the number we're after
const after = performance.now() - before;
return (
// same
)
};
最终结果如何?在不使用记忆化的情况下,CPU 运算速度降低 6 倍,但对这个包含约 250 个项目的数组进行排序却只需不到 2 毫秒 。相比之下,渲染这个列表------仅仅是带文本的原生按钮------需要 20 多毫秒。多出 10 倍!请参见代码示例。
而在现实生活中,数组可能会小得多,渲染的内容也会复杂得多,因此速度也会慢得多。因此,性能上的差异将比 10 倍还要大。
与其将数组操作记忆化,我们还不如将实际最昂贵的计算------重新渲染和更新组件------记忆化。类似这样:
tsx
const List = ({ countries }) => {
const content = useMemo(() => {
const sortedCountries = orderBy(countries, 'name', sort);
return sortedCountries.map((country) => <Item country={country} key={country.id} />);
}, [countries, sort]);
return content;
};
使用 useMemo
可将整个组件不必要的重新渲染时间从约 20 毫秒降至 2 毫秒以下。
综上所述,这就是我想介绍的关于将"昂贵"操作记忆化的规则:除非你实际上是在计算大数字的阶乘,否则请移除所有纯 javascript 操作上的 useMemo
Hook。重新渲染子代将始终是你的瓶颈。只有在渲染树的复杂部分才使用 useMemo
。
为什么要把它们都删除?把所有东西都记忆化(memoized)不是更好吗?如果我们将它们全部删除,会不会产生降低性能的复合效应?1 毫秒在这里,2 毫秒在那里,很快我们应用的速度就会大打折扣......
有道理。如果不是有一点需要注意:记忆化(memoized)不是免费的。如果我们使用 useMemo
,在初始渲染期间,React 需要缓存结果值------这需要时间。是的,这将是很小的一部分,在我们上面的应用中,对这些排序过的国家进行记忆化只需不到一毫秒的时间。但是!这也将是真正的复合效果。当应用第一次出现在屏幕上时,就会进行初始渲染。每个应该出现的组件都要经历它。在一个拥有数百个组件的大型应用程序中,即使其中三分之一的组件对某些内容进行了记忆化,也会导致初始渲染时间增加 10、20 毫秒,最糟糕的情况下甚至可能会增加 100 毫秒。
另一方面,重新渲染只发生在应用程序的某一部分发生变化之后。而在架构良好的应用程序中,只有这一小部分需要重新渲染,而不是整个应用程序。与上述情况类似的"计算"有多少会出现在变化的部分中?2-3? 还是 5 个?每次记忆化将为我们节省少于 2 毫秒的时间,即总体节省少于 10 毫秒。这 10 毫秒可能会发生,也可能不会发生(取决于触发它的事件是否发生),肉眼无法看到,而且会在子组件们的重新渲染中丢失,而重新渲染无论如何都会花费 10 倍于此的时间。但代价是初始渲染的速度会变慢,而这是必然会发生的😔。
今天就到此为止吧
这次讨论到的信息量很大,希望对你有用,现在你已经迫不及待地想检查你的应用程序代码,并删除所有无用的 useMemo
和 useCallback
,因为它们不小心占据了你的代码。在你打算这么做之前,我们快速总结一下:
useCallback
和useMemo
是仅对连续渲染(即重新渲染)有用的 Hook,对于初始渲染来说,它们实际上是有害的。- props 的
useCallback
和useMemo
本身并不能防止重新渲染。只有当每个 props 和组件本身都被记忆化时,才能防止重新渲染。只要漏了一个,一切就都完了,这些 Hook 也就失去了作用。 - 移除"原始" JavaScript 操作周围的
useMemo
------ 与组件更新相比,这些操作是不可见的,只会在初始渲染时占用额外的内存和宝贵的时间。
还有一件小事:考虑到这一切是多么复杂和脆弱,useMemo
和 useCallback
确实应该是你最后的性能优化手段。请先尝试其他性能优化技术。请参阅介绍其中一些技术的文章:
- How to write performant React code: rules, patterns, do's and don'ts
- Why custom react hooks could destroy your app performance
- How to write performant React apps with Context
- React key attribute: best practices for performant lists
- React components composition: how to get it right
当然,不言而喻:测量第一!没有基于测量结果评判的优化都是无效优化。
愿今天是你在 useMemo
和 useCallback
地狱中的最后一天!✌🏼
此外,还可以查看 YouTube 视频,其中使用了一些漂亮的动画来解释文章内容。