正确地使用useMemo和useCallback

385 阅读8分钟

image.png

1 前言

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

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

useMemouseCallback就是用来帮助我们优化重新渲染的工具hook

本文将通过分析该 useMemouseCallback的使用目的、方式以及具体使用场景,帮助开发者正确的决定如何适时的使用这些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 组件何时重新渲染

组件重新渲染的时机:

  • 当组件本身的 propsstate 改变时

  • Context value 改变时,使用该值的组件会重新渲染

  • 当父组件重新渲染时,其所有未被缓存的子组件都会重新渲染

我们先来看以下例子:

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 count</button>
            <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 count</button>
            <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 count</button>
            <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 count</button>
            <div>current count is: {count}</div>
            <Page onClick={handleClick} value={valueRef.current} />
          </div>);
};

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

  • 子组件自身被缓存

  • 子组件所有的 prop 都被缓存

3 使用useCallback

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

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

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 总结

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

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

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

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

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

5 参考文章

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