【React 源码阅读】useCallback

136 阅读3分钟

1. 背景

初入 React Hooks 的小伙伴可能比较疑惑,为什么 useCallback 这个 Hook 每次写一个都要传入相应的 deeps 呢?,简直不要太麻烦了。

2. 源码阅读

前面一篇文章里提到的类似,useCallback 也是用链表来进行存储和和初始化的。

2.1 mountCallback

mount 阶段,会执行 mountCallback,它本身需要传入两个参数:

  • callback:实际需要执行的函数
  • deps: 函数执行时需要传入的依赖
function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

可以看到,mountCallback 内还会执行 mountWorkInProgressHook 来生成链表,拿到的 hook 其实就是链表中的某个节点。 最终,会把我们传入的 callbackdeps 都绑定在 hook.memoizedState 上。

2.2 updateCallback

update 阶段,会执行 updateWorkInprogressHook 来更新链表,之后,会拿 nextDepsprevDeps 来进行对比:

function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
): boolean {
  if (__DEV__) {
    if (ignorePreviousDependencies) {
      // Only true when this component is being hot reloaded.
      return false;
    }
  }

  if (prevDeps === null) {
    if (__DEV__) {
      console.error(
        '%s received a final argument during this render, but not during ' +
          'the previous render. Even though the final argument is optional, ' +
          'its type cannot change between renders.',
        currentHookNameInDev,
      );
    }
    return false;
  }

  if (__DEV__) {
    // Don't bother comparing lengths in prod because these arrays should be
    // passed inline.
    if (nextDeps.length !== prevDeps.length) {
      console.error(
        'The final argument passed to %s changed size between renders. The ' +
          'order and size of this array must remain constant.\n\n' +
          'Previous: %s\n' +
          'Incoming: %s',
        currentHookNameInDev,
        `[${prevDeps.join(', ')}]`,
        `[${nextDeps.join(', ')}]`,
      );
    }
  }
  // $FlowFixMe[incompatible-use] found when upgrading Flow
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    // $FlowFixMe[incompatible-use] found when upgrading Flow
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

代码很简单,其实就是对 prevDepsnextDeps 做了一层浅比较:

  • 相等,则返回之前缓存的 callback
  • 不相等,则返回当前定义的 callback

完整代码:

function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

所以可以看出, useCallback 就是对函数做了一层缓存,deps 在比较时是做的浅比较。

3. useCallback 为什么难用

3.1 hook 带来的闭包问题

3.1.1 什么是闭包

闭包是由捆绑起来(封闭的)的函数和函数周围状态(词法环境)的引用组合而成。换言之,闭包让函数能访问它的外部作用域。在 JavaScript 中,闭包会随着函数的创建而同时创建。

简而言之,当函数在创建时,依赖了外部作用于的变量,此时就会 形成闭包。

一个最简单的例子:

function createCounter() {
  let num = 0
  return {
    count: () => {
      return ++num
    }
  }
}

let counter = createCounter()

counter 在创建完成之后,始终会依赖 createCounter 里创建的变量,此时就形成了闭包。

3.1.2 React Hook 里的闭包

举个例子:

import React from 'react';

export default function App() {
  const [count, setCount] = React.useState(0);

  const updateCount = React.useCallback(() => {
    setCount(count + 1)
    console.info('count value is ', count)
  }, []);

  return (
    <div className='App'>
      <div>count is: {count}</div>
      <button style={{ marginTop: 8 }} onClick={updateCount}>
        update
      </button>
    </div>
  );
}

这个例子里面,count 拿到的值始终都是 0。 跟上面的例子很像,不过要再理解一下 React 的运行过程:即每次渲染的之后 App 都会重新执行一次,相当于每次都会创建 App 里都会形成新的闭包。
但是在这个例子里,由于我们没有传入 deps,所以即使触发了 App 重新渲染,updateCount 依赖的上下文也始终没有改变过,所以不难知道 count 的值始终都会是 0。

4. 总结

4.1 useCallback 为啥要传 deps

  1. 为了解决 React Hook 带来的闭包问题
  2. 可以减少函数创建带来的性能开销
优点缺点
节省函数创建时间;避免闭包问题心智负担重,并且有额外内存开销

4.2 最佳实践

  1. 不要直接使用 useCallback,需要的时候再用它:
  • 不使用的场景:比如直接在 render 内使用的函数,其实就没太大必要包一层 useCallback
  • 使用的场景:比如在 useEffect 或者其他 hook 中用到了这个函数,就可以考虑用 useCallback 套一层。否则会因为每次渲染时 callback 都是一个新引用而导致重复执行 hook。
  1. 建议使用 ahook 封装的 useMemoizedFn 来包一层 callback(而不是使用 useCallback)