原文:How to useMemo and useCallback: you can remove most of them
如果你已经对React有一些了解的话,你可能至少已经对useMemo和useCallback这两个Hooks比较熟悉。要是维护过中等规模的前端项目,你大概率可能碰到过一些层层嵌套的useMemo和useCallback非常难以理解和调试。这些Hooks往往在代码中到处不断胡乱出现,直到你难以维护,最后你也可能因为代码里都是useMemo和useCallback并且周围人都在用他们而随波逐流。
你知道最可悲的是什么吗?使用这些Hooks往往完全没有必要。你现在就可以删掉你项目中90%的useMemo和useCallback且不会有任何bug,甚至项目还会变得更快一点。不要误会我的意思,我不是说useMemo和useCallback没有用,我想说的是,我们应该在仅限于一些非常具体的情况中再用它们。然而在大多数情况下,我们往往没必要地在变量和方法上使用它们。
因此,这就是我今天想谈的主题:开发者在使用useMemo和useCallback时会犯了什么样的错误,useMemo和useCallback的实际用处是什么以及如何正确使用它们。
这两个Hooks在代码中被滥用有两个主要原因:
- 缓存Props以避免组件重新渲染
- 缓存变量以避免在每次重新渲染时执行复杂的计算过程
这两点将在后文中详细展开,但首先要回答一个问题:我们为何要用useMemo和useCallback?
我们为何要用useMemo和useCallback
答案很简单--在重新渲染之间进行缓存。如果一个值或一个函数被包裹在这些Hooks中,react会在初始渲染时将它们缓存,并在连续渲染时返回对该保存值的引用。如果不缓存,像数组、对象或函数这样的非原始值将在每次重新渲染时反复创建。当这些变量没有变化时,将它们缓存起来会很有用。以下是一段普通的JS代码:
const a = { "test": 1 };
const b = { "test": 1'};
console.log(a === b); // 结果是false
const c = a; // c只是对a的一个引用
console.log(a === c); // 结果是true
或者举一个更普遍的React例子:
const Component = () => {
const a = { test: 1 };
useEffect(() => {
// a会在下次渲染时进行比较
}, [a]);
// 其余代码
};
对象a是useEffect的一个依赖项。在每次重新渲染Component组件时,React都会将其与之前的值进行比较。a是一个定义在Component中的对象,这意味着在每次重新渲染时,它都会被重新创建。因此,重新渲染前与重新渲染后的a的比较结果将返回false,并且每次重新渲染时都会触发useEffect。
为了避免这种情况,我们可以用useMemo把对象a包起来:
const Component = () => {
// 在重新渲染前将a的引用缓存起来
const a = useMemo(() => ({ test: 1 }), []);
useEffect(() => {
// 这里仅当对象a实际改变时才触发
}, [a]);
// 其余代码
};
现在useEffect只会在a改变时再出发执行(在此例子中永远不会执行)。 同样的情况也适用于useCallback,只是它更适合于缓存函数:
const Component = () => {
// 在重新渲染前将onClick方法缓存起来
const fetch = useCallback(() => {
console.log('fetch some data here');
}, []);
useEffect(() => {
// 这里仅当onClick方法实际改变时才触发
fetch();
}, [fetch]);
// 其余代码
};
这里最重要的一点是,useMemo和useCallback都只在重新渲染阶段发挥作用。在初始渲染阶段,它们不仅没有用,甚至适得其反:React会因此执行一些额外的工作。这意味着你的代码在初始渲染阶段会变慢一些。如果你的代码到处都有这样的Hooks,那么渲染速度甚至会降低得更明显。
缓存Props以避免重渲染
在理解为何去使用这些Hooks之后,让我们来看看它们在实际情境中是如何使用的。其中最重要也是最经常使用的是对Props进行缓存来避免重渲染。来看看以下例子是否在你的代码中也出现过:
- 将onClick包在useCallback中来避免重新渲染
const Component = () => {
const onClick = useCallback(() => {
/* 代码 */
}, []);
return (
<>
<button onClick={onClick}>Click me</button>
... // 一些其他组件
</>
);
};
const Item = ({ item, onClick, value }) => <button onClick={onClick}>{item.name}</button>;
const Component = ({ data }) => {
const value = { a: someStateValue };
const onClick = useCallback(() => {
/* 点击相关操作 */
}, []);
return (
<>
{data.map((d) => (
<Item item={d} onClick={onClick} value={value} />
))}
</>
);
};
- 由于是已缓存onClick方法的依赖项,将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} />
))}
</>
);
};
这些代码是不是都有些似曾相识?是不是都觉得没什么问题?如果你的回答都是"是",那么恭喜你:你滥用了useMemo和useCallback。在所有的例子中,这些Hooks都没有起到作用,反而不必要地使代码复杂化,拖慢了首次渲染速度。
为了理解这个原因,我们需要理解React的一个重要运作机制:组件重新渲染的原因。
为什么组件会重新渲染?
众所周知,当state或props发生变化时,组件会重新渲染,React文档中也是这样表述的。而我认为这句话恰恰导致了“如果Props不改变(即缓存化),那么组件就不会重新渲染"的错误结论。因为还有一个非常重要的原因会让组件重新渲染:当它的父组件重新渲染。或者如果我们从相反的方向去理解:当一个组件重新渲染自己时,它也会重新渲染它所有的子组件。请看一下这段代码的例子:
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的重新渲染,而这将继而触发它所有的子组件的重新渲染,包括没有Props的Page组件。
现在假设在这个Page组件里面也有一个子组件:
const Page = () => <Item />;
Item是一个完全是空的组件,它既没有state也没有Props。但是当App重新渲染时,它也会被重新渲染。因此,Page组件将触发其Item子组件的重新渲染,App组件的状态变化会链式触发整个组件的重新渲染。
中断这个链式渲染的唯一方法是对其中的一些组件进行缓存。我们可以利用useMemo,或者最好是用React.memo。只有当组件被缓存后,React才会在重新渲染前停下检查Props是否有变化。
const Page = () => <Item />;
const PageMemoized = React.memo(Page);
加上state变化:
const App = () => {
const [state, setState] = useState(1);
return (
... // 代码同上
<PageMemoized />
);
};
只在这样的情境下,缓存Props的值才会变得有意义。为了解释这一点,我们假定Page组件有一个onClick的Prop方法,如果我传递给它一个没有被缓存的函数会发生什么事呢?
const App = () => {
const [state, setState] = useState(1);
const onClick = () => {
console.log('Do something on click');
};
return (
// Page组件将无论如何都会重新渲染,不管onClick有没有被缓存
<Page onClick={onClick} />
);
};
App组件会重新渲染,React会发现Page是它的子组件并重新渲染它,不管onClick是否被useCallback缓存过。
但是当我缓存了Page组件呢?
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包装了,停止重新渲染链,并首先检查这个组件上的道具是否改变。在这种情况下,由于onClick是一个没有缓存的函数,Props比较的结果会是不一致,于是PageMemoized将重新渲染自己。最后,我们将useCallback使用起来:
const PageMemoized = React.memo(Page);
const App = () => {
const [state, setState] = useState(1);
const onClick = useCallback(() => {
console.log('Do something on click');
}, []);
return (
// PageMemoized不会重新渲染,由于onClick已经被缓存
<PageMemoized onClick={onClick} />
);
};
现在当React在PageMemoized组件上停留时检查它的Props,onClick的引用没有改变,于是PageMemoized组件就不会被重新渲染了。倘若我在它里面再加一个没被缓存的值呢?同样场景如下:
const PageMemoized = React.memo(Page);
const App = () => {
const [state, setState] = useState(1);
const onClick = useCallback(() => {
console.log('Do something on click');
}, []);
return (
// PageMemoized仍旧会重新渲染,由于value没有缓存
<PageMemoized onClick={onClick} value={[1, 2, 3]} />
);
};
React在PageMemoized上停下来检查它的Props,onClick不变,但value改变了,PageMemoized会进行重新渲染。
考虑到上述情况,我们发现只有当每一个Props和组件本身都被缓存的时候,使用这两个Hooks才是有意义的。其他的情况下都是在浪费内存,并且不必要地使你的代码复杂化。
所以,如果以下条件满足的话,你尽可放心删除所有使用过的useMemo和useCallback:
-
缓存的变量作为属性,直接或通过一个依赖链传递给DOM元素
-
缓存的变量作为Props,直接或通过依赖链传递给一个未被缓存的组件
-
缓存的变量作为Props,直接或通过依赖链传递给一个组件,它至少有一个没有被缓存的Prop属性。
为什么要删掉而不是仅仅修复添加其他的缓存呢?Emmmmm,如果你发现因为重新渲染导致的性能问题,你应该早就注意到并解决好了不是吗?而且如果没有性能问题,就没有必要修复它。删掉没用的useMemo和useCallback会简化代码,提高初始渲染速度,而不会降低目前的重渲染性能。
避免每次渲染时的昂贵复杂运算
根据React文档,useMemo的主要目标是避免每次渲染时的昂贵计算。但对什么是"昂贵"的计算没有更多的阐述。因此,开发人员有时会在render函数中用useMemo包装几乎所有东西。创建一个新的日期?过滤、映射或排序一个数组?创建一个对象?不管三七二十一,都用useMemo全缓存了!
行吧,先让我们看一些数据。假设现在我们有一个所有国家名字的数组(大约250个),我们想把它们呈现在屏幕上并允许用户对它们进行排序。
const List = ({ countries }) => {
// 将数组进行排序
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);
// 排序之后的性能
const after = performance.now() - before;
return (
// 未改变
)
};
最终的结果是:在没有缓存的情况下,在CPU减速6倍的情况下,对这个有大约250个元素的数组进行排序需要不到2毫秒的时间。相比之下,渲染这个列表,仅仅是带有文本的原生按钮需要超过20毫秒的时间,几乎多了10倍! 而在现实中,数组可能会小得多,并且渲染内容都要复杂得多,因此会更慢。所以,性能上的差异将比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。
考虑到上述情况,这就是我想介绍的关于缓存"昂贵"操作的评判规则:除非你真的在计算大数的阶乘,否则那就删掉所有纯JS操作的useMemo吧,因为重新渲染子组件才是真正的性能瓶颈。
为什么要删除呢?把所有的东西都缓存起来不是更好吗?如果我们把它们全部删除,岂不是会造成性能下降的复合效应?这里一毫秒,那里两毫秒,很快我们的代码就变慢了……
确实有一定道理,这种想法完全正确,如果不是因为一点的话:缓存操作并不是没有成本的。如果我们使用useMemo,在最初的渲染过程中,React需要对值进行缓存并消耗一定时间。这个耗时微乎其微,在我们上面的App中,缓存国家名数组只需要不到一毫秒的时间。但是,这将带来实打实的叠加效应。首次渲染时。每个出现的组件都会消耗这么一点时间。在一个有数百个组件的大应用程序中,即使其中有三分之一的组件进行了缓存,也可能导致最坏的情况下100毫秒的初始渲染。
另一方面,组件重新渲染只会发生在App的某个组件发生变化之后,而在一个架构良好的App中,只有这个特定的组件会被重新渲染。有多少类似于上述情况的"计算"会出现在改变的组件呢?2-3个? 比方说5个。每一个值的缓存将为我们节省不到2毫秒的时间,也就是说,总体上不到10毫秒的时间。这10毫秒并不是一直能节省出来,取决于触发它的事件是否发生,并且这10毫秒用肉眼是看不到的。相比之下,与子组件的重新渲染所花费的多10倍的时间相比,这样的优化可以说是微乎其微了。
结尾
快速总结一下,在你划走之前巩固一下本文:
-
useCallback和useMemo是只对连重新渲染有用的Hooks,对于初始渲染而言,它们实际上反而降低了一定的性能。
-
Props的useCallback和useMemo本身并不能阻止重新渲染。只有当每一个Prop和组件本身都被缓存时,才可以阻止重渲染。一旦有一个prop变量没有被缓存,之前的努力就还是白费了,如果看到了就删了吧。
-
删除原生JS操作相关的useMemo缓存。与组件更新相比,这些优化九牛一毛,只会在初始渲染时占用额外的内存和消耗宝贵的时间。
最后:考虑到其复杂性,用useMemo和useCallback来优化性能应该是你最后再采取的方式,你应该首先尝试其他的性能优化技术。