今天来聊聊使用useCallback进行React优化中的翻车现场。
相信很多写React的伙伴在遇到页面卡顿,组件重复渲染时,第一时间想到的性能优化的方案就是使用useCallback包裹函数,React.memo包裹组件。
但是结果往往事与愿违,性能反而变差了,今天就我的理解来聊聊为什么会这样?React性能优化里的三剑客:React.memo、useMemo和useCallback,到底应该怎么使用,用来干什么的?
1.为什么加了memo,它还是在疯狂渲染?
我们来复现一个经典的踩坑现场。
先写一个子组件<Child />,每次渲染耗时1000ms。为了优化,加上React.memo,逻辑是:只要我Child数据没变,它就不会重新渲染。
const Child = React.memo(({ onClick }) => {
const start = performance.now();
while (performance.now() - start < 1000) {
// 空转,阻塞 1 秒
}
const end = performance.now();
const renderTime = (end - start).toFixed(2);
const componentStyle = {
border: '2px solid #000',
padding: '20px',
borderRadius: '8px',
color: 'red',
background: '#9c9c9cff'
};
return (
<div style={componentStyle}>
<h3></h3>
<p>我是Child,每次渲染,耗时:{renderTime} ms</p>
<button onClick={onClick} style={{padding: '5px 10px', cursor: 'pointer'}}>
点我调用父组件传来的函数
</button>
</div>
);
});然后父组件中,调用子组件,并传入函数:
function Parent() {
const [count, setCount] = useState(0);
// 每次 Parent 渲染,都会生成一个全新的 handleClick 函数
const handleClick = () => console.log('点我了')
return (
<div style={{ padding: '30px'}}>
<h1>我是Parent</h1>
<p>当前 Count: {count}</p>
<button
onClick={() => setCount(count + 1)}
style={{fontSize: '16px', cursor: 'pointer',border:'2px #000 solid '}}
>
点我更新父组件State
</button>
<hr style={{margin: '20px 0'}} />
<Child onClick={handleClick} />
</div>
);
}然后我们跑起来,抓一下渲染实况:

我们可以清楚看到子组件发生了重绘,且原因是点击了父组件按钮。
可是为什么子组件已经缓存,触发父组件函数更新State还会引发子组件渲染呢,其实这是因为React在比较props有无变化时,用的是浅比较。在JS中,每次Parent重新执行,传递的handleClick函数都会在内存中开辟一块全新的空间。
对于React.memo来说,至此传进来的函数地址和上次不一样,说明props变了,重新渲染!
(PS:这里其实涉及到了一点“值和引用”,关于JS如何对内存中的“栈(Stack)”和堆(Heap)进行分配,我们有时间可以单独拉一篇讲讲。)
2. 正确使用方式
为了解决上面这种因为内存“地址变化”导致的踩坑,我们应该正确使用缓存。
React.memo:高阶组件,只要props跟上次一模一样,我就不让你进去重新渲染。useCallback:缓存函数。只要依赖项没变,就保证每次都是同一个内存地址的函数。useMemo:缓存复杂计算结果或对象。只要依赖不变,每次返回的都是同一个对象地址。
所以,我们应该在Parent中这样解决:
// 必须把要传给 memo 组件的函数和对象,都缓存起来!
const handleClick = useCallback(() => console.log('点我了'), []);
// React.memo一看,地址还是上次那个,放行,不渲染!
<Child onClick={handleClick} />3. 思考:那我们就应该把所有东西都缓存么?
当然不是!优化是有成本的。
useMemo 和 useCallback 本身也是函数,每次渲染 React 都要去检查它们的依赖数组,还要去开辟空间把结果存起来。如果你在组件里写了满屏的 useCallback,可读性变差的同时,一个属性的意外变化,就会导致整个流程的崩溃:React.memo停止工作,useCallback变得毫无意义,useMemo再变得毫无意义。特别是props的传递,来自”props的props“。
const Child = () => { };
const ChildMemo = React.memo(Child);
const Component = (props) => { return < ChildMemo {...props} />; };
const ComponentWrapper= (props) => { return < Component {...props} />; };
const InitialComponent = (props) => {
// 此组件有状态 → 它会重新渲染 → 其下方的所有内容都会重新渲染
return ( < ComponentWrapper { ...props } data = {{ id: ' 1 ' }} /> );
};尤其是当这些组件位于不同文件,拥有自己的逻辑,且相当复杂时,任何一个层级的破坏,就会导致整个链路的崩溃。
那什么时候用呢?
传给
memo组件的props:需要传递引用类型时,必须使用useCallback、useMemo,不然memo就失效了。耗时的计算:假设有一个上万条的数据需要做过滤、排序等操作时,果断用
useMemo缓存,防止主线程卡死。作为其他
Hooks的依赖项:在useEffect中监听某个函数或者对象时,防止死循环,需要用缓存。
什么时候不用?
普通事件:如果只是一个简单的点击事件函数,就没有必要用
useCallback了,原生DOM并不缺这点性能。简单组件:使用缓存的目的是优化性能,在交互逻辑比较简单的组件中,就没有必要使用缓存了。
最后
React给我们使用的这些钩子,是为了在遇到真正的性能瓶颈时,能够有优雅的方法解决,滥用只会让代码变成屎山。
下次写代码时,遇到想敲useMemo的时候,不妨停下好好想一想:“这玩意真的需要缓存么?”
最后的最后,祝大家的代码少一点无意义的 re-render,多一点准点下班的快乐!