likes
comments
collection
share

如何用好 useMemo 和 useCallback:你可以删除你应用程序中90%的它们

作者站长头像
站长
· 阅读数 13

本文源于翻译:How to useMemo and useCallback: you can remove most of them 译者注:本文对比了排序计算与重新渲染的耗时,并讲解了 useMemo 对首次渲染性能的影响,从而说明 useMemouseCallback 应当用于缓存渲染的结果,而不是缓存 props,并指出了我们常见的错误用法,值得深读。

useMemouseCallback 是React中的两个钩子函数,它们的目的是优化组件的性能。文本将从使用目的、常见错误用法、最佳实践等多个角度解释如何用好这两个勾子。

如果你对React并不是完全陌生,你可能已经对 useMemouseCallback 这两个钩子函数有所了解。如果你在一个中大型应用程序上工作,很有可能你可以将你的应用程序的某些部分描述为“一连串难以理解的 useMemouseCallback,让人无法阅读和调试”。这些钩子函数似乎有一种不受控地在代码中扩散的能力,直到它们完全掌控了代码,你发现自己只是因为它们无处不在,周围的每个人都在使用它们而使用它们。

你知道令人伤心的是什么吗?这一切都是完全没有必要的。你现在可能可以删除你的应用程序中90%的 useMemouseCallback,应用程序仍然可以正常运行,甚至可能稍微变快。别误会我的意思,我并不是说 useMemouseCallback 是无用的。只是它们的使用仅限于一些非常特定和具体的情况。而大部分时间,我们把一些不需要的东西包裹在它们里面。

所以今天我想讨论的是:开发者在使用 useMemouseCallback 时犯了哪些错误,它们的实际用途是什么,以及如何正确使用它们。

这两个钩子函数在应用程序中扩散的主要原因有两个:

  1. 对props进行记忆化,以防止重新渲染
  2. 对值进行记忆化,以避免在每次重新渲染时进行昂贵的计算

我们稍后会详细看一下它们,但首先,让我们了解一下 useMemouseCallback 的确切目的。

为什么我们需要 useMemouseCallback

答案很简单-在重新渲染之间进行记忆化。如果一个值或一个函数被这些钩子函数包裹,React在初始渲染期间会将其缓存,并在后续的渲染中返回对保存值的引用。如果没有记忆化,非原始值(如数组、对象或函数)将在每次重新渲染时从头开始重新创建。当这些值进行比较时,记忆化是有用的。

接下来,让我们来看一些代码示例来帮助理解。

