likes
comments
collection
share

[译] 如何更好使用 useMemo 和 useCallback:你可以移除其中大部分的错误使用

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

原文地址:www.developerway.com/posts/how-t…

原文作者:NADIA MAKAREVICH

原文发布于:2022 年 06 月 13 日

设计 useMemo 和 useCallback 的目的是什么,使用它们时的常见错误和最佳实践,以及为什么绝大部分的使用可以移除掉,并且可能是更好的选择。

如果您不是 React 的完全新手,您可能至少已经熟悉了 useMemouseCallback Hook。如果您正在开发一个中型到大型应用程序,您很可能会把应用程序的某些部分描述为“一连串难以理解的 useMemouseCallback,其理解和调试难于登天”。这些 Hook 有一种不受控制地在代码中四处传播的能力,直到它们完全占据了代码,你会发现自己编写它们的原因只是因为它们无处不在,你周围的每个人都在编写它们。

你知道最可悲的是什么吗?所有这一切都完全没有必要。您现在就可以移除应用程序中 90% 的 useMemouseCallback,这样应用程序也不会有问题,甚至可能会稍微快一点。别误会,我并不是说 useMemouseCallback 毫无用处。只是说它们的使用仅限于一些非常特殊和具体的情况。而且大多数情况下,我们只是无效的一直用它们来把东西包裹起来。

这就是我今天要讨论的话题:开发人员在使用 useMemouseCallback 时会犯哪些错误,它们的实际用途是什么,以及如何正确使用它们。

这些 Hook 在应用程序中的病毒式传播主要有两个来源:

  • 对 props 进行记忆,以避免重渲染。
  • 对那些高开销计算得来的值进行记忆,避免每次重渲染中都需要重新计算。

我们将在文章的稍后部分了解它们,但首先:useMemouseCallback 的目的究竟是什么?

为什么我们需要 useMemo 和 useCallback

答案很简单——在重新渲染之间记忆化。如果一个值或一个函数被封装在这些 Hook 中,React 会在初始渲染时缓存它,并在接下来重新渲染时返回对该保存值的引用。没有它,像数组、对象或函数这样的非原始值将在每次重新渲染时从头开始创建。不熟悉这个 javascript 机制的可以复习一下:

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]);

  // the rest of the code
};

a 的值是 useEffect 的依赖项。。每次 Component 重新渲染时,React 都会将其与之前的值进行比较。a 是定义在 Component 中的对象,这意味着每次重新渲染时,它都将从头开始重新创建。因此,将 “重新渲染前”的值与“重新渲染后”的值进行比较将返回 false,并且每次重新渲染都会触发 useEffect

为了避免这种情况,我们通常会用 useMemoa 包裹起来:

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

  useEffect(() => {
    // this will be triggered only when "a" value actually changes
  }, [a]);

  // the rest of the code
};

现在,只有当 a 值实际发生变化时(在这个实例中永远不会发生变化),才会触发 useEffect

useCallback 的原理也是一样,只是它更适合用来对函数记忆化:

const Component = () => {
  // 在每次重渲染之间保存函数引用
  const fetch = useCallback(() => {
    console.log('fetch some data here');
  }, []);

  useEffect(() => {
    // 只有当 fetch 的值变化时才会触发
    fetch();
  }, [fetch]);

  // the rest of the code
};

这里最重要的一点是,useMemouseCallback 只在重新渲染阶段有用。在初始渲染时,它们不仅无用,甚至有害:它们会让 React 做一些额外的工作。 这意味着您的应用程序在初始渲染时会变得稍慢。如果您的应用程序中有成百上千个这样的过程,那么这种变慢甚至是可感知的。

通过记忆化 props 来阻止重新渲染

既然我们已经知道了这些 Hook 的用途,那就来看看它们的实际用法吧。其中最重要也是最常用的 Hook 之一就是将 props 记忆化,来阻止重新渲染。如果你在你的应用程序中看到过下面的代码,请大声说出来:

  1. 不得不将 onClick 包在 useCallback 中,以阻止重新渲染:
