如何用好 useMemo 和 useCallback:你可以移除它们中的大多数

如何用好 useMemouseCallback:你可以移除它们中的大部分

本文源于翻译:How to useMemo and useCallback: you can remove most of them

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 = () => {



  // 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做一些额外的工作。这意味着你的应用程序在初始渲染期间会变得稍微慢一些。如果你的应用程序在各个地方都有成百上千的这些钩子函数,这种减慢甚至可能是可衡量的。

记忆化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以及组件本身都被记忆化时。其他情况下,它们只是浪费内存,并且不必要地复杂化了代码。

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

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

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

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

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

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

const sortedCountries = useMemo(() => {
  return countries.sort((a, b) => {
    const nameA = a.name.toUpperCase();
    const nameB = b.name.toUpperCase();
    if (nameA < nameB) {
      return -1;
    }
    if (nameA > nameB) {
      return 1;
    }
    return 0;
  });
}, []);

这看起来很合理,对吧?我们只在组件加载时进行一次排序,然后在后续的渲染中重用结果。这肯定比在每次重新渲染时对整个数组进行排序要好得多。

但是,这个例子中的计算是否真的属于“昂贵”的范畴?让我们使用**console.timeconsole.timeEnd**来衡量它的性能:

console.time('排序');
const sortedCountries = useMemo(() => {
  return countries.sort((a, b) => {
    const nameA = a.name.toUpperCase();
    const nameB = b.name.toUpperCase();
    if (nameA < nameB) {
      return -1;
    }
    if (nameA > nameB) {
      return 1;
    }
    return 0;
  });
}, []);
console.timeEnd('排序');

我在我的机器上运行了这段代码,浏览器的控制台显示了排序所花费的时间。结果是:

makefileCopy code
排序: 0.006ms

这真的很快!在每次重新渲染时进行此排序可能会花费一些时间,但对于现代计算机而言,这种计算可以在瞬间完成。

这并不是说我们不应该使用**useMemo来记忆化排序结果。如果在组件树的某个层级上,这个排序结果被传递给了多个子组件,那么记忆化将确实提供一些好处。但如果这个排序结果只在当前组件内部使用,并且重新排序的成本非常低,那么使用useMemo**可能是一种过度优化。

因此,当你考虑将计算结果进行记忆化时,请确保该计算真正昂贵到需要优化,并进行性能测试来验证。

最佳实践和结论

在使用useMemo和useCallback时,有几个最佳实践可以帮助你正确使用它们:

  1. 只在必要时使用:确保你在使用这些钩子函数时有明确的理由,并且在性能问题出现之前不要过度优化。
  2. 避免不必要的嵌套:只在你知道prop或值的确实变化时才进行记忆化。不必要的嵌套会增加复杂性,并可能导致错误。
  3. 将记忆化限制在组件树的适当层级上:将记忆化限制在需要它的组件及其子组件上,而不是在整个应用程序中广泛使用。
  4. 了解何时需要记忆化:只有在props或值的变化会导致组件重新渲染时,记忆化才有意义。如果组件在其父组件重新渲染时也需要重新渲染,那么记忆化可能是不必要的。
  5. 性能测试:在考虑使用记忆化时,进行性能测试以确保它真正解决了性能问题。

最后,记住这样一句话:“简单即可预测,复杂则容易出错”。遵循简单的规则和最佳实践,只在必要时使用useMemo和useCallback,可以避免复杂性,并保持代码的可读性和可维护性。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYbpFBxV' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片