const a = { "test": 1 };
const b = { "test": 1'};

console.log(a === b); // 返回 false

const c = a; // "c" 仅仅是 "a" 的引用

console.log(a === c); // 返回 true

以上代码中,ab 是两个不同的对象,尽管它们的属性值相同,但它们并不相等。而 c 只是对 a 的引用,所以它们是相等的。

如果我们将其应用到更接近React的场景中,可以看到:

const Component = () => {
  const a = { test: 1 };

  useEffect(() => {
    // "a" 将在重渲染中被比较
  }, [a]);

  // ...一些业务逻辑
};

这里的 auseEffect 钩子函数的一个依赖项。在 Component 重新渲染时,React会将它与前一个值进行比较。a 是在 Component 内部定义的一个对象,这意味着在每次重新渲染时它将会被从头创建。因此,在“重新渲染前”和“重新渲染后”比较时,将返回false,并且 useEffect 将在每次重新渲染时被触发。

为了避免这种情况,我们可以使用 useMemo 钩子函数来包裹 a 的值:

const Component = () => {
  // 在多次重渲染间缓存 `a` 的引用
  const a = useMemo(() => ({ test: 1 }), []);

  useEffect(() => {
    // 仅仅只会在 `a` 发生实际变化时触发
  }, [a]);

  // ...一些业务逻辑
};

现在,只有当 a 的值实际变化时(注意,在此实现例子中 a从不变化),useEffect 才会被触发。

对于 useCallback,同样的原理,只是它更适用于记忆化函数:

const Component = () => {
  // 在多次重渲染间缓存 fecth 函数
  const fetch = useCallback(() => {
    console.log('fetch some data here');
  }, []);

  useEffect(() => {
    // 仅仅在 fetch 函数发生实际变化时触发执行
    fetch();
  }, [fetch]);

  // ...一些业务代码
};

在这里,重要的是要记住,useMemouseCallback 只在重新渲染阶段有用。在初始渲染期间,它们不仅没有用,而且甚至有害:它们会让React做一些额外的工作。这意味着你的应用程序在初始渲染期间会变得稍微慢一些。如果你的应用程序在各个地方都有成百上千的这些钩子函数,这种减慢甚至可能是可衡量的。

记忆化props以防止重新渲染

现在我们知道了这些钩子函数的用途,让我们看看它们的实际应用。其中一个最重要和最常用的用法是记忆化props的值以防止重新渲染。如果你在你的应用程序中的某个地方看到过下面的代码,请发出一些声音:

  1. 为了防止重新渲染,不得不将onClick包装在useCallback中
const Component = () => {
  const onClick = useCallback(() => {
    /* 执行一些操作 */
  }, []);
  return (
    <>
      <button onClick={onClick}>点击我</button>
      ... // 其他组件
    </>
  );
};
  1. 为了防止重新渲染,不得不将onClick包装在useCallback中
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 包裹在 useMemo 中,因为它是一个 memoized onClick 的依赖项:
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已经控制了你的生活,并且是没有必要的。在所有的示例中,这些钩子函数是无用的,不必要地,且复杂化了代码,减慢了初始渲染,并且没有防止任何问题的。

要理解其中的原因,我们需要真正理解React是如何工作的一个重要事实:组件为什么会发生重新渲染

为什么组件会重新渲染?

“当状态或prop值改变时,组件会重新渲染”是常识。即使React文档也是这样表述的。而我认为这个说法正是导致了“如果props不变化(即记忆化),那么它将防止组件重新渲染”的错误结论。

因为组件重新渲染的另一个非常重要的原因是:当其父组件重新渲染时。或者,如果我们从相反的方向来看:当一个组件重新渲染时,它也会重新渲染所有的子组件。以以下代码为例:

const App = () => {
  const [state, setState] = useState(1);

  return (
    <div className="App">
      <button onClick={() => setState(state + 1)}> 点击重新渲染 {state}</button>
      <br />
      <Page />
    </div>
  );
};

App组件有一些状态和一些子组件,其中包括Page组件。当点击按钮时会发生什么?状态会改变,它会触发App的重新渲染,这将触发所有子组件的重新渲染,包括Page组件。甚至它自己都没有props!

现在,在Page组件内部,如果我们还有其他子组件:

const Page = () => <Item />;

这个Page组件完全为空,既没有状态也没有props。但是当App重新渲染时,它的重新渲染将被触发,并且它将触发其Item子组件的重新渲染。App组件状态的变化会触发整个应用程序中重新渲染的连锁反应。在此codesandbox中查看完整示例。

唯一中断这个连锁反应的方法是对其中的一些组件进行记忆化。我们可以使用useMemo钩子函数来实现,或者更好地使用React.memo工具。只有当组件被包裹在这些记忆化工具中时,React才会在重新渲染之前停下来,检查props值是否发生变化。

记忆化组件:

const Page = () => <Item />;
const PageMemoized = React.memo(Page);

在App中使用它进行状态更改:

const App = () => {
  const [state, setState] = useState(1);

  return (
    ... // 之前的代码
      <PageMemoized />
  );
};

在这种情况下,props是否被记忆化是重要的。

举个例子,假设Page组件有一个接受函数的onClick prop。如果我直接将其传递给Page而不记忆化它,会发生什么?

const App = () => {
  const [state, setState] = useState(1);
  const onClick = () => {
    console.log('点击时执行一些操作');
  };
  return (
    // 无论是否记忆化onClick,Page都将重新渲染
    <Page onClick={onClick} />
  );
};

App将重新渲染,React会在其子组件中找到Page,并重新渲染它。无论是否使用useCallback包裹onClick都没有影响。

如果我记忆化Page呢?

const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = () => {
    console.log('点击时执行一些操作');
  };
  return (
    // PageMemoized将重新渲染,因为onClick没有被记忆化
    <PageMemoized onClick={onClick} />
  );
};

App 将重新渲染,React会在其子组件中找到被 React.memo 包裹的**PageMemoized** ,它会停下来检查它是否有 props 发生变化。在这种情况下,由于**onClick** 是一个没有被记忆化的函数,props 比较的结果将失败,PageMemoized 将重新渲染自己。最终,useCallback 发挥了一些作用:

const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = useCallback(() => {
    console.log('点击时执行一些操作');
  }, []);

  return (
    // PageMemoized将不会重新渲染,因为onClick被记忆化了
    <PageMemoized onClick={onClick} />
  );
};

现在,当React停在 PageMemoized 上检查其props时,onClick 将保持不变,PageMemoized将不会重新渲染。

如果我在PageMemoized中添加另一个未记忆化的值呢?结果和上述情况完全相同:

const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = useCallback(() => {
    console.log('点击时执行一些操作');
  }, []);

  return (
    // Page将重新渲染,因为value没有被记忆化
    <PageMemoized onClick={onClick} value={[1, 2, 3]} />
  );
};

当React停在 PageMemoized 上检查其 props 时,onClick 将保持不变,但 value 将发生变化,PageMemoized 将重新渲染自己。在这里查看完整示例,尝试移除记忆化以查看所有内容如何重新渲染。

综上所述,只有在一个场景中记忆化 props 的组件才是有意义的:当每个单独的 prop 以及组件本身都被记忆化时。其他情况下,它们只是浪费内存,并且不必要地复杂化了代码。

如果满足以下条件之一,请随意从代码中删除所有的 useMemouseCallback

  • 将它们直接或通过一系列依赖关系传递给DOM元素的属性;
  • 将它们直接或通过一系列依赖关系传递给未进行记忆化的组件的属性;
  • 将它们直接或通过一系列依赖关系传递给至少一个未进行记忆化的prop的组件。

