前言

对于不同团队而言,使不使用 useCallback和 useMemo 都有着不同的见解,看过很多文章都说不要用,作为深度使用 useCallback和 useMemo 这两个钩子的我来说,我完全能理解为什么很多人都说不要用。而这篇文章是帮助你理解 useCallback 和 useMemo,以便于结合实际来考量。

本篇文章基本概括了很多人对 useCallback 和 useMemo 的疑问,至于是要多用还是少用,相信看完你会有自己的思考。

如果你不太好考量,请看最后我结合我的开发习惯和公司情况的总结:尽量使用 useCallback 和 useMemo

注意:由于 useCallback(fn, deps) 相当于 useMemo(() => fn, deps) 。以及大部分人只争议 useCallback 要不要用,所以会着重针对 useCallback。

官方&源码

React官网对useCallback的介绍是比较简短的,我们一起来解析一下吧:

开篇第一句话:

返回一个 memoized 回调函数

意思很显而易见就是会返回被记忆的函数,具体如何实现,看看源码:

// 装载阶段
function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
// 获取对应的 hook 节点
const hook = mountWorkInProgressHook();
// 依赖为 undefiend,则设置为 null
const nextDeps = deps === undefined ? null : deps;
// 将当前的函数和依赖暂存
hook.memoizedState = [callback, nextDeps];
return callback;
}

// 更新阶段
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
// 获取上次暂存的 callback 和依赖
const prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
// 将上次依赖和当前依赖进行浅层比较,相同的话则返回上次暂存的函数
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
}
// 否则则返回最新的函数
hook.memoizedState = [callback, nextDeps];
return callback;
}

虽然看上去复杂,但简单的理解的话,其实就是运用了闭包,在内部维护了依赖和函数,通过判断前后依赖是否改变,再更新这个函数并返回;

分析

如果有兴趣可以也按这个思路分析 useMemo 的源码,这里节省内容就不分析 useMemo了。

初始化阶段:

  • ❌ 函数 使用 useCallback 创建,内部产生闭包,缓存当前 deps的引用。

依赖不变的更新阶段:

  • useCallback diff deps
  • ✅ 判断 deps 无变化,直接取缓存的函数

依赖变化的更新阶段:

  • useCallback diff deps
  • ❌ 判断 deps 改变,重新生成方法给 函数 引用
    其实可以发现,useCallback 会给我们做很多操作,大部分可能是无谓的操作,是浪费开销的。

为了更详细的理解,你可以看下面的思考:

思考

举个实际的例子,以便思考:

const handleClick1 = () => {}
const handleClick2 = useCallback(()=>{
// 和handleClick1 一样
},[ deps ])

执行 handleClick2 需要经历哪些步骤:

1、初始化,使用 useCallback 创建函数

2、申请 useCallbck 第一个参数对应的函数所需要的内存,这一点的花费基本和 handleClick1 的开销一样。

3、在内存中保存上一次的依赖,和最新的依赖浅比较。

这样看来,他并没有节省开销嘛,那又怎么谈得上优化呢?有了这样的疑问我们再看使用场景。

使用场景

官网的第二段,第一句告诉怎么用返回什么,第二句告诉在什么情况下用。第一句话已经分析过了,我们注意第二句话。

传递给 经过优化 并使用 引用相等性 去避免非必要渲染的子组件时,它将非常有用。

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

这里我们就可以知道一点官网的态度了,它告诉我们在使用 shouldComponentUpdateReact.memo)的子组件,这种情况下非常有用。

先说这种非常有用的情况:

const Parent = () => { 
console.log("Parent-render");
const [count, setCount] = useState(0);
const [age, setAge] = useState(18);

const addCount = () => {
setCount(count + 1);
};

const addAge = () => {
setAge(age + 1);
};

const onChange = useCallback((value) => {
//...doSomthing
console.log("value", value);
}, []);

return (
<div>
<Button type="primary" onClick={addCount}>
add account
</Button>

<Button type="primary" onClick={addAge} className="ml10">
add age
</Button>

<Child onChange={onChange} />
</div>
);
};


const Child = React.memo(function (props) {
console.log("子组件函数Child...");
const { onChange } = props;

const onCheckChange = useCallback(
(checkedValues) => {
onChange && onChange(checkedValues);
},
[onChange]
);

return (
<div>
<Checkbox.Group onChange={onCheckChange}>
<Checkbox value="A">A</Checkbox>
<Checkbox value="B">B</Checkbox>
<Checkbox value="C">C</Checkbox>
</Checkbox.Group>
</div>
);
});

