React 性能优化秘籍:深入理解 `useMemo` 与 `useCallback`

0 阅读12分钟

引言:当 React 组件“不听话”时

React 凭借声明式 UI 和灵活的组件模型,成为前端开发者的心头好。但随着应用复杂度上升,不必要的重复渲染往往会拖慢页面性能。你是否遇到过以下场景:

  • 输入一个表单值,整个页面卡顿了几秒?
  • 父组件状态一变,所有子组件都跟着重新渲染?
  • 明明 props 没变,子组件却依然执行了 diff 和渲染?

这些问题的根源,往往在于 React 默认“组件内任何状态或 props 变化,都会触发整个组件重新执行”的机制。而 useMemouseCallback 就是 React 官方提供的两个优化钩子,用来缓存计算结果稳定函数引用,从而跳过无意义的渲染工作。

本文将结合实战代码,逐行拆解这两个 API 的用法、原理,以及如何正确使用它们写出高性能的 React 应用。

目录

  1. 重新认识 React 的渲染机制
  2. useMemo —— 缓存昂贵的计算结果
    • 2.1 问题场景:每次渲染都做无用的过滤和计算
    • 2.2 逐行解析 App2.jsx
    • 2.3 useMemo 的核心思想与表格总结
  3. useCallback —— 稳定函数引用,配合 memo 优化子组件
    • 3.1 问题场景:函数 props 导致子组件频繁重渲染
    • 3.2 逐行解析 App.jsx
    • 3.3 useCallbackmemo 的默契配合
  4. 对比表格:useMemo vs useCallback
  5. 常见误区与最佳实践
  6. 总结

1. 重新认识 React 的渲染机制

在深入优化之前,我们先明确一个基础概念:React 组件的重新渲染

  • 当组件的 stateprops 发生变化时,React 会重新执行该组件的函数体(对于函数组件),得到新的虚拟 DOM,然后与旧的虚拟 DOM 对比,最后只更新真实 DOM 中变化的部分。
  • 这个过程本身很快,但如果组件树庞大,或者组件内部有昂贵的计算逻辑(例如大数组过滤、复杂数学运算),那么频繁的重新渲染就会造成可感知的性能下降。

React 提供了两个钩子来“阻止”不必要的计算和渲染:

钩子作用
useMemo缓存一个计算后的值,依赖不变时直接返回缓存值
useCallback缓存一个函数本身,依赖不变时返回同一个函数引用
React.memo高阶组件,浅比较 props,避免子组件无意义渲染

接下来,我们通过两个代码文件,逐一详解这些钩子的实战用法。


2. useMemo —— 缓存昂贵的计算结果

2.1 问题场景:每次渲染都做无用的过滤和计算

我们来看 App2.jsx 中的代码。这个组件有两个独立的状态:countkeyword,还有一个昂贵的计算函数 slowSum,以及一个根据 keyword 过滤的列表。

在没有任何优化的情况下,任何状态变化(比如点 count+1 按钮)都会导致整个组件函数重新执行,这意味着:

  • filterList 的过滤逻辑会重新执行(即便 keyword 没变)
  • slowSum 的昂贵循环会重新执行(即便 num 没变)

这无疑是对 CPU 资源的浪费。useMemo 就是为了解决这类问题而生。

2.2 逐行解析 App2.jsx

让我们打开 App2.jsx 文件,逐行分析。

import {
  useState,
  useMemo // 从 React 中引入 useMemo
} from 'react';
  • useMemo 是 React 16.8 推出的 Hooks API 之一,专门用于缓存计算结果。
// 昂贵的计算
function slowSum(n) {
  console.log('计算中...');
  let sum = 0;
  for (let i = 0; i < n * 10000000; i++) {
    sum += i;
  }
  return sum;
}
  • 这是一个模拟昂贵计算的函数。n 越大,循环次数越多(n * 10,000,000 次)。
  • 如果每次渲染都调用它,页面会明显卡顿。
