React 性能优化避坑指南:彻底搞懂 useMemo、useCallback 与闭包陷阱

50 阅读7分钟

对于 React 学习者来说,掌握基础的 JSX 和 useState 往往只是第一步。当你开始构建更复杂的应用时,你可能会遇到一些令人困惑的现象:为什么我的组件在疯狂重新渲染?为什么定时器里的数据永远是最旧的?

这篇文章将带你深入 React 的渲染机制,通过三个经典的实战场景,彻底搞懂 useMemouseCallback 的核心作用,以及那个让无数新手“翻车”的闭包陷阱。

一、 为什么我们需要“缓存”?

首先,我们需要建立一个核心认知:React 组件本质上就是一个函数。

每当组件的状态(State)或属性(Props)发生变化时,这个函数就会从头到尾重新运行一次。这个过程被称为“重新渲染”(Re-render)。在大多数情况下,这非常快。但是,如果你的组件里包含了大量的计算逻辑,或者你的组件树非常深,无脑的“重算”就会导致页面卡顿。

React 提供了三个 Hook 来帮助我们“缓存”数据,避免无意义的消耗:useMemouseCallbackReact.memo

二、 场景一:拒绝昂贵的重复计算 (useMemo)

想象这样一个场景:我们需要对一个包含大量数据的列表进行关键词过滤。同时,页面上还有一个毫无关联的计数器按钮。

1. 问题代码

在这个版本中,每次点击“计数器”按钮导致状态更新,组件函数都会重新执行。这意味着 slowList 的过滤逻辑和 slowSum 的累加逻辑会被强制重跑一遍,尽管它们依赖的数据根本没有变。

import { useState } from 'react';

// 模拟一个昂贵的计算过程
function slowSum(n) {
  console.log('正在进行昂贵的计算...');
  let sum = 0;
  // 模拟耗时操作
  for (let i = 0; i < n * 10000000; i++) {
    sum += i;
  }
  return sum;
}