上述是没有加useCallback的示例,当Parent 组件render时,onChange 函数会被重新创建。每次 render 时 Child 参数中会接受一个新的 onChange 参数,这会直接击穿 React.memo,导致性能优化失效,并联动一起 render。这里看效果

单从这个例子来看我们可以发现

1、不用useCallback, 父组件的每次render,都会导致子组件重新渲染。

2、不用React.memo, 也会造成同样问题。(所以说要搭配使用)

由此我们对 useCallback 已经非常清楚了,但是基于以上的认知,产生了两种截然不同的声音:尽量使用和尽量不使用。

尽量不要使用 useCallback

大部分场景不仅没有提升性能,反而让代码可读性变得很差。

以下为我调研总结不支持使用 useCallback 同学辩论得出的观点:

1、创建函数的性能消耗就不在优化范围内useCallback是防止函数重新创建,然而重新创建函数是一个非常轻的根本不需要优化的动作。

2、useCallback其实是防止子函数的rerender,但必须配合 react.memo,不然没有意义。

3、既浪费缓存又浪费时间。浪费缓存这个是已知的了,时间这个差距不是很大,简单的测下来发现不用的时间还短一些(后续会解释为什么会慢)。

而 useCallBack 节省的那点创建函数的时间根本不算优化,函数里该执行多少时间还是会执行多少时间。至于为什么甚至会多时间,因为其实每次组件重新渲染时,都可避免重新创建内部函数,因为即使useCallback的deps没有变,它也会重新创建内部函数作为useCallback的实参

4、useCallBack 和 useMemo 不一样,useMemo 应该大量使用useMemo 是缓存计算值,当计算值是 expensive 的时候,用它是非常有用的,可以大大减少计算时间。

大部分场景没有性能提升

为什么我们会觉得有性能提升呢?

1、useCallback可以避免重复生成函数

重新生成函数代价十分低,并且使用 useCallback 也不能减少必要的创建,可见此实例:链接

  • 使用useCallback会产成额外的性能:对deps的判断。
  • 其实每次组件重新渲染时,都可避免重新创建内部函数,因为即使useCallbackdeps没有变,它也会重新创建内部函数作为useCallback的实参

2、作为props传给子组件时候,也可以避免子组件重复渲染。真正有用的是能为使用 memo 的组件减少 render。

代码可读性变差

综上useCallback 可能并不会给我们带来想要的性能提升,可能还会让代码的可读性变差,我们再来看代码:

1.让人费解的嵌套依赖

const someFuncA = useCallback((d, g, x, y)=> {
   doSomething(a, b, c, d, g, x, y);
}, [a, b, c]);

const someFuncB = useCallback(()=> {
   someFuncA(d, g, x, y);
}, [someFuncA, d, g, x, y]);

useEffect(()=>{
  someFuncB();
}, [someFuncB]);

2.操作意外,闭包问题,函数拿到旧值

原理上有 eslint 检查报 warn 我们是能知道要把依赖都全部加上的。但是还是会有失误的时候。

比如在函数中:注释了一段代码,忘记把依赖删去。新增一段代码,存在新增依赖,忘记加上去。

总是需要凭借程序员的警觉,来防止犯错。

3.调用顺序问题
这也不算是特别大的问题,算是裸写 function 带来变量提升的一点好处吧。函数顺序能自己决定,在代码阅读起来其实也会更加舒畅。

关于提前优化的思考

为什么会有这种思考呢?

因为总结起来很明确的是 useCallback 只有在搭配 memo 使用才有用,其他大部分情况没有优化。

然后有一个观点纠结我很久,父组件不知道子组件用了 memo 而他没使用 useCallback 从而导致优化失效。=>所以为了避免这种情况我们应该要多使用 useCallback 避免这种情况发生。

我找寻了很多文章,都没有人说这个问题,他们都局限于 useCallback 没有优化不要用,而没想过这种情况。对于这种想法我也只在https://zhuanlan.zhihu.com/p/88593858 这篇文章能看到解决方案。

  • 自封装useCallback 函数,添加__useCallback__属性,并在run-time检查是否存在这个属性。
  • eslint插件去检查。但实际我并没有找到这样的。
  • 团队约束:新人进来就要求全部加上 useCallback 或者 review检查。

思想的碰撞💥