export default function App() {
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState('');
  const list = ['apple', 'banana', 'orange', 'pear'];
  • 定义了两个状态:countkeyword
  • list 是内联定义的数组。注意一个陷阱:每次组件渲染,list 都会是一个全新的数组(引用地址不同)。稍后会分析它对 useMemo 的影响。
  // 没有使用 useMemo 的版本(注释部分)
  // const filterList = list.filter(item => {
  //   console.log('filter 执行');
  //   return item.includes(keyword)
  // })
  • 如果直接这样写,每次渲染都会执行 filter,控制台会频繁打印 “filter 执行”。
  • 即使 keyword 没变,count 的改变也会触发无意义的过滤操作。
  const filterList = useMemo(() => {
    // 类似于 Vue 中的 computed
    return list.filter(item => item.includes(keyword))
  }, [keyword, list])
  • 这是 useMemo 的核心用法
    • 第一个参数是一个函数,该函数返回需要缓存的值(这里是过滤后的数组)。
    • 第二个参数是依赖数组 [keyword, list]
    • 只有当 keywordlist 发生变化时,才会重新执行过滤逻辑;否则直接返回上一次缓存的结果。

⚠️ 潜在陷阱:由于 list 每次渲染都会重新创建(引用变了),所以 useMemo 会认为依赖项 list 改变了,导致过滤逻辑仍然每次重新执行。这是一个常见的错误,正确的做法是将 list 定义在组件外部,或者用 useState / useRef 固定其引用。例如:

const list = useMemo(() => ['apple', 'banana', 'orange', 'pear'], []);

这样 list 的引用就稳定了。

  const [num, setNum] = useState(0);
  const result = useMemo(() => {
    return slowSum(num)
  }, [num]);
  • 同样,用 useMemo 缓存 slowSum 的结果。
  • 只有当 num 改变时,才会重新执行那个长达千万次的循环;否则直接返回上一次的结果。
  • 你可以试试:点击 num+1 按钮,控制台会打印 “计算中...”;而点击 count+1 按钮,不会触发昂贵计算。
  return (
    <div>
      <p>结果:{result}</p>
      <button onClick={() => setNum(num + 1)}>num+ 1</button>
      
      <input type="text" value={keyword} onChange={e => setKeyword(e.target.value)}/>
      {count}
      <button onClick={() => setCount(count + 1)}>count+ 1</button>
      {
        filterList.map(item => (
          <li key={item}>{item}</li>
        ))
      }
    </div>
  )
}
  • UI 部分:展示昂贵计算结果、输入框、两个独立按钮、过滤后的列表。
  • 你可以实际测试:快速点击 count+1 按钮,页面数字更新很快,没有卡顿感,因为 slowSum 没有被重复调用;而点击 num+1 时,会明显感觉到一次卡顿(这是昂贵计算本身的耗时)。

2.3 useMemo 的核心思想与表格总结

思想提炼

  • useMemo 是一种空间换时间的优化策略。它把计算结果保存在内存中,只有当依赖项改变时才重新计算。
  • 它不仅仅是用来缓存昂贵的数值计算,还可以缓存任何引用类型(对象、数组、函数),从而帮助子组件避免不必要的重渲染(配合 React.memo)。
  • 它的本质是记忆化(memoization)——一种常见的函数式编程优化技术。

适用场景

