React 性能优化利器:深入理解 useCallback、useMemo 与 React.memo
在 React 应用开发中,随着组件树的不断壮大,不必要的重复渲染 往往成为性能瓶颈的罪魁祸首。本文将从一个实际 Demo 出发,由浅入深地剖析 React 为性能优化而生的三个核心 API------React.memo、useCallback 和 useMemo。
一、问题的起源:谁在"悄悄"重渲染?
想象这样一个场景:
javascript
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('开心');
return (
<>
<button onClick={() => setCount(count + 1)}>点击计数 {count}</button>
<button onClick={() => setName('不开心')}>摸摸你</button>
<RegularChild name={name} />
<MemoChild name={name} />
</>
);
}
父组件 App 同时管理着 count 和 name 两个状态。点击「点击计数」按钮时,只有 count 发生了变化,name 依然是 '开心'。
但问题来了 ------此时 <RegularChild name={name} /> 和 <MemoChild name={name} /> 传入的 name 值并没有变,它们还有必要跟着父组件一起重渲染吗?
答案显然是否定的。这正是性能浪费的起点。
二、第一道防线:React.memo------"请记住我"
React.memo 是一个高阶组件(HOC) ,它的职责很简单:
对传入的 props 进行浅比较,如果 props 没有变化,就跳过该组件的重新渲染,直接复用上一次的结果。
来看 Demo 中的对比:
javascript
// ❌ 普通组件------父组件每次渲染,它都跟着渲染
function RegularChild({ name }) {
console.log('渲染了 RegularChild 组件');
return <h2>{name}</h2>;
}
// ✅ Memo 组件------props 不变,它就不渲染
const MemoChild = memo(({ name }) => {
console.log('MemoChild 组件渲染了');
return <div>Hello, {name}</div>;
});
行为对比:
| 操作 | RegularChild |
MemoChild |
|---|---|---|
| 点击「点击计数」(count 变,name 不变) | 🔴 重渲染 | 🟢 不渲染 |
| 点击「摸摸你」(name 变) | 🔴 重渲染 | 🟡 重渲染(因为 props 确实变了) |
效果立竿见影!memo 成功避免了 props 未变化时的无效渲染。
三、memo 的"阿喀琉斯之踵"
memo 看起来很完美,但它有一个致命的盲区------引用类型 props。
假设我们给 MemoChild 传递一个回调函数:
javascript
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('开心');
// 每次渲染都会创建一个全新的函数引用!
const handleClick = () => {
console.log('按钮被点击了');
};
return (
<>
<button onClick={() => setCount(count + 1)}>点击计数 {count}</button>
<MemoChild name={name} onClick={handleClick} />
</>
);
}
现在再点击「点击计数」会发生什么?MemoChild 又重渲染了!
原因在于:App 每次渲染时,handleClick 都会被重新创建,它在内存中是一个全新的引用 。memo 做浅比较时发现 onClick 的引用变了,于是判定 props 已变化,照样触发渲染。
这就是 useCallback 登场的时刻。
四、useCallback------"函数引用,请保持稳定"
useCallback 的作用是缓存函数引用,只有当它的依赖项发生变化时,才返回一个新的函数引用。
javascript
import { useCallback } from 'react';
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('开心');
// ✅ count 不变,handleClick 的引用就不变
const handleClick = useCallback(() => {
console.log('当前 name:', name);
}, [name]); // 依赖 name,name 变了才创建新函数
return (
<>
<button onClick={() => setCount(count + 1)}>点击计数 {count}</button>
<MemoChild name={name} onClick={handleClick} />
</>
);
}
现在点击「点击计数」时:count 变化 → name 没变 → handleClick 引用不变 → memo 浅比较通过 → MemoChild 不渲染! 🎉
核心心法:
useCallback(fn, deps)= "帮我把这个函数缓存起来,除非deps里的值变了,否则永远给我同一个引用。"
五、useMemo------不止函数,值也可以缓存
useCallback 缓存的是函数 ,那如果我想缓存一个计算值呢?比如一个经过复杂计算得到的数组或对象?
scss
// ❌ 每次渲染都重新计算,且生成全新引用
const sortedList = expensiveSort(items);
// ✅ 只有 items 变化时才重新计算
const sortedList = useMemo(() => expensiveSort(items), [items]);
useMemo 和 useCallback 本质上是同一个东西:
scss
useCallback(fn, deps) // 等价于
useMemo(() => fn, deps)
| Hook | 缓存什么 | 返回值 |
|---|---|---|
useCallback |
函数本身 | 缓存的函数 |
useMemo |
函数的执行结果 | 缓存的值 |
六、三者协作全景图
让我们把 memo、useCallback、useMemo 串联起来看一个完整示例:
javascript
import { useState, useCallback, useMemo, memo } from 'react';
// Step 1:用 memo 包裹子组件,给浅比较打底
const ExpensiveList = memo(({ items, onItemClick }) => {
console.log('ExpensiveList 渲染了');
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onItemClick(item.id)}>
{item.label}
</li>
))}
</ul>
);
});
function App() {
const [count, setCount] = useState(0);
const [filter, setFilter] = useState('');
const rawData = ['React', 'Vue', 'Angular', 'Svelte'];
// Step 2:用 useMemo 缓存计算结果(数组引用稳定)
const filteredItems = useMemo(
() => rawData
.filter(d => d.toLowerCase().includes(filter.toLowerCase()))
.map((d, i) => ({ id: i, label: d })),
[filter] // 只有 filter 变了才重新计算
);
// Step 3:用 useCallback 缓存回调函数(函数引用稳定)
const handleItemClick = useCallback((id) => {
console.log('选中了:', id);
}, []); // 空依赖 ------ 这个函数永远不会变
return (
<>
<button onClick={() => setCount(count + 1)}>
计数:{count}
</button>
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="搜索..."
/>
<ExpensiveList items={filteredItems} onItemClick={handleItemClick} />
</>
);
}
各司其职:
memo→ 组件级别的渲染守卫useMemo→ 计算值的引用稳定器useCallback→ 函数引用的稳定器
当点击「计数」按钮时,count 变了但 filter 没变 → filteredItems 引用不变,handleItemClick 引用不变 → memo 判定 props 无变化 → ExpensiveList 不渲染。 完美!
七、注意事项 & 最佳实践
- 不是所有组件都需要
memo。 如果组件本身很轻量,或者 props 总是在变,加上memo反而多了一次浅比较的开销。先测量,再优化。 - 依赖数组不要撒谎。
useCallback/useMemo的依赖数组必须诚实列出所有在回调中用到的、会随时间变化的外部变量,否则你会拿到过期的闭包值(stale closure)。 - 不要过早优化。 Kent C. Dodds 有句名言:"直到你实际测量出性能问题之前,不要使用
useCallback和useMemo。"------这句话值得玩味,但当你传递回调给memo子组件时,useCallback是必要的配合,而非过早优化。 useMemo不保证永远不重新计算。 React 保留在未来某些场景下"丢弃"缓存值并重新计算的权力。所以不要把副作用逻辑写在useMemo里。
📚 知识点总结
| 知识点 | 核心要点 |
|---|---|
| 问题根源 | 父组件状态更新 → 父组件渲染 → 所有子组件默认跟着渲染,即使传入的 props 没变 |
React.memo |
高阶组件,对 props 做浅比较,props 不变则跳过子组件渲染。「记住」组件的渲染结果 |
memo 的局限 |
浅比较无法识别「值相等但引用不同」的情况。函数、对象、数组每次渲染都是新引用 |
useCallback(fn, deps) |
缓存函数引用 ,仅在依赖项变化时返回新函数。是 memo 的最佳拍档 |
useMemo(() => val, deps) |
缓存计算结果(值引用),仅在依赖项变化时重新计算。用于稳定对象/数组引用或避免昂贵计算 |
useCallback vs useMemo |
useCallback(fn, deps) ≈ useMemo(() => fn, deps) ------ 前者是后者的语法糖 |
| 三者协作 | memo 做渲染守卫 → useMemo 稳定值引用 → useCallback 稳定函数引用,形成完整的性能优化链条 |
| 依赖数组规则 | 必须诚实包含回调中使用的所有外部可变值,否则产生过期闭包 Bug |
| 优化原则 | 先测量再优化;传递引用类型 props 给 memo 组件时,useCallback/useMemo 是必要配合 |
生命在于运动,React 在于"不动"------让不需要渲染的组件安静地待着,才是对性能最大的尊重。 🧘