「翻译」为什么你可以删除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 恶心地向四面八方扩散:

为什么需要 useMemo 和 useCallback

答案很简单:在每次重渲染之间缓存数据。

如果一个值或函数被包裹在这两个 hooks 中,react 就会在首次渲染时缓存这个值或函数。在接下来的每次重渲染时,都会返回这个缓存的值。如果不使用它们,所有非原始类型的值,如 arrayobject,或 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
};

在这里,需要记住的最重要的一件事是,useMemouseCallback 只有在重渲染的过程中才有用。在初始渲染过程中,它们不仅是无用的,甚至是有害的:它们会让 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,那么恭喜了,useMemouseCallback 劫持了你,并且毫无必要地控制了你的生活。在以上所有的例子里,这两个 hooks 都毫无作用。它们让代码变得复杂,拖慢了初始渲染,却没有阻止任何事情。

为什么一个组件会重新渲染

“Component re-renders itself when state or prop value changes”是一个共识,甚至 React 官方文档也提到了这一点。

所以这里很容易得出一个错误结论,“如果props没有改变,那组件就会被重新渲染”。

为什么?因为“当父组件重新渲染自身时,子组件也会重新渲染。”

让我们看如下代码示例:
Image
当我点击几次按钮,得到如下结果:
Image
哈哈,是不是很神奇?这个Page组件甚至都没有 props ,但是App重新渲染了,导致Page也重新渲染了,从而触发应用内一整个重渲染链条。

唯一打断这链条的办法,是缓存组件内的子组件。我们能够使用useMemo做到这些,更好的方法则是React.memo
Image
现在,让我们多点几次按钮:
Image
发现了吗,这里只打印了初次渲染时的日志。那我们试一试当Page组件存在一个onClick属性时的情况,并且不缓存这个函数:
Image
让我们点击几次按钮:
Image
那么如果我缓存Page呢?

Image
点击下按钮,看看发生什么?
Image
不对吧?你可能会疑惑,这里不是用React.memo包裹了吗?为什么按钮点击后仍会重新渲染?

原因很简单,这里onClick不是一个缓存过的函数,所以PageMemoized仍然会重新渲染自己,现在让我们用useCallback试一试:
Image
再点击几下按钮看看:
Image
Oh,是不是很神奇,现在Page不会重新渲染了。

那如果我再加一个没被缓存的值给PageMemoized呢?看看这段代码:
Image
点击按钮后的结果显而易见:
Image
这里onClick确实没变化,但是value变化了(引用值,地址改变),那么PageMemoized还是会重新渲染的。

结论

对以上示例做一个归纳,我们可以得出结论:只有在唯一的场景下,缓存props才是有意义的:当组件的每一个prop,及组件本身被缓存的时候

如果组件代码里有以下情形,我们可以毫无心理负担地删除useMemouseCallback

但为啥不修复缓存,而是要删掉它们呢? 那是因为:如果你因为组件的重渲染而出现了某些性能问题,你肯定已经注意到,并且修复掉它们了,对吧? 😉剩下的既然没有性能问题,就也没必要去 fix 了。删除无用的 useMemouseCallback 将会简化你的代码,并且在初次渲染时稍稍提速,同时不会对现有的重渲染性能产生任何负面影响。

避免每次渲染时进行昂贵的计算

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 你的代码,从 useMemouseCallback 的束缚中解脱出来。下面是一些简短的总结:

最后的小提示:考虑到 useMemouseCallbak 是如此复杂且脆弱,你应该把它们作为你性能优化计划中最后才考虑的一点。试试其他优化技巧吧,看一下我的其它文章,它们提到了这些技巧。

当然,不言而喻的是,测量优先!(没有数据就没有发言权)

希望今天是你在 useMemouseCallbak 地狱中苦苦挣扎的最后一天!✌🏼