• 作者:老汪软件技巧
  • 发表时间:2024-11-13 00:02
  • 浏览量:

image.png

1 前言

在介绍useMemo和useCallback之前,让我们先讲讲React的基本工作原理:React的核心功能是保持用户界面(UI)与应用状态同步。为了实现这种同步,React需要进行一个称为 “re-render” (重新渲染) 的过程。每一次重新渲染都是对当前应用状态的快照,它告诉我们在某一特定时刻下,应用的UI应该是什么样子的。

React进行了大量的优化,所以在一般情况下,重新渲染并不是什么大问题。但是在某些场景下,重新渲染会进行一些高开销的操作,这些快照则需要更多的时间来创建,UI不能快速同步更新,最终导致性能问题。

useMemo和useCallback就是用来帮助我们优化重新渲染的工具hook。

本文将通过分析该 useMemo和useCallback的使用目的、方式以及具体使用场景,帮助开发者正确的决定如何适时的使用这些hook。

2 使用useMemo

我们先从 useMemo 开始介绍,useMemo 是一个 React Hook(钩子函数),它提供了 “记录” 每次渲染之间的计算值的能力,可以捕获在渲染过程中进行的计算,并在每次状态更改时重复使用这些计算结果,从而避免不必要的重新计算。这就像一个高效的“记忆”系统,可以在每次渲染之间保存和重复使用计算结果,从而提高应用的性能。

这段话不放出来⚠️:useMemo 可以将某些函数的计算结果(返回值)挂载到 React 底层原型链上,并返回该函数返回值的索引。当组件重新渲染时,如果 useMemo 的依赖项未发生变化,那么直接使用原型链上缓存的该函数计算结果,跳过本次无意义的重新计算,从而达到提高组件性能的目的。

使用 useMemo 通常用于实现以下目的:

下面将对这两种场景进行详细介绍。

2.1 减少不必要的计算工作量

Javascript 运行时是单线程的,若每次渲染都执行一个高开销的计算(比如阶乘计算,大量数据遍历计算,或者从内存中取值后运算等),那么每次渲染前,该计算逻辑会占用线程资源较长时间,导致其他任务没法快速执行,整个应用会让人感觉很迟钝,尤其是在低性能的设备上感知更加明显。

const List = ({ keyword }) => {
  const visibleItems = useMemo(() => {
    const searchOptions = { page: 1, pageSize: 10, keyword };
    // 假设 searchItems 方法包含了高开销的计算
    return searchItems(searchOptions);
  }, [keyword]);
  useEffect(() => {
    doSomething();
  }, [visibleItems]);
  // ...
};

在上面的例子中,searchItems方法包含了高开销的计算,如果 keyword 没有改变,visibleItems 也不会改变,也就不会重复执行 effect。

若上面例子中 searchItems 方法不是一个高开销的计算逻辑,而只是简单的数据处理逻辑,更好的解决方法是 keyword 作为 useEffect 的依赖,并将数据计算逻辑放置在 useEffect 的内部,以减少useMemo 带来的不必要的资源消耗:

const List = ({ keyword }) => {
  useEffect(() => {
    // 只有当 keyword 的值变化时,effect 才会被触发
    const searchOptions = { page: 1, pageSize: 10, keyword };
    const visibleItems = searchItems(searchOptions);
    doSomething();
  }, [keyword]);
  // ...
};

在实际开发过程中,高开销的计算其实极少出现,如下示例,对包含 250 个元素的数组 countries 进行排序、渲染,并计算耗时:![image-20231120194504481](/Users/theo.zhou/Library/Application Support/typora-user-images/image-20231120194504481.png)

可以看到,排序耗时仅用了 1.8 毫秒,而渲染图中的组件(仅仅只是按钮 + 文字)却用了 7.6 毫秒

注:由于文章篇幅,具体代码详见 这里

大部分情况下,我们的计算逻辑往往都比较简单,组件渲染要比这个组件复杂的多,所以真实程序中,计算和渲染的性能差距会更大。

可见,组件渲染才是性能的瓶颈,应该把 useMemo 用在程序里渲染昂贵的组件上,而不是常规的数值计算上(不包括高开销计算)。

2.2 减少组件需要渲染的次数

为了防止不必要的重新渲染,我们需要明确以下问题:

2.2.1 组件何时重新渲染

组件重新渲染的时机:

我们先来看以下例子:

正确使用地图一点都不能错_正确使用地板清洁液的方法_

const Page = ({ onClick }) => {
  // ...
};
const App = () => {
  const [count, setCount] = useState(1);
  const handleClick = useCallback(() => {
    console.log('do something');
  }, []);
  const handleAddCount = () => {
    setCount((value) => value + 1);
  }
  return (<div>
            <button onClick={handleAddCount}>add countbutton>
            <div>current count is: {count}div>
            <Page onClick={handleClick} />
          div>);
};

