译:如何运用useMemo和useCallback:你其实可以不用它们

668 阅读13分钟

原文: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进行缓存来避免重渲染。来看看以下例子是否在你的代码中也出现过:

  1. 将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} />
      ))}
    </>
  );
};
  1. 由于是已缓存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来优化性能应该是你最后再采取的方式,你应该首先尝试其他的性能优化技术。