export default function App() {
  const [count, setCount] = useState(0); // 与下方计算无关的状态
  const [keyword, setKeyword] = useState('');
  const list = ['apple', 'orange', 'peach', 'banana'];

  // 🔴 性能问题:
  // 每次点击 count+1,组件重新渲染,filter 都会重新执行
  const filterList = list.filter(item => {
    console.log('Filter 逻辑被触发了');
    return item.includes(keyword);
  });

  // 🔴 性能问题:
  // 每次组件渲染,这个昂贵的求和函数都会运行,阻塞页面
  const result = slowSum(10); 

  return (
    <div>
      <h3>结果: {result}</h3>
      
      {/* 这里的输入框改变会导致 keyword 更新 */}
      <input 
        type="text" 
        value={keyword} 
        onChange={(e) => setKeyword(e.target.value)} 
        placeholder="输入关键词过滤"
      />

      {/* 这里的点击会导致 count 更新,进而触发整个组件重绘 */}
      <button onClick={() => setCount(count + 1)}>
        Count: {count} (点我也许会卡顿)
      </button>
      
      <ul>
        {filterList.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

2. 优化方案:使用 useMemo

useMemo 的作用是缓存计算结果。它类似于 Vue 中的 computed 属性。它接收两个参数:

  1. 计算函数。
  2. 依赖项数组:只有数组里的变量变了,计算函数才会重新执行。
import { useState, useMemo } from 'react';

// ... slowSum 函数保持不变 ...

export default function App() {
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState('');
  const [num, setNum] = useState(10);
  const list = ['apple', 'orange', 'peach', 'banana'];

  // ✅ 优化 1:缓存列表过滤结果
  // 只有当 keyword 变化时,才会重新执行过滤逻辑
  const filterList = useMemo(() => {
    console.log('Filter 逻辑执行');
    return list.filter(item => item.includes(keyword));
  }, [keyword]); // 依赖项是 keyword

  // ✅ 优化 2:缓存昂贵的数学计算
  // 只有当 num 变化时,slowSum 才会重新运行
  const result = useMemo(() => {
    return slowSum(num);
  }, [num]); // 依赖项是 num

  return (
    <div>
      <p>结果: {result}</p>
      
      {/* 这里的操作现在非常流畅,因为 filterList 和 result 都是直接取缓存值 */}
      <button onClick={() => setCount(count + 1)}>
        Count + 1 (不会触发重算)
      </button>

      {/* ... 省略渲染部分 ... */}
    </div>
  );
}

现在,当你点击 count + 1 时,控制台不会再打印 "Filter 逻辑执行" 或 "正在进行昂贵的计算",因为 React 直接复用了上一次的结果。

三、 场景二:防止子组件“无辜陪跑” (useCallback)

React 有一个默认行为:当父组件重新渲染时,所有的子组件也会跟着重新渲染,无论子组件的 Props 有没有变化。

1. 使用 React.memo 锁住子组件

为了阻止这种“连坐”效应,我们可以使用高阶组件 memo。它的作用是:只有当 Props 发生浅比较变化时,才允许子组件重新渲染。

import { useState, memo } from 'react';

// 使用 memo 包裹子组件
const Child = memo(({ count, handleClick }) => {
    console.log('子组件渲染了'); // 只有 props 变了才会打印
    return (
        <div onClick={handleClick} style={{ border: '1px solid red', padding: 10 }}>
            我是子组件,收到 Count: {count}
        </div>
    )
});

2. 引用类型的陷阱:为什么 memo 失效了?

即使加了 memo,如果你向子组件传递了一个函数,你可能会发现优化失效了。

export default function App() {
    const [count, setCount] = useState(0);
    const [otherNum, setOtherNum] = useState(0);

    // 🔴 陷阱:
    // 每次 App 重绘,都会创建一个全新的 handleClick 函数对象
    // 虽然函数体代码没变,但内存地址变了!
    const handleClick = () => {
        console.log('点击了子组件');
    }

    return (
        <div>
            {/* 点击这个按钮,App 重绘 -> handleClick 变了 -> Child 重绘 */}
            <button onClick={() => setOtherNum(otherNum + 1)}>
                修改无关数据 ({otherNum})
            </button>
            
            {/* Child 虽然使用了 memo,但 props.handleClick 每次都是新的,所以依然会重绘 */}
            <Child count={count} handleClick={handleClick} />
        </div>
    )
}

在 JavaScript 中,函数是引用类型。第一次渲染创建的 handleClick 和第二次渲染创建的 handleClick 是两个不同的对象(func1 !== func2)。memo 经过对比发现 Props 变了,于是允许子组件更新。

3. 终极解法:使用 useCallback

useCallback 的作用就是缓存函数引用。只要依赖项不变,它返回的永远是同一个函数引用。

import { useState, useCallback } from 'react';

export default function App() {
    const [count, setCount] = useState(0);
    const [otherNum, setOtherNum] = useState(0);

    // ✅ 优化:
    // 使用 useCallback 缓存函数
    // 依赖项数组为空 [],或者包含需要的依赖
    // 这里如果 handleClick 内部不依赖外部变量,依赖项可以是 []
    const handleClick = useCallback(() => {
        console.log('点击了子组件');
    }, []); // 永远返回同一个函数引用

    return (
        <div>
            <button onClick={() => setOtherNum(otherNum + 1)}>
                修改无关数据 ({otherNum})
            </button>
            
            {/* 此时,handleClick 引用没变,count 也没变,Child 完全不会重新渲染! */}
            <Child count={count} handleClick={handleClick} />
        </div>
    )
}

总结: React.memo 负责拦截组件更新,useCallback 负责提供稳定的函数引用,两者往往需要配合使用才能生效。

四、 场景三:令人头秃的“闭包陷阱”

在使用 useEffect 处理定时器或事件监听时,新手最容易遇到“数据不更新”的诡异 BUG。

1. BUG 复现

import { useState, useEffect } from 'react';

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      // 🔴 陷阱:这里的 count 永远是 0
      console.log('当前 Count 是:', count);
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 依赖项为空,只在组件挂载时执行一次

  return (
    <div>
      <p>页面上的 Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

现象: 点击按钮,页面上的数字变成了 1, 2, 3... 但控制台打印的永远是 Current count: 0

原因: 这就是闭包陷阱(Stale Closure)。

  1. 组件第一次渲染,count 是 0。
  2. useEffect 执行,创建了一个定时器函数。这个函数“记住”了它诞生时的环境,也就是 count = 0
  3. 依赖项是 [],所以 useEffect 再也没运行过。
  4. 不管后来组件重新渲染多少次,定时器里跑的永远是第一次那个“老旧”的函数,它眼里只有旧的 count

2. 解决方案

解决闭包陷阱主要有两种方式:

方法 A:诚实地填写依赖项(推荐)

如果 Effect 内部用到了 count,就应该把它加入依赖数组。

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前 Count 是:', count);
    }, 1000);
    
    // 每次 count 变化:
    // 1. 执行清理函数 clearInterval
    // 2. 重新运行 Effect,创建新闭包(捕获最新的 count)
    return () => clearInterval(timer);
  }, [count]); 

方法 B:使用函数式更新(适用于 setState

如果你只是想基于旧值更新状态,不需要读取值,可以使用 setCount(prev => prev + 1),这样就不需要依赖外部的 count 变量了。

总结

React 的性能优化并不神秘,核心就在于管理好依赖引用

Hook核心作用适用场景
useMemo缓存这里的计算太贵了,不想每次渲染都算一遍
useCallback缓存函数这个函数要传给用 memo 包裹的子组件,不想破坏它的稳定性
useEffect处理副作用记得处理好依赖项,小心闭包陷阱

希望这篇文章能帮你构建出更高效、更健壮的 React 应用!如果你有任何疑问,欢迎在评论区讨论。