const Component = () => {
  const onClick = useCallback(() => {
    /* do something */
  }, []);
  return (
    <>
      <button onClick={onClick}>Click me</button>
      ... // some other components
    </>
  );
};
  1. 不得不将 onClick 包在 useCallback 中,以阻止重新渲染:
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} />
      ))}
    </>
  );
};
  1. 不得不将 value 包裹在 useMemo 中,因为它是被记忆化的 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} />
      ))}
    </>
  );
};

你有做过,或看到周围其他人做过这些优化吗?你是否同意这些用例以及 Hook 是如何解决这个问题的?如果这些问题的答案是“是”,那么恭喜你:useMemouseCallback 绑架了你,不必要地控制了你的生活。在所有例子中,这些 Hook 都是无用的,它们不必要地使代码复杂化,减慢了初始渲染速度,却什么也阻止不了。

要了解原因,我们需要记住 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 的重新渲染,并触发其所有子组件(包括 Page 组件)的重新渲染。Page 组件甚至没有接受 props!

现在,假如 Page组件含有这样一些子组件:

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

它完全是空的,既没有 state 也没有 props。但当 App 重新渲染时,Page 的重新渲染将被触发,并因此触发其 Item 子项的重新渲染。App 组件状态的改变会触发整个应用的一连串重新渲染。请参阅 codesandbox 中的完整示例

中断这个链条的唯一方法就是将其中的某些组件记忆化(memoized)。我们可以使用 useMemo 钩子,或者更好的是使用 React.memo 函数。只有当组件被包装后,React 才会通过检查它的 props 是否发生变化,阻止重新渲染。

记忆化(memoized)组件:

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

在有状态变化的应用中使用:

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

  return (
    ... // same code as before
      <PageMemoized />
  );
};

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

举例说明,假设 Page 组件有一个 props onClick,接受一个函数。如果我不先对其进行记忆化就将其传递给 Page,会发生什么情况?

const App = () => {
  const [state, setState] = useState(1);
  const onClick = () => {
    console.log('Do something on click');
  };
  return (
    // 无论 onClick 是否被记忆化,Page 都会重新渲染
    <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 封装,停止重新渲染链,并首先检查该组件上的 props 是否发生变化。在这种情况下,由于 onClick 是一个未被记忆化的函数,props 比较的结果是 false,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 不会重新渲染
    <PageMemoized onClick={onClick} />
  );
};

现在,当 React 停止重新渲染链,并对 PageMemoized 进行 props 检查时,因为 onClick 的引用保持不变,检查结果为 true,PageMemoized 将不会重新渲染。

如果我在 PageMemoized 中添加另一个非记忆化值,会发生什么情况?其他代码是完全一样的:

const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = useCallback(() => {
    console.log('Do something on click');
  }, []);

  return (
    // PageMemoized 会重新渲染
    <PageMemoized onClick={onClick} value={[1, 2, 3]} />
  );
};

React 在停止重新渲染链,并对 PageMemoized 进行 props 检查时,onClick 保持不变,但 value 改变了。PageMemoized 将重新渲染。请参见此处的完整示例,尝试移除记忆化,看看一切是如何重新渲染的。

综上所述,只有一种情况下对组件上的 props 进行记忆化才有意义:即对每个 props 和组件本身都进行记忆化。其他情况都只是在浪费内存,并使代码不必要地复杂化。

如果需要,可随意删除这些代码中的所有 useMemouseCallbacks

  • 它们作为属性直接、或通过依赖链传递给 DOM 元素。
  • 它们作为属性直接、或通过依赖链传递给未记忆化的组件。
  • 它们作为属性直接、或通过依赖链传递给已经记忆化,但至少有一个属性入参未记忆化的组件。