为什么要删除,而不是修复记忆化呢?如果由于重新渲染在该区域存在性能问题,那么你早就应该注意到并修复了,不是吗?既然没有性能问题,就没有必要修复它。删除无用的 useMemouseCallback 将简化代码,并稍微加快初始渲染,而不会对现有的重新渲染性能产生负面影响。

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

根据React文档useMemo 的主要目的是避免在每次渲染时进行昂贵的计算。然而,它没有明确指出什么样的计算属于“昂贵”的范畴。结果,开发者有时会将几乎每个计算都包裹在 useMemo 中。创建一个新的日期?对数组进行过滤、映射或排序?创建一个对象?所有都用 useMemo 包裹起来!

好的,让我们来看看一些数据。假设我们有一个包含国家名称的数组(大约250个国家),我们需要根据用户的语言首选项对其进行排序:

const List = ({ countries }) => {
  // 排序国家名称
  const sortedCountries = orderBy(countries, 'name', sort);

  return (
    <>
      {sortedCountries.map((country) => (
        <Item country={country} key={country.id} />
      ))}
    </>
  );
};

这看起来很合理,对吧?我们只在组件加载时进行一次排序,然后在后续的渲染中重用结果。这肯定比在每次重新渲染时对整个数组进行排序要好得多,对吗?。 但是,这个例子中的计算是否真的属于“昂贵”的范畴?让我们来衡量它的性能:

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倍的差距!可以在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 可以将整个组件的不必要的重新渲染时间从约20毫秒减少到不到2毫秒。

考虑到上述情况,我要提出的有关记忆化"昂贵"操作的规则是:除非你真的在计算大数的阶乘,否则在所有纯 JavaScript 操作上删除 useMemo 钩子。重新渲染子组件总是性能瓶颈所在,应该只使用 useMemo 来记忆化渲染树中的重要部分。

然而,为什么要删除它们呢? 难道将所有操作都进行记忆化不是更好吗?难道删除它们不会对性能产生负面影响吗?一毫秒在这里,两毫秒在那里,很快我们的应用程序就不会像它本应该那样快了...

这个观点是很有道理的。如果没有一个例外情况的话,这种思维是100%正确的:记忆化并不是没有代价的。如果我们使用了 useMemo,在初始渲染过程中,React需要缓存结果值,这需要时间。是的,这个时间非常短,对于我们上面的应用程序,记忆化排序后的国家列表花费不到一毫秒的时间。但是!这将是真正的复合效应。初始渲染发生在应用程序首次出现在屏幕上时。每个应该显示的组件都要经历初始渲染。在一个拥有数百个组件的大型应用程序中,即使有三分之一的组件进行了记忆化,这可能会导致初始渲染增加10、20,最坏的情况甚至可能增加100毫秒。

另一方面,重新渲染仅在应用程序的某个部分发生更改后才发生。在良好架构的应用程序中,只有这个特定的小部分会重新渲染,而不是整个应用程序。在那个发生更改的部分中,有多少类似上面案例中的"计算"?2-3个?假设是5个。每个记忆化将节省不到2毫秒的时间,即总共少于10毫秒。这10毫秒可能发生也可能不发生(取决于是否发生触发它的事件),它们对于肉眼来说是不可见的,并且会在子组件的重新渲染中丢失,这些重新渲染本身就需要10倍的时间。而这是以降低始终会发生的初始渲染速度为代价的 😔。

因此,在权衡利弊时,删除不必要的 useMemouseCallback 可以降低初始渲染的时间,而这对于整体性能并没有实质的损害。重新渲染只会在应用程序的特定部分发生变化时才发生,每个记忆化操作所节省的时间非常有限。这样的做法可以简化代码,并在不显著影响现有重新渲染性能的情况下,稍微加快初始渲染的速度。

结论

这里有相当多的信息需要消化了,希望您觉得它们有用,并且现在渴望审查您的应用程序,摆脱那些无用的 useMemouseCallback,它们无意中占据了您的代码。在您离开之前,这里有一个简要总结,以巩固您的知识:

  • useCallbackuseMemo 是仅对连续渲染(即重新渲染)有用的钩子,在组件的初始渲染过程中实际上是有害的。
  • useCallbackuseMemo 本身不能防止 props 的重新渲染。只有当每个单独的 prop 和组件本身都被记忆化时,才能防止重新渲染。一次错误的记忆化就会使这些钩子变得无用。如果发现它们是多余的,请将其删除。
  • 移除在“原生”JavaScript操作周围使用useMemo,与组件更新相比,这些操作是看不见的,并且只会在初始渲染过程中消耗额外的内存和宝贵的时间。
  • 还有一件小事:考虑到所有这些都是多么复杂和脆弱,为了性能优化,useMemouseCallback 实际上应该是最后的选择。请首先尝试其他性能优化技术。请查看上述文章,了解其中一些技术的描述。

当然,还有一点需要强调:首先进行测量!

愿今天成为您告别"useMemouseCallback 地狱"的最后一天!✌🏼

另外,不要忘记观看YouTube视频,它通过一些精美的动画来解释文章的内容。

转载自:https://juejin.cn/post/7255588784589570104
评论
请登录