引言:当 React 组件“不听话”时
React 凭借声明式 UI 和灵活的组件模型,成为前端开发者的心头好。但随着应用复杂度上升,不必要的重复渲染往往会拖慢页面性能。你是否遇到过以下场景:
- 输入一个表单值,整个页面卡顿了几秒?
- 父组件状态一变,所有子组件都跟着重新渲染?
- 明明 props 没变,子组件却依然执行了 diff 和渲染?
这些问题的根源,往往在于 React 默认“组件内任何状态或 props 变化,都会触发整个组件重新执行”的机制。而 useMemo 和 useCallback 就是 React 官方提供的两个优化钩子,用来缓存计算结果和稳定函数引用,从而跳过无意义的渲染工作。
本文将结合实战代码,逐行拆解这两个 API 的用法、原理,以及如何正确使用它们写出高性能的 React 应用。
目录
- 重新认识 React 的渲染机制
useMemo—— 缓存昂贵的计算结果- 2.1 问题场景:每次渲染都做无用的过滤和计算
- 2.2 逐行解析
App2.jsx - 2.3
useMemo的核心思想与表格总结
useCallback—— 稳定函数引用,配合memo优化子组件- 3.1 问题场景:函数 props 导致子组件频繁重渲染
- 3.2 逐行解析
App.jsx - 3.3
useCallback与memo的默契配合
- 对比表格:
useMemovsuseCallback - 常见误区与最佳实践
- 总结
1. 重新认识 React 的渲染机制
在深入优化之前,我们先明确一个基础概念:React 组件的重新渲染。
- 当组件的 state 或 props 发生变化时,React 会重新执行该组件的函数体(对于函数组件),得到新的虚拟 DOM,然后与旧的虚拟 DOM 对比,最后只更新真实 DOM 中变化的部分。
- 这个过程本身很快,但如果组件树庞大,或者组件内部有昂贵的计算逻辑(例如大数组过滤、复杂数学运算),那么频繁的重新渲染就会造成可感知的性能下降。
React 提供了两个钩子来“阻止”不必要的计算和渲染:
| 钩子 | 作用 |
|---|---|
useMemo | 缓存一个计算后的值,依赖不变时直接返回缓存值 |
useCallback | 缓存一个函数本身,依赖不变时返回同一个函数引用 |
React.memo | 高阶组件,浅比较 props,避免子组件无意义渲染 |
接下来,我们通过两个代码文件,逐一详解这些钩子的实战用法。
2. useMemo —— 缓存昂贵的计算结果
2.1 问题场景:每次渲染都做无用的过滤和计算
我们来看 App2.jsx 中的代码。这个组件有两个独立的状态:count 和 keyword,还有一个昂贵的计算函数 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'];
- 定义了两个状态:
count和keyword。 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]。 - 只有当
keyword或list发生变化时,才会重新执行过滤逻辑;否则直接返回上一次缓存的结果。
⚠️ 潜在陷阱:由于
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)——一种常见的函数式编程优化技术。
适用场景:
| 场景 | 示例 |
|---|---|
| 复杂的数据转换 | 大数组的 filter、map、reduce 操作 |
| 昂贵的数学计算 | 递归、大量循环、三角函数等 |
| 保持引用稳定 | 返回对象或数组,传递给子组件(配合 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组件接收count和handleClick作为 props。- 因为使用了
memo,只有当count或handleClick的引用发生变化时,它才会重新渲染。 - 控制台输出
'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>
)
}
- 实验步骤:
- 点击
num+1按钮,控制台不会打印 “child 重新渲染”,因为useCallback和memo联手阻止了不必要的子组件更新。 - 点击
count+1按钮,count改变,Child会重新渲染(符合预期)。 - 如果没有
useCallback,点击num+1时也会看到子组件重新渲染,这就是性能浪费。
- 点击
3.3 useCallback 与 memo 的默契配合
思想提炼:
- 在 React 中,函数是“一等公民”,但每次渲染都会产生新的闭包。
useCallback让函数在依赖不变时保持稳定,从而保留身份标识。 - 单独使用
memo是不够的,因为父组件传递的函数 props 常变常新。必须配合useCallback才能真正发挥memo的作用。 - 类似的,如果传递给子组件的 props 是一个对象或数组,也需要用
useMemo来稳定引用。
适用场景:
| 场景 | 解决方案 |
|---|---|
将函数作为 props 传递给 memo 子组件 | 使用 useCallback |
作为其他 Hook(如 useEffect)的依赖项 | 使用 useCallback 避免 effect 频繁执行 |
| 在 Context 中提供稳定的方法 | 使用 useCallback 保证 value 对象中的函数不变 |
4. useMemo vs useCallback
| 特性 | useMemo | useCallback |
|---|---|---|
| 返回内容 | 缓存的值(可以是任何类型:数字、字符串、对象、数组等) | 缓存的函数 |
| 典型使用场景 | 缓存昂贵计算结果、稳定对象/数组引用 | 稳定函数引用,配合 memo 或 useEffect |
| 语法 | useMemo(() => value, deps) | useCallback(fn, deps) |
| 等价关系 | useCallback(fn, deps) 等价于 useMemo(() => fn, deps) | — |
| 依赖项作用 | 依赖改变时重新计算值 | 依赖改变时重新生成函数 |
与 React.memo 的关系 | 间接(稳定对象/数组 prop) | 直接(稳定函数 prop) |
| 优化效果 | 减少计算量,避免子组件因引用变化而重渲染 | 减少函数创建开销,避免子组件无意义重渲染 |
5. 常见误区与最佳实践
误区一:滥用 useMemo 和 useCallback
“把所有变量和函数都用
useMemo/useCallback包起来,性能肯定最好!”
真相:这两个 Hook 本身也有开销(创建闭包、依赖比较)。对于简单的计算或函数,滥用反而可能降低性能。只在必要时使用:
- 昂贵计算(如超过 1ms 的循环或递归)。
- 传递给
memo子组件的对象/数组/函数。 - 作为其他 Hook(
useEffect、useLayoutEffect)依赖项且可能频繁变化的值。
误区二:依赖数组不完整
const handleClick = useCallback(() => {
console.log(keyword); // 使用了 keyword,但依赖数组为空
}, []);
- 如果函数体内使用了外部变量(如
keyword),但依赖数组没有包含它,那么函数会一直引用旧的keyword(闭包陷阱)。React 会给出 ESLint 警告(react-hooks/exhaustive-deps),务必遵守。
误区三:以为 useMemo 能完全阻止子组件渲染
useMemo 只是缓存了值,但如果你直接将这个值作为 JSX 的一部分返回,父组件本身还是会重新渲染。它只能避免子组件因引用变化而重新渲染,无法阻止父组件自身的函数体执行(除非你用 React.memo 包裹该子组件)。
最佳实践清单 ✅
- 默认不使用优化,先写出清晰的逻辑。遇到性能瓶颈时,用 React DevTools 的 “Highlight updates” 找出不必要的渲染。
- 将稳定的数据移到组件外部(如常量、静态数组),这样根本不需要
useMemo。 - 对于传递给子组件的回调,优先使用
useCallback,并配合React.memo。 - 依赖数组要诚实,不要省略必要的依赖。
- 避免在
useMemo中执行副作用(它是纯函数)。 - 性能优化不是银弹,优先解决根本问题(如不合理的数据结构、过大的组件树)。
6. 总结
useMemo和useCallback是 React 性能优化工具箱中的重要成员,它们分别解决了计算缓存和函数引用稳定两大问题。useMemo适用于昂贵的计算或需要保持引用稳定的对象/数组,避免每次渲染都做无用功。useCallback适用于需要传递给子组件(尤其是被memo包裹的子组件)的函数,避免子组件无意义重渲染。- 二者都需要搭配正确的依赖数组使用,并避免滥用。
- 理解 React 渲染机制是正确使用它们的前提 —— 优化是有针对性的,而不是盲目套用。
通过本文对两个实战代码文件的逐行拆解,相信你已经能够看懂并写出高效的使用模式。在你下一次遇到页面卡顿或子组件频繁渲染时,不妨先问问自己:“我是否可以用 useMemo 缓存这个计算结果?是否应该用 useCallback 稳定这个函数?” 然后,用代码证明你的优化效果。
文中所有示例代码均来自真实项目(
App2.jsx和App.jsx) 完整项目链接:gitee.com/hong-strong…