为什么要删除而不只是修复记忆化?好吧,如果因为未记忆化而导致重新渲染出现了性能问题,你早就注意到并修复了,不是吗?😉 既然没有性能问题,就没有必要修复它。移除无用的 useMemouseCallback 可以简化代码,加快初始渲染速度,但不会对现有的重新渲染性能产生负面影响。

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

根据 React 文档,useMemo 的主要目标是避免每次渲染都进行昂贵的计算。甚少有人仔细想过什么是“昂贵”的计算。因此,开发人员有时会在渲染函数中使用 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
  )
};

最终结果如何?在不使用记忆化的情况下,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 可将整个组件不必要的重新渲染时间从约 20 毫秒降至 2 毫秒以下。

综上所述,这就是我想介绍的关于将“昂贵”操作记忆化的规则:除非你实际上是在计算大数字的阶乘,否则请移除所有纯 javascript 操作上的 useMemo Hook。重新渲染子代将始终是你的瓶颈。只有在渲染树的复杂部分才使用 useMemo

为什么要把它们都删除?把所有东西都记忆化(memoized)不是更好吗?如果我们将它们全部删除,会不会产生降低性能的复合效应?1 毫秒在这里,2 毫秒在那里,很快我们应用的速度就会大打折扣......

有道理。如果不是有一点需要注意:记忆化(memoized)不是免费的。如果我们使用 useMemo,在初始渲染期间,React 需要缓存结果值——这需要时间。是的,这将是很小的一部分,在我们上面的应用中,对这些排序过的国家进行记忆化只需不到一毫秒的时间。但是!这也将是真正的复合效果。当应用第一次出现在屏幕上时,就会进行初始渲染。每个应该出现的组件都要经历它。在一个拥有数百个组件的大型应用程序中,即使其中三分之一的组件对某些内容进行了记忆化,也会导致初始渲染时间增加 10、20 毫秒,最糟糕的情况下甚至可能会增加 100 毫秒。

另一方面,重新渲染只发生在应用程序的某一部分发生变化之后。而在架构良好的应用程序中,只有这一小部分需要重新渲染,而不是整个应用程序。与上述情况类似的“计算”有多少会出现在变化的部分中?2-3? 还是 5 个?每次记忆化将为我们节省少于 2 毫秒的时间,即总体节省少于 10 毫秒。这 10 毫秒可能会发生,也可能不会发生(取决于触发它的事件是否发生),肉眼无法看到,而且会在子组件们的重新渲染中丢失,而重新渲染无论如何都会花费 10 倍于此的时间。但代价是初始渲染的速度会变慢,而这是必然会发生的😔。

今天就到此为止吧

这次讨论到的信息量很大,希望对你有用,现在你已经迫不及待地想检查你的应用程序代码,并删除所有无用的 useMemouseCallback,因为它们不小心占据了你的代码。在你打算这么做之前,我们快速总结一下:

  • useCallbackuseMemo 是仅对连续渲染(即重新渲染)有用的 Hook,对于初始渲染来说,它们实际上是有害的。
  • props 的 useCallbackuseMemo 本身并不能防止重新渲染。只有当每个 props 和组件本身都被记忆化时,才能防止重新渲染。只要漏了一个,一切就都完了,这些 Hook 也就失去了作用。
  • 移除“原始” JavaScript 操作周围的 useMemo —— 与组件更新相比,这些操作是不可见的,只会在初始渲染时占用额外的内存和宝贵的时间。

还有一件小事:考虑到这一切是多么复杂和脆弱,useMemouseCallback 确实应该是你最后的性能优化手段。请先尝试其他性能优化技术。请参阅介绍其中一些技术的文章:

当然,不言而喻:测量第一!没有基于测量结果评判的优化都是无效优化。

愿今天是你在 useMemouseCallback 地狱中的最后一天!✌🏼

此外,还可以查看 YouTube 视频,其中使用了一些漂亮的动画来解释文章内容。