useCallbcak性能陷阱:一个React初学者的思考

157 阅读4分钟

前言

在学习hooks的时候,我们或多或少会接触到useCallback,知道可以用它来处理传递给子组件的函数,避免函数的不必要更新。但在实际开发场景中,useCallback所带来的开发负担也许是超乎想象的;还会出现由于依赖项传染的问题,导致更新不及时,从而出现bug

平时会使用useCallback的场景:

  1. 函数被useEffect所使用,函数作为依赖项,为了避免useEffect频繁调用,用useCallback包一下

但其实开发者很难去管理函数引用,所以最好还是不要这么干,尽管有时候确实这么干了;useEffect的执行依赖的应该是状态而非函数引用

  1. 当函数会被传递给子组件,为了避免子组件频繁渲染,使用 useCallback 包裹,保持引用不变
  2. 因为有一个使用了useCallback的函数引用了我这个函数,所以我不得不包一下(本质和第一点差不多)

尽量别用

会导致的问题:

  1. 性能会变差:useCallback/useMemo在内存中额外存了函数及依赖数据,很多情况下,用不用useCallback其实都一样,反倒是增加了缓存的开销
  2. 传染性: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])

一整套下来的话,依赖图就会越来越复杂。以这个例子为例,callbackBstateB派生而来,但真正导致callbackA执行的却是stateA stateB;如果callbackAcallbackB都依赖了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工具函数有啥区别?

  1. 前者通常函数逻辑与组件紧密相关,不适合提取为工具函数
  2. 参数通常由父组件传递给子组件,再由子组件调用时传入;但是工具函数是可复用的独立逻辑,通常直接在需要使用的地方传入参数
  3. 或者说,传给前者的参数是组件状态,而传给工具函数的参数仅仅是数据,逻辑是纯函数且与组件状态无关

其实进一步抽象一下,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;
image.pngimage.png

分析一下useMyEvent的实现:

  1. useRef声明的变量在状态更新前后是保持引用不变
  2. 其内部借助ref这一特性,虽然useCallback只会在初始时创建一次(函数地址不发生变化),但却能同时保证每次更新可以拿到最新的回调
  3. 回调最新但引用不变,所传递的子组件使用Reacr.memo包裹之后,实现真正意义上的非必要不更新

其他类似的实现:ahooks的useMemoizedFn方法,效果与上述一致