前言
在学习hooks的时候,我们或多或少会接触到useCallback,知道可以用它来处理传递给子组件的函数,避免函数的不必要更新。但在实际开发场景中,useCallback所带来的开发负担也许是超乎想象的;还会出现由于依赖项传染的问题,导致更新不及时,从而出现bug
平时会使用useCallback的场景:
- 函数被
useEffect所使用,函数作为依赖项,为了避免useEffect频繁调用,用useCallback包一下
但其实开发者很难去管理函数引用,所以最好还是不要这么干,尽管有时候确实这么干了;
useEffect的执行依赖的应该是状态而非函数引用
- 当函数会被传递给子组件,为了避免子组件频繁渲染,使用 useCallback 包裹,保持引用不变
- 因为有一个使用了
useCallback的函数引用了我这个函数,所以我不得不包一下(本质和第一点差不多)
尽量别用
会导致的问题:
- 性能会变差:
useCallback/useMemo在内存中额外存了函数及依赖数据,很多情况下,用不用useCallback其实都一样,反倒是增加了缓存的开销 - 传染性:
useCallback在组件内的存在一定的传染性: 如果callbackA中调用了另一个函数B,为了在callbackA运行的时候调用的是B最新的快照,需要把B加到callbackA的依赖里,但是为了保证useCallback有意义,则B也得用useCallback包裹:
// ❌ callbackA 里不一定能拿到 b 的最新快照
const b = () => { stateB };
const callbackA = useCallback(() => {
b()
}, [stateA])
// ❌ 每次 render,b 都是一个新的引用,callbackA 的 useCallback 就没有意义
const b = () => {};
const callbackA = useCallback(() => {
b()
}, [stateA, b])
// b 函数也需要用 useCallback 包裹
const callbackB = useCallback(() => {
},[stateB])
const callbackA = useCallback(() => {
callbackB()
}, [stateA, callbackB])
一整套下来的话,依赖图就会越来越复杂。以这个例子为例,callbackB由stateB派生而来,但真正导致callbackA执行的却是stateA stateB;如果callbackA和callbackB都依赖了stateB,那么callbackA的依赖项还要不要写callbackB呢?
如果非要使用:
传参代替依赖:
如果callbackA内部需要用到stateA,而调用callbackA的地方可以获取到stateA的最新快照,则最好把stateA从依赖变为参数
// 当stateA变化的时候,函数引用会变,子组件尽管使用React.memo也得发生不必要的渲染
const callbackA = useCallback(() => {
}, [stateA])
// 但是如果把依赖作为参数传入,函数始终不变但也能拿到对应的值
const callbackA = useCallback((stateA) => {
}, [])
// 子组件
<sonCom fn={callbackA} />
const sonCom = memo(() => {})
这个时候不禁就要产生疑问?既然把依赖作为参数传入的话,那跟封装成
utils工具函数有啥区别?
- 前者通常函数逻辑与组件紧密相关,不适合提取为工具函数
- 参数通常由父组件传递给子组件,再由子组件调用时传入;但是工具函数是可复用的独立逻辑,通常直接在需要使用的地方传入参数
- 或者说,传给前者的参数是组件状态,而传给工具函数的参数仅仅是数据,逻辑是纯函数且与组件状态无关
其实进一步抽象一下,useCallback适用于处理只需要初次创建的函数(即依赖项为[]),但这种场景往往比较少,常见于:处理DOM操作、ref引用等
ref + useCallback:
import { useCallback, useRef, useEffect, useState, memo } from 'react';
// 类似 useEvent 的实现
function useMyEvent<T extends (...args: any[]) => any>(callback: T) {
const ref = useRef(callback);
ref.current = callback; // 每次渲染后更新为最新的回调
// 依赖为空,传入的 cb 地址永远不变
return useCallback((...args: Parameters<T>) => ref.current(...args), []);
}
const Child1 = memo(({ onClick }: { onClick: () => void }) => {
console.log('Child1 渲染了!'); // 引用不变,不会重复打印
return <button onClick={onClick}>子组件1按钮</button>;
});
const Child2 = memo(({ onClick }: { onClick: () => void }) => {
console.log('Child2 渲染了!'); // 引用变化,会重复打印
return <button onClick={onClick}>子组件2按钮</button>;
});
function App() {
const [count, setCount] = useState(0);
// ✅ 函数地址不变,但内部能访问最新的 count
const handleClick1 = useMyEvent(() => {
// 依据state执行某些复杂逻辑
console.log('handleClick1 exe', count); // 永远是最新的 count
});
const handleClick2 = useCallback(() => {
// 依据state执行某些复杂逻辑
console.log('handleClick2 exe', count);
}, [count]);
return (
<div>
{count}
<hr />
<button onClick={() => setCount(count + 1)}>Add Count</button>
<hr />
<Child1 onClick={handleClick1} />
<hr />
<Child2 onClick={handleClick2} />
</div>
);
}
export default App;
分析一下useMyEvent的实现:
useRef声明的变量在状态更新前后是保持引用不变的- 其内部借助
ref这一特性,虽然useCallback只会在初始时创建一次(函数地址不发生变化),但却能同时保证每次更新可以拿到最新的回调 - 回调最新但引用不变,所传递的子组件使用
Reacr.memo包裹之后,实现真正意义上的非必要不更新
其他类似的实现:ahooks的useMemoizedFn方法,效果与上述一致