「翻译」为什么你可以删除90%的useMemo和useCallback
引用
https://www.developerway.com/posts/how-to-use-memo-use-callback
-- Nadia Makarevich
如果你不是一个 React 纯新手,那么你大概对 useMemo
和 useCallback
已经比较熟悉了。你知道令人悲伤的一点是什么吗?你当下可以删除你代码中至少 90% 的 useMemo
和 useCallback
,与此同时你的应用跑起来完全 OK,甚至还比原来快了那么一丢丢。
不要误会我的意思,我不是说 useMemo
和 useCallback
完全没用。只是他们的用途只被限制在少数非常特殊并且确定的场景下。除此之外,大部分时间里我们都在毫无意义地把业务代码包裹在它们里面。
有两个主要原因导致了这两个 hooks 恶心地向四面八方扩散:
- 缓存props,从而防止组件重新渲染
- 缓存某些值,从而避免在每次重新渲染时执行开销昂贵的计算任务
为什么需要 useMemo 和 useCallback
答案很简单:在每次重渲染之间缓存数据。
如果一个值或函数被包裹在这两个 hooks 中,react 就会在首次渲染时缓存这个值或函数。在接下来的每次重渲染时,都会返回这个缓存的值。如果不使用它们,所有非原始类型的值,如 array
、object
,或 function
,都会在每一次重渲染时被彻底重新创建。如果你需要在每次重渲染时比较这些值,那么缓存它们是很有用的。这其实和普通的 javascript 没什么区别:
const a = { "test": 1 };
const b = { "test": 1'};
console.log(a === b); // will be false
const c = a; // "c" is just a reference to "a"
console.log(a === c); // will be true
或者,更接近于我们的典型 React 应用的话,例子是这样的:
const Component = () => {
const a = { test: 1 };
useEffect(() => {
// "a" will be compared between re-renders
}, [a]);
// the rest of the code
};
a
是 useEffect
hook 的依赖项。在 Component
的每次重渲染时,a
都会被完全重新创建,所以被 useEffect
包裹的函数也将会在每次重渲染的过程中触发调用。
为了避免,我们可以将a
的值包裹在useMemo
中:
const Component = () => {
// preserving "a" reference between re-renders
const a = useMemo(() => ({ test: 1 }), []);
useEffect(() => {
// this will be triggered only when "a" value actually changes
}, [a]);
// the rest of the code
};
useCallback
也是同样的道理,只不过它对于缓存函数更有用:
const Component = () => {
// preserving onClick function between re-renders
const fetch = useCallback(() => {
console.log('fetch some data here');
}, []);
useEffect(() => {
// this will be triggered only when "fetch" value actually changes
fetch();
}, [fetch]);
// the rest of the code
};
在这里,需要记住的最重要的一件事是,useMemo
和 useCallback
只有在重渲染的过程中才有用。在初始渲染过程中,它们不仅是无用的,甚至是有害的:它们会让 React 做很多额外的工作。这意味着你的应用在初始渲染过程中会稍稍更慢一些。并且,如果你的应用有数百个这些 hooks 分布在各处,那么这些轻微的影响初始渲染的作用就可以被观察到。
缓存props避免重新渲染
现在我们已经知道了这俩hooks
的作用,让我们考察一下它们。其中最重要的一点,也是最常被用到的一点,就是缓存 props 以避免重渲染。请注意你的应用中是否存在如下的类似代码:
const Component = () => {
const onClick = useCallback(() => {
/* do something */
}, []);
return (
<>
<button onClick={onClick}>Click me</button>
... // some other components
</>
);
};
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} />
))}
</>
);
};
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 解决了你想让它们解决的问题?如果你的答案是 yes,那么恭喜了,useMemo
和 useCallback
劫持了你,并且毫无必要地控制了你的生活。在以上所有的例子里,这两个 hooks 都毫无作用。它们让代码变得复杂,拖慢了初始渲染,却没有阻止任何事情。
为什么一个组件会重新渲染
“Component re-renders itself when state or prop value changes”是一个共识,甚至 React 官方文档也提到了这一点。
所以这里很容易得出一个错误结论,“如果props没有改变,那组件就会被重新渲染”。
为什么?因为“当父组件重新渲染自身时,子组件也会重新渲染。”
让我们看如下代码示例:
当我点击几次按钮,得到如下结果:
哈哈,是不是很神奇?这个Page
组件甚至都没有 props ,但是App
重新渲染了,导致Page
也重新渲染了,从而触发应用内一整个重渲染链条。
唯一打断这链条的办法,是缓存组件内的子组件。我们能够使用useMemo
做到这些,更好的方法则是React.memo
。
现在,让我们多点几次按钮:
发现了吗,这里只打印了初次渲染时的日志。那我们试一试当Page
组件存在一个onClick
属性时的情况,并且不缓存这个函数:
让我们点击几次按钮:
那么如果我缓存Page
呢?
点击下按钮,看看发生什么?
不对吧?你可能会疑惑,这里不是用React.memo
包裹了吗?为什么按钮点击后仍会重新渲染?
原因很简单,这里onClick
不是一个缓存过的函数,所以PageMemoized
仍然会重新渲染自己,现在让我们用useCallback
试一试:
再点击几下按钮看看:
Oh,是不是很神奇,现在Page
不会重新渲染了。
那如果我再加一个没被缓存的值给PageMemoized
呢?看看这段代码:
点击按钮后的结果显而易见:
这里onClick
确实没变化,但是value
变化了(引用值,地址改变),那么PageMemoized
还是会重新渲染的。
结论
对以上示例做一个归纳,我们可以得出结论:只有在唯一的场景下,缓存props才是有意义的:当组件的每一个prop,及组件本身被缓存的时候。
如果组件代码里有以下情形,我们可以毫无心理负担地删除useMemo
和useCallback
:
- 它们作为属性直接或通过依赖链传递给 DOM 元素
- 它们作为 props 直接或通过依赖链传递给未缓存的组件
- 它们作为 props 直接或通过一系列依赖关系传递给至少有一个 prop 未缓存的组件
但为啥不修复缓存,而是要删掉它们呢? 那是因为:如果你因为组件的重渲染而出现了某些性能问题,你肯定已经注意到,并且修复掉它们了,对吧? 😉剩下的既然没有性能问题,就也没必要去 fix 了。删除无用的 useMemo
和 useCallback
将会简化你的代码,并且在初次渲染时稍稍提速,同时不会对现有的重渲染性能产生任何负面影响。
避免每次渲染时进行昂贵的计算
useMemo
的主要目标,根据React文档,被用来避免每次渲染时昂贵的计算。但没有暗示什么构成了“昂贵”的计算。因此,开发人员有时会将渲染函数中几乎所有计算都包含在useMemo
中。创建新日期? 过滤、映射或排序数组? 创建一个对象?全都使用useMemo
。
好了,让我们看一个例子,假如有一个250个元素的列表,我们希望在页面上展示并且允许用户去执行排序操作:
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
中以避免在每次重新渲染时重新计算它,对吧?嗯,很容易测量:
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
)
};
最终结果?如果没有缓存,设置 6x CPU,对包含约 250 个项目的数组进行排序只需不到 2 毫秒。相比之下,渲染此列表(仅带有文本的本机按钮)需要超过 20 毫秒。 10倍以上!在 codesandbox 中查看。
在实际场景中,数组往往比示例中的更小,同时渲染的内容比示例中的更复杂,因此更慢。所以总的来说「计算」与「渲染」之间的耗时往往超过 10 倍。
与其说缓存数组操作,我们更应该缓存的是实际上是最耗时的计算——重渲染并更新组件。像下面这样:
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
把大约 20ms 的重渲染时间,减少了不到 2ms(也就是 18ms 左右)。
考虑以上事实,我想说的关于缓存”开销巨大“操作的一条准则就是:除非你真的要搞类似大数阶乘,疯狂递归,大素数分解这样的操作,否则就在纯 javascript 操作中把 useMemo
删掉吧。重渲染元素才是你的瓶颈。请只在渲染树的重要部分使用 useMemo
。
那为啥一定要删掉它们呢?缓存并不是毫无开销的。如果我们使用 useMemo
,在初始渲染过程中 React 就需要缓存其值了——这当然也产生耗时。没错,这耗时很微小,但是!这才会产生货真价实的叠加效应!
今天说得够多的了
上面确实传达了不少信息,希望你能觉得有用,并立马热情澎湃地回过头来 review 你的代码,从 useMemo
和 useCallback
的束缚中解脱出来。下面是一些简短的总结:
useCallback
和useMemo
仅仅在后续渲染(也就是重渲染)中起作用,在初始渲染中它们反而是有害的useCallback
和useMemo
作用于props
并不能避免组件重渲染。只有当每一个prop
都被缓存,且组件本身也被缓存的情况下,重渲染才能被避免。只要有一丁点疏忽,那么你做的一切努力就打水漂了。所以说,简单点,把它们都删了吧。- 把包裹了“纯 js 操作“的
useMemo
也都删了吧。与组件本身的渲染相比,它缓存数据带来的耗时减少是微不足道的,并且会在初始渲染时消耗额外的内存,造成可以被观察到的延迟。
最后的小提示:考虑到 useMemo
和 useCallbak
是如此复杂且脆弱,你应该把它们作为你性能优化计划中最后才考虑的一点。试试其他优化技巧吧,看一下我的其它文章,它们提到了这些技巧。
- 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
和 useCallbak
地狱中苦苦挣扎的最后一天!✌🏼