在 React 的世界里,组件的渲染就像一场"牵一发而动全身"的多米诺骨牌。父组件打个喷嚏(State 变了),底下的子组件全得跟着感冒(重新渲染)。
虽然 React 够快,但如果你的组件里住着一只"吞金兽"(昂贵的计算逻辑),或者你的子组件是个"强迫症"(非要 Props 完全没变才肯不渲染),那你就得请出 React 性能优化的两尊大神了:useMemo 和 useCallback。
很多人分不清它俩,其实很简单:
useMemo缓存的是结果(脑子转完产出的东西)。useCallback缓存的是函数本身(干活的工具)。
今天咱们就拿一段真实的代码,扒一扒这俩货到底怎么帮我们省资源。
useMemo:给你的组件装个"缓存大脑"
想象一下,你有一个超级复杂的数学题要算(比如从 0 加到 1000 万)。
优化前:笨笨的复读机
看这段代码,我们有一个 slowSum 函数,它模拟了一个耗时的计算过程:
JavaScript
javascript
// 昂贵的计算:模拟 CPU 密集型任务
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); // 这个 state 和计算毫无关系
const [num, setNum] = useState(1); // 这个 state 才是计算需要的
// 😱 灾难现场:
// 只要组件重新渲染(比如你点击了 count+1),这行代码就会重新跑一遍!
const result = slowSum(num);
return (
<>
<p>计算结果:{result}</p>
{/* 点击这里,slowSum 居然也会执行?! */}
<button onClick={() => setCount(count + 1)}>Count + 1 (无辜路人)</button>
<button onClick={() => setNum(num + 1)}>Num + 1 (正主)</button>
</>
)
}
痛点 :当你点击 Count + 1 时,明明 num 没变,结果也没变,但 React 重新执行组件函数,slowSum 又傻乎乎地跑了一遍。页面卡顿随之而来。
优化后:学会"偷懒"
这时候 useMemo 就登场了。它像一个记性很好的会计,只有当依赖项(账本)变了,它才重新算。
JavaScript
scss
// ✅ 智能缓存
const result = useMemo(() => {
return slowSum(num);
}, [num]); // 👈 只有当 num 变了,才重新跑里面的函数
现在你再疯狂点击 Count + 1,控制台不会再打印"计算中...",页面丝般顺滑。
场景二:代替 Vue 的 Computed
除了昂贵计算,useMemo 也是处理派生状态 的神器,类似于 Vue 里的 computed。
比如这里有一个过滤列表的需求:
JavaScript
ini
const [keyword, setKeyword] = useState('');
const list = ['apple', 'banana', 'orange', 'pear'];
// 如果不用 useMemo:
// 每次组件渲染(比如 count 变了),filter 都会重新遍历数组。
// 虽然这里数组小看不出性能损耗,但如果是大数据列表,这就是性能杀手。
const filterList = useMemo(() => {
// 只有关键词变了,我才重新过滤
return list.filter(item => item.includes(keyword));
}, [keyword]);
(注:includes('') 默认为 true,所以初始状态会显示所有水果,完美符合搜索逻辑。)
useCallback + memo:父子组件的"定情信物"
接下来聊聊 useCallback。很多人觉得:"我不就传个函数给子组件吗,为啥要包一层?"
这得从 JavaScript 的特性说起。
优化前:最熟悉的陌生人
父组件给子组件传 Props,子组件用 React.memo 包裹,本来是想做性能优化(Props 不变就不重新渲染)。但是...
JavaScript
javascript
// 子组件:使用了 memo,理论上 Props 不变我就不渲染
const Child = memo(({ handleClick }) => {
console.log('👶 Child 重新渲染了 (我不想这样)');
return <div onClick={handleClick}>子组件</div>
});
export default function App() {
const [count, setCount] = useState(0);
// 😱 问题出在这里:
// 每次 App 重新渲染,handleClick 都会被重新定义!
// 在 JS 里,function A() {} !== function A() {}
// 引用地址变了 -> memo 认为 Props 变了 -> 子组件被迫渲染
const handleClick = () => {
console.log('click');
}
return (
<div>
{/* 我改了 count,跟 Child 半毛钱关系没有,但 Child 还是渲染了 */}
<button onClick={() => setCount(count + 1)}>Count + 1</button>
<Child handleClick={handleClick} />
</div>
)
}
痛点 :React.memo 就像一个严格的保安,它对比 Props 是否变化用的是"浅比较"(引用对比)。因为父组件每次渲染都生成一个新的函数地址,保安觉得:"这函数换人了!" 于是放行,导致子组件无意义渲染。
优化后:给函数发个"身份证"
useCallback 的作用就是把这个函数"固化"下来。
JavaScript
javascript
// ✅ 保持函数引用稳定
const handleClick = useCallback(() => {
console.log('click');
}, []); // 依赖为空,说明这个函数永远是同一个引用地址
现在,当你点击 Count + 1 时,父组件重渲染了,但 handleClick 还是原来那个 handleClick。Child 组件发现 Props 没变,就安心地躺平不渲染了。
注意 :如果你需要在回调里用到 count,记得把它加进依赖数组:
JavaScript
javascript
const handleClick = useCallback(() => {
// 如果依赖数组里没写 count,这里永远打印 0 (闭包陷阱)
console.log('click', count);
}, [count]);
// 👆 一旦 count 变了,函数引用还是会变,Child 还是会渲染。
// 这是为了保证逻辑正确性必须付出的代价。
总结
别为了优化而优化。useMemo 和 useCallback 也是有成本的(它们本身也需要消耗内存来做依赖对比)。
请遵循这套"心法":
-
useMemo:
- 昂贵计算 :当你看到
for循环次数巨大,或者复杂的递归时。 - 引用稳定 :当你计算出的对象/数组,要作为
useEffect的依赖项,或者传给被memo包裹的子组件时。
- 昂贵计算 :当你看到
-
useCallback:
- 配合 React.memo :当你的函数需要传给一个"很重"的子组件,且该子组件被
memo包裹时。 - 作为 Hooks 依赖 :当这个函数要被用作
useEffect的依赖项时。
- 配合 React.memo :当你的函数需要传给一个"很重"的子组件,且该子组件被