场景示例
复杂的数据转换大数组的 filtermapreduce 操作
昂贵的数学计算递归、大量循环、三角函数等
保持引用稳定返回对象或数组,传递给子组件(配合 memo

对比表格:有无 useMemo 的区别

行为不使用 useMemo使用 useMemo
组件每次渲染时重新执行所有内部计算逻辑跳过未变化依赖的计算,直接返回缓存值
依赖改变时自然重新计算重新计算并更新缓存
对引用类型返回值的影响每次生成新引用(可能触发子组件重渲染)依赖不变则返回相同引用
性能收益显著减少 CPU 耗时(尤其是昂贵计算)

完整代码如下:


3. useCallback —— 稳定函数引用,配合 memo 优化子组件

3.1 问题场景:函数 props 导致子组件频繁重渲染

React 中的数据流是单向的,父组件通过 props 向子组件传递数据和方法。但当我们把函数作为 props 传递给子组件时,一个隐藏的问题浮现了:

每次父组件渲染,都会创建一个全新的函数(尽管函数代码相同)。

对于普通的子组件,这不算大问题。但如果子组件使用了 React.memo 进行优化(即只有 props 改变时才重新渲染),由于函数每次都是新的引用,memo 的浅比较会认为 props 发生了变化,从而导致子组件无意义地重新渲染。

useCallback 就是为了保证函数引用的稳定性而生的。

3.2 逐行解析 App.jsx

现在我们分析 App.jsx 文件。

import {
  useState,
  memo,
  useCallback
} from 'react';
  • memo:高阶组件,用来包裹函数组件。它会对传入的新旧 props 进行浅比较,如果所有 prop 都相同,则跳过渲染(复用最近一次渲染结果)。
  • useCallback:用于缓存函数。
// 使用 memo 包裹的子组件
const Child = memo(({ count, handleClick }) => {
  console.log('child 重新渲染');
  return (
    <div onClick={handleClick}>
      子组件{count}
    </div>
  )
})
  • Child 组件接收 counthandleClick 作为 props。
  • 因为使用了 memo,只有当 counthandleClick引用发生变化时,它才会重新渲染。
  • 控制台输出 'child 重新渲染' 可以帮助我们观察渲染次数。
export default function App() {
  const [count, setCount] = useState(0);
  const [num, setNum] = useState(0);
  • 父组件有两个独立状态:count(传给子组件)和 num(只影响父组件自身)。
  // 没有使用 useCallback 的版本(注释部分)
  // const handleClick = () => {
  //   console.log('click');
  // }
  • 如果直接这样定义函数,那么每次 App 重新渲染(例如点击 num+1 按钮),handleClick 都会是一个全新的函数。
  • 这会导致 Child 组件即便 count 没变,也会重新渲染(因为 handleClick 引用变了)。
  const handleClick = useCallback(() => {
    console.log('click');
  }, [])   // 空依赖数组意味着这个函数永远不会重新创建
  • useCallback 缓存了这个函数,依赖数组为空,所以整个应用生命周期内,handleClick 都是同一个引用。
  • 因此,当父组件因为 num 改变而重新渲染时,Child 收到的 handleClick 仍然是原来那个函数,memo 检查时发现 handleClick 没变,count 也没变,就会跳过渲染。
  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1)}>count+ 1</button>
      {num}
      <button onClick={() => setNum(num + 1)}>num+ 1</button>
      
      <Child count={count} handleClick={handleClick} />
    </div>
  )
}
  • 实验步骤:
    1. 点击 num+1 按钮,控制台不会打印 “child 重新渲染”,因为 useCallbackmemo 联手阻止了不必要的子组件更新。
    2. 点击 count+1 按钮,count 改变,Child 会重新渲染(符合预期)。
    3. 如果没有 useCallback,点击 num+1 时也会看到子组件重新渲染,这就是性能浪费。

3.3 useCallbackmemo 的默契配合

思想提炼

  • 在 React 中,函数是“一等公民”,但每次渲染都会产生新的闭包。useCallback 让函数在依赖不变时保持稳定,从而保留身份标识
  • 单独使用 memo 是不够的,因为父组件传递的函数 props 常变常新。必须配合 useCallback 才能真正发挥 memo 的作用。
  • 类似的,如果传递给子组件的 props 是一个对象或数组,也需要用 useMemo 来稳定引用。

适用场景