三个观点:

  1. 尽量使用
  • useCallback会造成每次渲染时函数的重建。

    • 1.重建时间短,多创建一两次甚至更多次,不会造成什么影响
    • 2.和 useMemo 不一样,useMemo能切实的减少重复计算(尤其 expensive 的计算)
    • 3.使用 useCallback 花费时间其实反而会更长(真),用缓存也没换时间
  • 不使用useMemouseCallback会造成React.memo失效

    • 情况很少,因为真的没有那么多使用了React.memo的非必要不渲染的子组件呀。
  • 不使用useCallbackuseEffect无法依赖 回调函数

    • 尽量不要把函数作为 useEffect 的依赖
  1. 尽量不使用
    • 1.不使用,你真正遇到问题,能解决问题吗?
      • 能呀,并且有比 useCallback 更好用的方法解决
    • 2.你不知道你的子组件是否用了React.memo,然后你没加 useCallback 不就有问题了?
      • 在这种情况下确实有优处呀,它就是为了解决这个的。但为了优化这一点,而选取让其他组件都反优化是否有点不太值当?
      • 我能通过一些方法(如上述例子),在runtime报错,强制让父组件包裹 useCallback
    • 3.尽量使用我觉得并不会造成你说的那么多问题
      • 1.关于嵌套依赖,useCallback 的依赖很好加,按照提示点一下就好了。到 useEffect 里绝大多数情况我们不会把函数作为依赖。
      • 2.关于闭包问题,按照提示写依赖不会存在问题

3.总是思考用不用
你需要总是了解组件的情况
不用思考呀,就总是不用,子组件用了 memo 的时候再包一下呀

强制加上useCallback和useMemo除了多写两行代码并不会影响代码的稳定,如果因为依赖少加导致取到旧的值,我觉得是开发者的问题(而且我们有编辑器帮助我们检查是否有漏掉依赖,这不应该是个问题)

那我还觉得你父组件 在子组件使用了 memo 优化的情况下函数没加 useCallback是开发者的问题呢。

尽量使用

  • 不使用useCallback会造成每次渲染时函数的重建 ❌ 创建函数时间非常小、内部还是会重新创建
  • 不使用useMemo和useCallback会造成React.memo失效 ✅
  • 不使用useCallback 则 useEffect无法依赖 回调函数 ✅ 但大多数情况useEffect不应该依赖回调函数
  • React.memo 类似 PureComponent 能用就用 ✅ 优化,但不是说全部要使用

损失小性能换大性能

首先分情况,对于简单组件,使用 useCallback 可能会造成一定的损耗,但是对比差别其实非常小,组件不会因为你使用了 useCallback 而产生性能问题。

反而对于复杂组件或者说是需要被优化的组件,有可能就会因为没有使用 useCallback 而造成优化失效而产生问题。

产生问题是不会用 useCallback

1.依赖嵌套,useCallback和 useMemo 里的依赖都是要按提示补全的,通过依赖你也可以知道一些信息。比如你说的这个例子:

const someFuncA = useCallback(()=> {
   doSomething(a, b, c);
}, [a, b, c]);

const someFuncB = useCallback(()=> {
   someFuncA(x, y);
}, [someFuncA,x, y]);

useEffect(()=>{
  someFuncB();
}, [someFuncB]);
  • 首先能在 useEffect 依赖里写的函数必须要useCallback 包裹,如果你要这样写的话(的确还是有场景需要这样做的)。
  • 其次这样改成不用 useCallback 那就更错了。你可能要去找究竟是那个依赖改变了要触发 useEffect。
  • 更多时候都不会有问题,如果你足够了解 useEffect 的话

大部分场景可能是这种

const getData = useCallback((a)=> {
   //doSomething...
}, []);

useEffect(()=>{
  getData(a);
}, [a]);

对于新人的培养

这点很多文章其实没有考虑到,主观思维是这个新人一定是非常了解 useCallback 的,知道什么情况下用。

但其实我觉得这只对一部分人是有效的,很多情况下,如果你不要求他多写 useCallback ,他根本不知道原来这里要优化要用 useCallback

举个简单的例子就是,你知道一个公式,但你从来不使用,你觉得碰到应该使用这个公式的题的时候,他能使用起来嘛?

对于团队

对于团队一定是要有规范的,不然很容易乱。对于用不用呢,每个团队都有自己的考量。我也完全可以理解不同团队做出的选择,emmm 可能有的团队没有想过东西,就随着不同开发者的个性写。

作为一个团队可以从这几个点出发去想:

  • 重视代码的稳定、可维护
  • 重视团队新成员的成长
  • 重视团队规范

至于争论

导致争论的最大原因,我觉得可以归为以下几个原因:
1、团队差异
2、官方文档没有详细介绍
3、个人对官方文档理解差异

参考文章

等…
感谢这些作者的文章,以及感谢提供观点的我的组员和不愿露面的网友。

🌸🌸🌸🌸🌸

非常感谢你看到这,如果觉得不错的话点个赞 ⭐ 吧

今天也是在努力变强不变秃的 HearLing 呀 💪

🌸🌸🌸🌸🌸