何时应该在 React 中使用 memoize

2025-06-07

何时应该在 React 中使用 memoize

如果您听说过或使用过 React 记忆方法(useMemo、useCallback 和 memo),您可能会经常在不需要它们的情况下使用它们。

当我第一次了解这些方法时,我也经常在各处使用它们,因为优化某些东西会有什么坏处呢,对吧?

好吧,您现在可能已经猜到了,我错了,因为这些钩子和方法存在于某些特定的用例中,如果在任何地方都盲目地使用它们,它们实际上会降低应用程序的性能。

在本文中,我将尽力解释 -

  1. 为什么过早优化是不好的
  2. 如何在不进行记忆的情况下优化代码
  3. 什么时候应该真正记忆

为什么过早优化是不好的

使用回调

让我们从一个例子开始。你觉得下面代码片段中的 handleChange 怎么样?

const MyForm = () => {
  const [firstName, setFirstName] = React.useState('');

  const handleSubmit = event => {
    /**
     * Omitted for brevity
     */
  };

  const handleChange = React.useCallback(event => {
    setFirstName(event.target.value);
  }, []);

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="firstName" onChange={handleChange} />
      <button type="submit" />
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

我以前以为,useCallback返回一个记忆化的回调函数,只有当依赖项发生变化时才会改变,这样可以提高性能。在我们的例子中,由于依赖项数组为空,它会被记忆化,比普通的内联函数更高效,对吧?

但事情并非如此简单,因为任何优化都会带来相应的成本。而在上述案例中,优化带来的成本并不值得。这是为什么呢?

const handleChange = React.useCallback(event => {
    setFirstName(event.target.value);
}, []);
Enter fullscreen mode Exit fullscreen mode

在上面的例子中,每次组件重新渲染useCallback()时都会调用。即使它返回的是同一个函数对象,内联函数仍然会在每次渲染时创建,只是跳过它以便拥有对函数的相同引用。不仅如此,我们还有一个空的依赖数组,它本身正在通过运行一些逻辑表达式来检查内部变量是否发生了变化等。MyFormuseCallback

所以这实际上不算是优化,因为优化比没有优化花费更多。而且,由于函数被包装在 useCallback 中,我们的代码比以前更难读了。

就内联函数而言,这是React 网站上的官方文档所说的,它们实际上并不像你想象的那么糟糕

useMemo 不同但相似

useMemo也与 非常相似useCallback,唯一的区别是它允许对任何值类型进行记忆。它通过接受一个返回值的函数来实现这一点,并且仅在依赖项列表中的项目发生变化时重新计算。所以,如果我不想在每次渲染时都初始化某些内容,我可以这样做吗?

const MightiestHeroes = () => {
  const heroes = React.useMemo( () => 
    ['Iron man', 'Thor', 'Hulk'], 
  []);

    return (
        <>
            {/* Does something with heroes, Omitted for brevity */}
        </>
    )

}
Enter fullscreen mode Exit fullscreen mode

再次,节省的费用非常少,以至于使代码变得更复杂是不值得的,并且由于我们在上一节中讨论过的相同原因,情况可能会更糟。

对于这种情况,最好在组件外部定义数组。

const heroes = ['Iron man', 'Thor', 'Hulk'];

const MightiestHeroes = () => {
    // Ommited for brevity 

}
Enter fullscreen mode Exit fullscreen mode

带备忘录的极端情况

同样的事情也发生在memo,如果我们不够小心,你的记忆组件最终可能会做更多的工作,因此比正常的组件效率更低

以这个沙箱为例,当你增加计数时,你认为这个记忆组件将渲染多少次。

但是它不应该只渲染一次吗,因为它只需要一个children道具,而且这个道具在渲染过程中似乎不会发生变化?

memo对之前的 props 和新的 props 进行浅层比较,并且只有当 props 发生变化时才重新渲染。所以,如果你已经使用 JavaScript 一段时间了,那么你一定知道引用相等性-

2 === 2 // true
true === true // true
'prateek' === 'prateek' // true

{} === {} // false
[] === [] // false
() => {} === () => {} // false
Enter fullscreen mode Exit fullscreen mode

而且由于typeof children === 'object,memo 中的相等性检查始终返回 false,因此每当父级重新渲染时,它都会导致我们的 memoized 组件也重新渲染。

如何在不进行记忆的情况下优化代码

在大多数情况下,请检查是否可以将变化的部分与不变的部分分开,这很可能能够解决大多数问题,而无需使用记忆化。例如,在前面的 React.memo 示例中,如果我们将繁重的组件与计数逻辑分离,那么我们就可以避免不必要的重新渲染。

如果您想了解更多信息,可以查看 Dan Abramov 的文章《Before you Memo》 。

但在某些情况下,您需要使用记忆钩子和函数,所以让我们看看何时应该使用这些方法。

什么时候应该真正记忆

useCallback 和 useMemo

的主要目的是在将函数传递给记忆组件或在依赖数组中使用时useCallback保持函数的引用相等性useMemo(因为如上所述,函数并非引用相等)。除了引用相等性之外,likememo也是一种避免重复计算昂贵计算的方法。让我们通过一些示例来了解它们的工作原理:

引用相等

首先,让我们看看这些钩子如何帮助我们维护引用相等性,请看下面的例子(请记住,这是一个人为的例子,用于解释这些钩子的用例,实际的实现会有所不同)

const PokemonSearch = ({ weight, power, realtimeStats }) => {
  const [searchquery, setSearchQuery] = React.useState('');

  const filters = {
    weight,
    power,
    searchquery,
  };

  const { isLoading, result } = usePokemonSearch(filters);

  const updateQuery = newQuery => {
    /**
     * Some other stuff related to
     * analytics, omitted for brevity
     */
    setSearchQuery(newQuery);
  };

  return (
    <>
      <RealTimeStats stats={realtimeStats} />

      <MemoizedSearch query={searchquery} updateQuery={updateQuery} />

      <SearchResult data={result} isLoading={isLoading} />
    </>
  );
};

const usePokemonSearch = filters => {
  const [isLoading, setLoading] = React.useState(false);

  const [result, setResult] = React.useState(null);

  React.useEffect(() => {
    /**
     * Fetch the pokemons using filters
     * and update the loading and result state
     * accordingly, omitted for brevity
     */
  }, [filters]);

  return { result, isLoading };
};
Enter fullscreen mode Exit fullscreen mode

在这个例子中,我们有一个PokemonSearch组件,它使用usePokemonSearch自定义钩子来获取给定一组过滤器对应的宝可梦。我们的组件从父组件接收体重和力量过滤器。它还接收一个用于实时统计数据的 prop,顾名思义,该 prop 会经常变化。

我们的组件本身通过 来处理最后一个过滤器,名为searchQueryuseState我们将此过滤器传递给一个名为 的记忆组件,MemoizedSearch并使用名为 的方法来更新它updateQuery

您现在可能已经注意到我们的示例中的第一个问题,每次我们PokemonSearch重新渲染时,updateQuery都会创建一个新的函数引用(由于 JavaScript 中引用相等的工作方式,它不会等于前一个引用),从而导致组件MemoizedSearch不必要地重新渲染,即使是searchQuery相同的。

这就是useCallback拯救世界的地方——

const updateQuery = React.useCallback(newQuery => {
    /**
     * Some other stuff related to
     * analytics, omitted for brevity
     */
    setSearchQuery(newQuery);
}, []);
Enter fullscreen mode Exit fullscreen mode

这将帮助我们维护updateQuery函数的相同引用,从而避免组件不必要的重新渲染,MemoizedSearch导致它仅在发生变化时重新渲染searchQuery

如果你检查usePokemonSearch自定义钩子,你会发现它有一个useEffect依赖于filtersprop 的函数,用于决定是否在 pokemon 属性发生变化时获取其详细信息。希望你也注意到了我们示例中的下一个问题。每次PokemonSearch重新渲染(假设不是由于某个过滤器的更改导致),它都会创建一个指向我们filters对象的新引用,而这个新引用在引用上不会与上一个引用相等,从而导致useEffect每次渲染时都会运行PokemonSearch,从而产生大量不必要的 API 调用。

让我们解决这个问题useMemo-

const filters = React.useMemo(() => ({
  weight,
  power,
  searchquery,
}), [weight, power, searchQuery]);
Enter fullscreen mode Exit fullscreen mode

现在,只有当我们的任何一个过滤器发生变化时,过滤器对象引用才会更新,因此useEffect只有当我们的其中一个过滤器发生变化时才会调用。

因此,经过所有优化的最终代码如下所示 -

const PokemonSearch = ({ weight, power, realtimeStats }) => {
  const [searchquery, setSearchQuery] = React.useState('');

  const filters = React.useMemo(() => ({
    weight,
    power,
    searchquery,
  }), [weight, power, searchQuery]);

  const { isLoading, result } = usePokemonSearch(filters);

  const updateQuery = React.useCallback(newQuery => {
    /**
     * Some other stuff related to
     * analytics, omitted for brevity
     */
    setSearchQuery(newQuery);
  }, []);

  return (
    <>
      <RealTimeStats stats={realtimeStats} />

      <MemoizedSearch query={searchquery} updateQuery={updateQuery} />

      <SearchResult data={result} isLoading={isLoading} />
    </>
  );
};

const usePokemonSearch = filters => {
  const [isLoading, setLoading] = React.useState(false);

  const [result, setResult] = React.useState(null);

  React.useEffect(() => {
    /**
     * Fetch the pokemons using filters
     * and update the loading and result state
     * accordingly, omitted for brevity
     */
  }, [filters]);

  return { result, isLoading };
};
Enter fullscreen mode Exit fullscreen mode

避免重新计算昂贵的计算

除了引用相等之外,useMemo钩子与memo函数类似,还有一个目的,即避免在不需要时每次渲染时重新计算昂贵的计算。

例如,以下面这个例子,如果你尝试快速更新名称,你将会看到一定的滞后,因为第 35 个斐波那契数(故意设计得很慢,并在计算时阻塞主线程)每次你的组件重新渲染时都会被计算,即使位置保持不变。

现在让我们用 试试useMemo。再次尝试快速更新名称,看看有什么区别 -

我们useMemo只在位置发生变化时重新计算斐波那契数,从而避免了不必要的主线程工作。

备忘录

如果您的组件在给定相同 props 的情况下重新渲染相同的结果,React.memo则如果 props 没有改变,则可以通过跳过重新渲染来提高性能。

Dmitri 在他的文章《明智地使用 React.memo()》中创建了一个非常好的例子,当你考虑记忆一个组件时,你应该使用一个一般的经验法则。

何时应该使用 React.memo

概念讲得够多了,我们来举个例子来理解一下React.memo。在下面的沙盒中,我们有一个usePokemon钩子函数,它会返回一些 Pokemon 的静态和实时数据。

静态信息包括宝可梦的名称、图片和能力。相比之下,实时信息则包含想要该宝可梦的人数以及拥有该宝可梦的人数等信息,这些信息经常变化。

这些细节由三个组件呈现PokemonDetails,分别为呈现静态细节,和,Cravers以及Owners呈现实时信息。

现在,如果你检查上面沙盒中的控制台,你会发现它看起来不太好,因为即使它PokemonDetails由静态数据组成,每次我们的实时值发生变化时,它仍然会重新渲染,这性能很差。所以,让我们使用上面提到的 Dmitri 的 Checklist 来看看是否应该记录它——

  • 它是一个纯功能组件吗?给定相同的 props 会呈现相同的输出吗?

    是的,我们的PokemonDetails组件是功能性的,并使用相同的 props 呈现相同的输出✅

  • 它是否经常重新渲染?

    是的,由于我们的自定义钩子提供的实时值,它经常重新渲染✅

  • 它是否使用相同的道具重新渲染?

    是的,它使用的道具在所有渲染中都不会改变✅

  • 它是一个中型到大型尺寸的组件吗?

    由于这是一个非常人为的例子,它实际上并不不在沙箱中,但为了这个例子,我们假设它是(虽然虽然不是很昂贵,但考虑到它满足上述三个条件,它仍然是一个很好的记忆案例)✅

由于我们的组件满足上述条件,因此让我们将其记忆起来 -

如果您检查上述沙箱中的控制台,您会发现它只重新渲染一次,通过节省可能昂贵的重新渲染,大大优化了我们的代码。

结论

如果你读到这里,我想你已经明白我的意思了。我再说一遍,每一项优化都有相应的成本,只有当优化的收益大于成本时,优化才值得。在大多数情况下,如果你能将经常变化的部分与不经常变化的部分区分开来,就像我们上面讨论的那样,你甚至可能不需要应用这些方法。

我知道这有点烦人,也许在未来,一些真正智能的编译器可以自动为您处理这些事情,但在那之前,我们在使用这些优化时必须小心谨慎。

我以前读过这个吗?

你可能已经注意到了,因为其中某些部分受到了Kent C. Dodds这篇优秀文章的启发。我很喜欢这篇文章,也想结合我遇到的一些情况来分享一些想法。然而,我仍然看到许多博客文章和代码片段在不必要的地方使用了这些方法,所以我认为这方面值得更多关注。

文章来源:https://dev.to/psuranas/when-should-you-memoize-in-react-1fjc
PREV
如何在 GitHub 仓库中隐藏 API KEY
NEXT
React 和 PDF 渲染