场景解决方案
将函数作为 props 传递给 memo 子组件使用 useCallback
作为其他 Hook(如 useEffect)的依赖项使用 useCallback 避免 effect 频繁执行
在 Context 中提供稳定的方法使用 useCallback 保证 value 对象中的函数不变

4. useMemo vs useCallback

特性useMemouseCallback
返回内容缓存的值(可以是任何类型:数字、字符串、对象、数组等)缓存的函数
典型使用场景缓存昂贵计算结果、稳定对象/数组引用稳定函数引用,配合 memouseEffect
语法useMemo(() => value, deps)useCallback(fn, deps)
等价关系useCallback(fn, deps) 等价于 useMemo(() => fn, deps)
依赖项作用依赖改变时重新计算值依赖改变时重新生成函数
React.memo 的关系间接(稳定对象/数组 prop)直接(稳定函数 prop)
优化效果减少计算量,避免子组件因引用变化而重渲染减少函数创建开销,避免子组件无意义重渲染

5. 常见误区与最佳实践

误区一:滥用 useMemouseCallback

“把所有变量和函数都用 useMemo/useCallback 包起来,性能肯定最好!”

真相:这两个 Hook 本身也有开销(创建闭包、依赖比较)。对于简单的计算或函数,滥用反而可能降低性能。只在必要时使用

  • 昂贵计算(如超过 1ms 的循环或递归)。
  • 传递给 memo 子组件的对象/数组/函数。
  • 作为其他 Hook(useEffectuseLayoutEffect)依赖项且可能频繁变化的值。

误区二:依赖数组不完整

const handleClick = useCallback(() => {
  console.log(keyword); // 使用了 keyword,但依赖数组为空
}, []);
  • 如果函数体内使用了外部变量(如 keyword),但依赖数组没有包含它,那么函数会一直引用旧的 keyword(闭包陷阱)。React 会给出 ESLint 警告(react-hooks/exhaustive-deps),务必遵守。

误区三:以为 useMemo 能完全阻止子组件渲染

useMemo 只是缓存了值,但如果你直接将这个值作为 JSX 的一部分返回,父组件本身还是会重新渲染。它只能避免子组件因引用变化而重新渲染,无法阻止父组件自身的函数体执行(除非你用 React.memo 包裹该子组件)。

最佳实践清单 ✅

  1. 默认不使用优化,先写出清晰的逻辑。遇到性能瓶颈时,用 React DevTools 的 “Highlight updates” 找出不必要的渲染。
  2. 将稳定的数据移到组件外部(如常量、静态数组),这样根本不需要 useMemo
  3. 对于传递给子组件的回调,优先使用 useCallback,并配合 React.memo
  4. 依赖数组要诚实,不要省略必要的依赖。
  5. 避免在 useMemo 中执行副作用(它是纯函数)。
  6. 性能优化不是银弹,优先解决根本问题(如不合理的数据结构、过大的组件树)。

6. 总结

  • useMemouseCallback 是 React 性能优化工具箱中的重要成员,它们分别解决了计算缓存函数引用稳定两大问题。
  • useMemo 适用于昂贵的计算或需要保持引用稳定的对象/数组,避免每次渲染都做无用功。
  • useCallback 适用于需要传递给子组件(尤其是被 memo 包裹的子组件)的函数,避免子组件无意义重渲染。
  • 二者都需要搭配正确的依赖数组使用,并避免滥用。
  • 理解 React 渲染机制是正确使用它们的前提 —— 优化是有针对性的,而不是盲目套用。

通过本文对两个实战代码文件的逐行拆解,相信你已经能够看懂并写出高效的使用模式。在你下一次遇到页面卡顿或子组件频繁渲染时,不妨先问问自己:“我是否可以用 useMemo 缓存这个计算结果?是否应该用 useCallback 稳定这个函数?” 然后,用代码证明你的优化效果。


文中所有示例代码均来自真实项目(App2.jsxApp.jsx) 完整项目链接:gitee.com/hong-strong…