为什么要放弃使用 useCallback(useCallback 的缺点)

·  阅读 6556

下面是 useCallback 的基本用法:

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);
复制代码

在上面这段代码中,memoizedCallback 会在初始的时候生成一次,在后面的过程中,只有它的依赖 ab 变化了才会重新生成。

明白了 useCallback 的基本用法,我们把使用 useCallback 包裹的函数和不使用它包裹的函数放到一块对比一下:

function App() {
  const method1 = () => { 
    // ...
  }

  const  method2 = useCallback(() => {
      // 这是一个和 method1 功能一样的方法
  }, [props.a, props.b])

  return (
    <div>
      <div onClick={method1}>button</div>
      <div onClick={method2}>button</div>
    </div>
  )
}
复制代码

请问一下,在上面的对比之中,是 method1 的性能好,还是 method2 的性能好呢?

我听到你说话了,当然是 method2 呀!

我们的 App 函数在每一次更新的时候都会重新执行,由于这个原因,它内部的函数也都会重新生成一次,也就是说,我们的 method1 每次都会重新执行生成一遍。

method2 就不一样了,它是被 useCallback 包裹的返回值,除非依赖变化了,不然它不会重新生成,于是,你可能就会认为 method2 那种写法性能更高。

但是事实上呢,我们这么想是有些不正确的。

首先,每次执行函数,都重新生成一下它内部的变量这件事,开销是可以忽略不计的,这一点,官网的 Hooks FAQ 给出了我们相关的结论:

未命名.png

就算「每次执行组件都重新生成变量」这件事不值得忽略,使用 useCallback 也一样每次都会生成新的函数,只不过它生成的地方很隐蔽,只不过它生成了没有使用罢了。现在我们来仔细分析一下这件事。

const method1 = () => { }
const method2 = useCallback(() => {
        /* 一个和 method1 一样的方法 */
    }, 
    [props.a, props.b]
)
复制代码

假设现在处于更新阶段,执行到 method1,我们只需要申请并存储好 method1 这个变量对应的函数所需要的内存就好了。

但是执行到 method2 呢,

  1. 首先,我们要额外执行 useCallback 函数,
  2. 同时,我们也要申请 useCallbck 第一个参数对应的函数所需要的内存,这一点的花费就和 method1 的开销一样了,就算我们会使用缓存,useCallback 第一个参数的内存的开销也是要的。
  3. 除此之外,为了能判断 useCallback 要不要更新结果,我们还要在内存保存上一次的依赖。
  4. 并且,如果我们的 useCallback 返回的函数依赖了组件其他的值,由于 JS 中闭包的特性,他们也会一直存在而不被销毁。
const list = [...]
const method = useCallback(() => {
         console.log(list) // list 的引用会一直存在
    }, 
)
复制代码

这样看下来,使用 useCallback,比起原来没有半点好处。

我们再通过 useCallback 的源码确认一遍:

function updateCallback<T>(
    callback: T, // useCallback 的第一个参数
    deps: Array<mixed> | void | null // useCallback 的第二个参数
): T {

  // 取到当前的 useCallback 语句对应的 hook 节点,
  const hook = updateWorkInProgressHook();
  
  // 当前的依赖,后面拿来和上一次的依赖进行比较
  const nextDeps = deps === undefined ? null : deps;
  
  // 取到上一次缓存的函数
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    // 传了 useCallbck 的第二个参数才走到这里
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      // 上一次的依赖和这一次的依赖进行比较,
      // 相同就直接返回缓存的结果
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}
复制代码

相信看到这里,就知道为什么不能轻易使用 useCallbck 了吧?

不得不说,它的正确使用场景太少了。

有一个很典型的 useCallbck 错误使用的场景,说来惭愧,我也这么写过。如果我们按照 这篇文档 的说明为我们的项目增加 ESLint 的配置,写类似于下面这段代码的时候会报错:

export default function App() {
    const [count, setCount] = useState();
    const fetchApi = async () => {
        await fetch('https://jsonplaceholder.typicode.com/posts/1');
        console.log(count);
    };

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


    return <div>Hello World</div>;
}
复制代码

image.png

我不知道有多少人遇到过类似的错误。但是我们知道肯定不能把 fetchApi 这个函数加到依赖里面去。

对于,这个问题,最简单直接的解决方法就是把函数移动到 useEffect 里面。

这样做会让某些人感到不太习惯,特别是刚从 Class 组件过来的同学(文章主题的原因,这一点我们就不展开说了)。事实上, useEffect 的设计理念本身就比较推荐我们把它放在内部,我们得尝试着适应它。如果习惯了,其实就会觉得也挺好的。

但是,肯定也有无法放到内部的情况,那就可以采用下面几种方案:

image.png

上面的截图出自文档的 在依赖列表中省略函数是否安全?

请你注意一下第三条~ 它也说了,使用 useCallback 这种方法其实是万不得已,经过我们前面的分析,你应该也比较清楚了它这么说的原因了吧。

既然 useCallback 这么不好,那它什么时候可以用呢?

假设我们有一个叫做 Counter 的子组件,初始化渲染的时候消耗非常大:

<ExpensiveCounter count={count} onClick={handleClick} />
复制代码

如果我们不做任何优化,父组件有了任何更新,都会重新渲染 Counter。为了避免每次渲染父组件的时候都重新渲染子组件,我们可以使用 React.memo

const ExpensiveCounter = React.memo(function Counter(props) {
    ...
})
复制代码

使用 React.memo 包裹之后,Counter 组件只有在 props 发生变化的时候才会重新渲染,我们的 Counter 接受两个 props:原始值 count,函数 handleClick

如果父组件由于其他值的更改而发生了更新,父组件会重新渲染,由于 handleClick 是一个对象,每次渲染生成的 handleClick 都是新的。

这就会导致,尽管 CounterReact.memo 包裹了一层,但是还是会重新渲染,为了解决这个问题,我们就要这样写 handleClick 函数了:

const handleClick = useCallback(() => {
    // 原来的 handleClick...
}, [])
复制代码

这样,我们每次传递给 Counter 组件的 handleClick 都是同一个,我们的 Counter 组件只有在 count 发生变化的时候才会去渲染,这正是我们想要的,也就起到了很好的优化作用。

上面这个场景或许是 useCallback 为数不多的很适合的场景了。但是你在工作中碰到的某个子组件特别耗性能的情况多吗?反正我碰到的不多。

这周本来计划更新一篇解读 React 调度的文章的,但是我也不知道为什么,一点也提起不了干劲,不想看源码,也许下周就有干劲了。

奥,对了,这个周末天气好冷,看到这里的朋友,明天得记得多穿一点。

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改