写在前面
在 React 开发中,性能优化常常不是靠"大改架构",而是通过一些细小却关键的实践来实现。其中,useMemo 和 useCallback 就是两个看似简单、却极易被误用或忽略的重要 Hook。它们的作用不是改变功能,而是在组件频繁更新时,避免重复计算和无效渲染,让应用更流畅、响应更快。
一个小问题
让我们先来看一段存在性能问题的代码:
jsx
import {
useState,
useMemo
} 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 filterList = list.filter(item => {
console.log('filter 执行'); // count 变的时候
return item.includes(keyword)
})
const result = slowSum(10)
return (
<div>
<p>{result}</p>
<button onClick={() => setNum(num + 1)}>num+ 1</button>
// keyword会更新
<input
type="text"
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
{count}
//count会更新
<button onClick={() => setCount(count + 1)}>count+ 1</button>
</div>
)
}
上面代码缺点在于它在每次组件重新渲染时都会执行不必要的计算,造成性能浪费甚至界面卡顿。
第一处:
jsx
const filterList = list.filter(item => {
console.log('filter 执行');
return item.includes(keyword)
})
这是段不必要的列表过滤, 每次组件重新渲染(例如你点击按钮 setCount(count + 1)),这行代码就会重新执行一次。
这显然是没有必要的,它只和keyword (键盘输入)有关系,为什么"我"(count)要重新渲染呢?
第二处:
jsx
const result = slowSum(10)
具体来说,filter 操作虽然逻辑简单,但每次点击 count 按钮(即 count 状态更新)都会触发整个组件函数重新执行
slowSum 本是一个固定的函数调用,结果是相同的。
但是!!!
它却写在了函数体内
也就是说我每次点击 count+1 时,页面就会卡住几百毫秒去重复计算一个早已知道答案的值
用户每点击一次按钮,引擎都不得不花费几毫秒甚至更长时间来执行这些重复的计算和渲染。
如果换成一个耗时更长的操作,比如复杂的同步计算或大量数据处理,主线程就会被阻塞,导致页面卡顿、输入无响应、动画掉帧------用户体验将变得极其糟糕。
这两个问题都违背了 React 性能优化的基本原则:避免在渲染过程中执行昂贵或与当前状态无关的计算。
正确的做法是将固定值提前计算好,或将依赖特定状态的计算用 useMemo 包裹起来,确保只在真正需要时才重新计算。
useMemo Hook
为了解决上述两个问题,React 提供了 useMemo Hook ------ 它的作用是缓存计算结果,只有当依赖项发生变化时,才会重新执行计算。
我们对代码做两处关键改造:分别是列表渲染和复杂CPU计算部分
1. 用 useMemo 包裹列表过滤逻辑,仅在 keyword 变化时重算:
jsx
const filterList = useMemo(() => {
// computed
return list.filter(item => item.includes(keyword))
},[keyword]);
2. 对昂贵计算使用 useMemo
jsx
const result = useMemo (() => {
return slowSum(num);
},[num]);
为了解决重复计算的问题,我们可以使用 useMemo 来缓存那些开销较大或与当前渲染无关的计算结果。
比如,将 list.filter(...) 包裹在 useMemo 中,并指定依赖项为 [keyword],这样只有当用户输入关键词发生变化时,过滤操作才会执行,
而点击 count + 1 这类无关状态更新就不会再触发无谓的重算;
同样地,对于 slowSum 这样的昂贵计算,如果它依赖某个状态(如 num),就用 useMemo 将其包裹并声明依赖
确保只在 num 改变时才重新计算,而如果参数是固定值(如 slowSum(10)),更高效的做法是直接在组件外部提前计算一次,避免在每次渲染中重复执行
通过这种方式,我们让 React 只在真正需要时才进行计算,既避免了主线程阻塞,又提升了整体性能和用户体验。
React.memo
我们刚刚用 useMemo 解决了"重复计算值"的问题,但 React 中还有一类常见的性能陷阱:重复创建函数。
在 React 的默认行为中,当父组件重新渲染时,其内部定义的所有子组件(即使没有 props 变化)也会随之重新渲染;
而如果这些子组件被 React.memo 包裹以跳过不必要的更新,那么是否重渲染就取决于传入的 props 是否发生了变化。
问题在于,每当父组件重新执行,写在它内部的箭头函数或普通函数(比如事件回调)都会被重新创建,哪怕代码逻辑完全没变------这会导致函数引用发生变化。
例如,一个处理点击的回调函数,如果每次父组件因 count 更新而生成一个新函数,就会破坏子组件的缓存机制。
这时候,我们就需要 useCallback ------ 它的作用和 useMemo 类似,但专门用于缓存函数本身,确保只有在依赖项变化时才返回新函数,从而稳定引用,避免子组件因无关更新而无效重渲染。
举个生活中的例子:
想象你每次去咖啡店点单,都对店员说:"请给我一杯拿铁,加一份燕麦奶。"
这句话的内容(逻辑)每次都一模一样,但如果你每次都换一个新朋友替你开口说话,哪怕他说的字字相同,店员也会觉得"哦,这是另一个顾客",于是重新走一遍确认流程、更新订单系统,甚至可能多花几秒钟核对。
在 React 中,父组件每次渲染就像你"重新派一个人去点单"------即使点的是完全相同的饮品(函数逻辑没变),但因为"说话的人"(函数引用)变了,被
React.memo包裹的子组件(比如那个讲究效率的店员)就会认为"输入变了",不得不重新处理整个请求,造成不必要的开销。而
useCallback就像是你本人一直站在柜台前,始终用同一个声音、同一个身份说同一句话。店员一看:"还是你啊,需求没变",就直接复用之前的准备,省时省力。这样,函数的"身份"稳定了,子组件才能真正发挥缓存的优势。
jsx
const Child = memo(({count,handleClick}) => {
console.log('child 重新渲染');
return (
<div onClick={handleClick}>
子组件{count}
</div>
)
})
这里我们点击按钮,只改变了 otherNum,count 和 handleClick 的逻辑都没变。按理说,Child 不该重渲染。
但事实是:它依然重渲染了。
原因在于:在 JavaScript 中,函数是引用类型。
每次 App 组件重新执行,const handleClick = () => {...} 这一行都会创建一个全新的函数对象 。虽然代码一模一样,但它们在内存中的地址不同,因此 func1 !== func2。
而 React.memo 正是通过 prevProps.handleClick === nextProps.handleClick 这样的引用比较来判断 props 是否变化的。一旦发现 handleClick 是个"新函数",它就认为 props 变了,于是放行子组件的重渲染------优化就此失效。
jsx
export default function App() {
const [count, setCount] = useState(0);
const [num, setNum] = useState(0);
const handleClick = useCallback(() => {
console.log('click');
},[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>
)
}
useCallback Hook
要解决这个问题,关键不是不让函数"看起来一样",而是让它真的是同一个函数。
这时候就轮到 useCallback 登场了。它的作用就是:缓存函数的引用,只要依赖项不变,就始终返回同一个函数实例。
jsx
import { useState, useCallback } from 'react';
export default function App() {
const [count, setCount] = useState(0);
const [otherNum, setOtherNum] = useState(0);
const handleClick = useCallback(() => {
console.log('点击了子组件');
}, []); // 永远返回同一个函数引用
return (
<div>
<button onClick={() => setOtherNum(otherNum + 1)}>
修改无关数据 ({otherNum})
</button>
{/* 此时,handleClick 引用没变,count 也没变,Child 完全不会重新渲染! */}
<Child count={count} handleClick={handleClick} />
</div>
);
}
现在,无论你点击多少次"修改无关数据",handleClick 始终是同一个函数,
count 也没变,React.memo 在浅比较时发现两个 props 都没变,就会直接跳过子组件的渲染,控制台也不会再打印"子组件渲染了"。
这是因为 React.memo 虽能拦截子组件更新,但前提是 props 的引用保持稳定;而函数在每次组件渲染时默认都会创建新引用,破坏了这种稳定性;
useCallback 正是用来缓存函数、确保相同逻辑对应同一引用的关键工具;
只有将
React.memo与useCallback配合使用,才能真正避免因函数引用变化导致的无效重渲染
总结
useMemo 和 useCallback 是 React 中提升性能的关键工具:
useMemo缓存计算结果,避免重复执行昂贵操作;useCallback缓存函数引用,防止因函数"新实例"导致子组件无效重渲染。
它们的核心思想一致:仅在依赖变化时才重新计算或生成新值,从而减少不必要的开销。
但切记:只在真正需要时使用 。简单逻辑无需优化,过度使用反而增加代码复杂度。合理搭配 React.memo,才能让应用既高效又清晰。