【React进阶】告别无效渲染!useCallback与React.memo的性能优化艺术

98 阅读6分钟

你好,亲爱的掘金开发者!👋

你是否曾面对一个日益复杂的React应用,感觉每一次微小的状态更新都像在平静的湖面投下了一颗巨石,激起层层涟漪,导致许多本该“岁月静好”的组件也跟着重新渲染?这不仅是性能上的浪费,也让我们的代码逻辑变得难以捉摸。

今天,就让我们一起深入探讨React性能优化的利器——React.memouseCallback以及useMemo,并通过一个生动的例子,看看它们是如何联手斩断不必要的渲染,让你的应用丝滑流畅。

一、渲染的“涟漪效应”:从一个问题开始

在React的世界里,组件的渲染遵循一个基本原则:父组件的重新渲染,通常会导致其所有子组件的重新渲染。而组件的挂载(Mount)顺序则恰恰相反,是先内后外的。

让我们来看一个经典的场景。我们有一个App父组件,它内部有两个状态countnum,以及一个独立的子组件Button

App.jsx - 父组件

import { useState, useCallback, useMemo } from 'react';
import Button from './components/Button';
import './App.css';

function App() {
  const [count, setCount] = useState(0);
  const [num, setNum] = useState(0);
  console.log('App render');

  const handleClick = useCallback(() => {
    console.log('handleClick');
  }, [num]);

  return (
    <>
      <div>{count}</div>
      <button onClick={() => setCount(count + 1)}>+</button>
      <br/>    
      <button onClick={() => setNum(num + 1)}>+</button>
      <br/>
      <Button onClick={handleClick}>点击</Button>
    </>
  );
}

export default App;

Button/index.jsx - 子组件

import { memo, useEffect } from 'react';

const Button = () => {
  useEffect(() => {
    console.log('Button useEffect');
  }, []);
  console.log('Button render');
  return <button>点击</button>;
};

export default memo(Button);

当我们点击第一个“+”按钮,更新count状态时,你会发现一个问题:控制台不仅打印了'App render',也打印了'Button render'

Button组件本身和count状态毫无关系,它的渲染是完全不必要的。在一个大型应用中,这种“涟漪效应”积少成多,就会成为性能的瓶颈。那么,我们该如何阻止它呢?

二、第一道防线:React.memo

React为我们提供了第一道性能防线:React.memo。它是一个高阶组件(HOC),会“记住”一个组件的渲染输出。如果该组件的props没有发生变化,React将跳过渲染,直接复用上次的渲染结果。

我们已经像上面代码中那样,用memo包裹了Button组件:export default memo(Button)

但奇怪的是,为什么它没有生效?即使我们只更新countButton依然在重新渲染。

三、真正的“捣蛋鬼”:不稳定的函数引用

答案藏在App组件的handleClick函数里。

在JavaScript中,函数是引用类型。每次App组件渲染(无论是count还是num更新),function App() { ... }函数体内的所有代码都会重新执行。这意味着:

const handleClick = () => { console.log('handleClick') };

这行代码每一次都会创建一个全新的函数实例

尽管新旧函数的功能完全一样,但它们的内存地址不同。所以对于React.memo来说,它在进行props浅比较时,发现onClick这个prop每次都是一个“新”函数,oldProps.onClick !== newProps.onClick永远为true。于是,memo的优化被轻松绕过,Button组件只能无奈地一次又一次重新渲染。

四、终极组合技:useCallback + React.memo

为了解决函数引用的不稳定性,useCallback闪亮登场。

useCallback会缓存一个函数的回调。只有当它的依赖项(dependency array)发生变化时,它才会重新创建一个新的函数。

让我们把handleClick的定义修改一下:

// App.jsx
const handleClick = useCallback(() => {
  console.log('handleClick');
}, [num]); // 只有当 num 改变时,才重新生成 handleClick 函数

现在,魔法发生了:

  1. useCallback登场handleClick函数被useCallback包裹。它的依赖数组是[num]
  2. 更新count:当我们点击更新count的按钮时,App组件重新渲染。
  3. 缓存命中useCallback检查到它的依赖num没有变化,于是它不会创建新函数,而是返回上一次缓存的handleClick函数实例。
  4. memo生效Button组件接收到的onClick prop的引用和上次完全相同。React.memo的浅比较发现所有props都没有改变,于是它骄傲地阻止了这次不必要的渲染!

控制台现在只会打印'App render'Button组件终于可以“安靜”地待着了。这套useCallback + React.memo的组合拳,正是React中优化子组件渲染的经典模式。

五、举一反三:用useMemo缓存高开销计算

useCallback师出同门的还有useMemo。如果说useCallback是缓存一个函数本身,那么useMemo就是执行一个函数并缓存它的返回值

在我们的App.jsx中,有这样一段代码:

// App.jsx
const expensiveComputation = useMemo(() => {
  console.log('expensiveComputation');
  for (let i = 0; i < 1000000000; i++) {
    i++;
  }
  return num * 2;
}, [num]);

这里的expensiveComputation模拟了一个非常耗时的计算。如果没有useMemo,每次count的更新都会触发这个漫长的循环,导致页面卡顿。

useMemo确保了只有在依赖num变化时,这个昂贵的计算才会重新执行,并将结果缓存起来。对于其他状态(如count)的更新,它会直接返回缓存值,避免了性能浪费。

小贴士: useCallback(fn, deps) 等价于 useMemo(() => fn, deps)useCallback可以看作是专门为缓存函数而生的语法糖。

六、思考的延伸:组件粒度与Context的智慧

从这个小例子,我们可以引申出更深层次的React设计哲学:

  1. 组件拆分的粒度:合理地将UI拆分成更小的、职责单一的组件,是性能优化的第一步。小组件意味着更少的状态和props,更新时影响范围更小,也更容易被memo等工具优化。

  2. Context的性能陷阱:有些同学为了方便,可能会把所有全局状态都塞进一个巨大的Context里。这是一个危险的信号!因为Context的任何一点微小变化,都会导致所有消费该Context的组件重新渲染。正确的做法是,将不同领域的Context进行拆分,让组件只订阅它关心的那部分数据,避免“一荣俱荣,一损俱损”的局面。

总结

性能优化不是一蹴而就的,它根植于我们对React渲染机制的深刻理解和日常的编码习惯中。

  • 通过组件化拆分,从设计上隔离变化。
  • 使用React.memo为纯粹的渲染组件建立第一道防线。
  • 配合useCallback提供稳定的函数引用,喂饱React.memo
  • 利用useMemo缓存高开销的计算结果,避免重复计算。

希望这篇文章能帮助你更好地掌握React的性能优化技巧,构建出更丝滑、更健壮的应用。如果你有任何想法,欢迎在评论区交流!

Happy Coding! 🚀