当点击按钮触发 count 的更新时,父组件 App 会发生重新渲染,由于子组件 Page 没有做缓存,因此也会跟着重新渲染。这里虽然使用了 useCallback 来缓存子组件的点击回调方法,但这个是完全无效的,它并不能阻止 Page 组件的重新渲染。

2.2.2 如何防止子组件重新渲染

为了阻止 Page 组件的重新渲染,必须同时缓存 onClick 方法(使用 useCallback)和组件本身(使用 React.memo):

const Page = React.memo(({ onClick }) => {
  // ...
});
const App = () => {
  const [count, setCount] = useState(1);
  const handleClick = useCallback(() => {
    console.log('do something');
  }, []);
  
  const handleAddCount = () => {
    setCount((value) => value + 1);
  }
  return (<div>
            <button onClick={handleAddCount}>add countbutton>
            <div>current count is: {count}div>
            <Page onClick={handleClick} />
          div>);
};

React.memo 会对传入的组件加上缓存功能生成一个新组件,然后返回这个新组件。在传给组件的 props 没有发生改变的情况下,它会使用最近一次缓存的结果,而不会进行重新的渲染,实现跳过组件渲染的效果。

然而,如果给 Page 组件再添加一个未被缓存的 props,一切就前功尽弃:

const Page = React.memo(({ onClick, value }) => {
  // ...
});
const App = () => {
  const [count, setCount] = useState(1);
  const valueList = [1, 2, 3];
  const handleClick = useCallback(() => {
    console.log('do something');
  }, []);
  const handleAddCount = () => {
    setCount((value) => value + 1);
  }
  return (<div>
            <button onClick={handleAddCount}>add countbutton>
            <div>current count is: {count}div>
            <Page onClick={handleClick} value={valueList} />
          div>);
};

上述代码的问题在于每次 React 重新渲染时,都会重新产生一个 valueList 数组,这个数组的值虽然每一次重新渲染都是相同的,但是它的 引用 却是不同的,这个情况会导致子组件仍然会触发重新渲染。

我们可以修改代码对 valueList 值进行缓存:

const Page = React.memo(({ onClick, value }) => {
  // ...
});
const App = () => {
  const [count, setCount] = useState(1);
  // 使用useRef缓存数据
  const valueRef = useRef([1, 2, 3]);
  const handleClick = useCallback(() => {
    console.log('do something');
  }, []);
  return (<div>
            <button onClick={handleAddCount}>add countbutton>
            <div>current count is: {count}div>
            <Page onClick={handleClick} value={valueRef.current} />
          div>);
};

可见,必须同时满足以下两个条件,子组件才不会重新渲染:

3 使用useCallback

本质上,useCallback和useMemo是一个东西,只是将返回值从 数组/对象 替换为了 函数。

函数与数组和对象类似,都是通过引用而不是通过值进行比较的:

const function1 = function() {return 1;}
const function2 = function() {return 1;}
console.log(function1 === function2); // false

因此,useCallback 的用途与 useMemo 相同,都是一种语法糖,useCallback的存在只是为了让我们在缓存回调函数的时候可以方便点。以下的两种实现方式的效果是相同的:

React.useCallback(function function1(){return 1;}, []);
// 功能等同于
React.useMemo(() => function function1(){return 1;}, []);

4 总结

尽管在某些情况下,useMemo和useCallback确实对性能优化有帮助并发挥着重要的作用,但很多开发者却没有正确地使用它,他们将函数式组件中所有的变量/函数都套上了useMemo和useCallback,期望能减少不必要的函数计算,进而达到性能优化的目的。

然而,不恰当地使用useMemo和useCallback可能没有带来任何性能上的优化,反而会增加了程序首次渲染的负担,增加程序的复杂度。

需要注意的是,useMemo和useCallback 仅在组件重新渲染阶段带来价值。在组件初始化期间,缓存会减慢应用程序的速度,并且这种影响有叠加的趋势,也就是说,缓存并不是没有代价的!。

因此,作为前端开发者要时刻牢记,useMemo和useCallback是有成本的,它会增加整体程序初始化的耗时,并不适合全局全面使用,它更适合做针对性的局部优化。

大多数情况下,我们应该考虑其他实现,避免过度使用这两个hook,给应用程序带来不必要的负担(性能/复杂度)。

5 参考文章useMemo 官方文档useCallback 官方文档Should You Really Use useMemo in React? Let’s Find Out.When not to use the useMemo React Hook不要再滥用 useMemo 了!你应该重新思考 Hooks memoization