Wenzi

React18 源码解析之 useCallback 和 useMemo

蚊子前端博客
发布于 2022/10/14 00:33
本篇文章我们主要了解下 useCallback 和 useMemo 是如何来优化React组件的。

我们解析的源码是 React18.1.0 版本,请注意版本号。React 源码学习的 GitHub 仓库地址:https://github.com/wenzi0github/react

React 中有两个 hooks 可以用来缓存函数和变量,提高性能,减少资源浪费。那什么时候会用到 useCallback 和 useMemo 呢?

1. 使用场景 #

useCallback 和 useMemo 的使用场景稍微有点不一样,我们分开来说明。

1.1 useCallback #

我们知道 useCallback 可以缓存函数体,在依赖项没有变化时,前后两次渲染时,使用的函数体是一样的。

很多同学觉得用 useCallback 包括函数,可以减少函数创建的开销 和 gc。但实际上,现在 js 在执行一些闭包或其他内联函数时,运行非常快,性能非常快。若不恰当的使用 useCallback 包括一些函数,可能还会适得其反,因为 React 内部还需要创建额外的空间来缓存这些函数体,并且还要监听依赖项的变化。

如下面的这种方式其实就很没有必要:

function App() {
  // 没必要
  const handleClick = useCallback(() => {
    console.log(Date.now());
  }, []);

  return <button onClick={handleClick}>click me</button>;
}

那 useCallback 在什么场景下会用到呢?

  • 函数作为其他 hook 的依赖项时(如在 useEffect()中);
  • 函数作为 React.memo()(或 shouldComponentUpdate )中的组件的 props;

我们来一一看下。

1.1.1 作为其他 hook 的依赖项 #

我们经常会有请求数据的场景,然后在 useEffect() 中触发:

function App() {
  const [state, setState] = useState();
  const requestData = async () => {
    // fetch
    setState();
  };
  useEffect(() => {
    requestData();
  }, []);
}

我们在官方脚手架 create-react-app 写这段代码时,他就会给出提示,大致意思是 useEffect 使用了外部的变量,需要将其添加到依赖中,即:

useEffect(() => {
  requestData();
}, [requestData]); // 将 requestData 添加到依赖项中

如若只是把它添加到依赖项中,再执行代码时,会发现代码陷入了无限循环。这是因为函数 requestData() 在每次 render()时,都是重新定义的,导致依赖项发生了变化,就会执行里面的 requestData(),进而触发 setState()进行下次的渲染,陷入无限循环。

为什么明明是同一个函数体,两个变量却不一样呢?比如下面的这个例子:

const funcA = () => {
  console.log('www.xiabingbao.com');
};
const funcB = () => {
  console.log('www.xiabingbao.com');
};

console.log(funcA === funcB); // false

他们的函数体仅仅是看起来是一样的,但实际上是完全独立的两个个体。上面的 requestData()同理,每次都是重新声明一个新的,跟之前的函数肯定就不一样了。

这个时候,我们就需要把 requestData()用useCallback()包裹起来:

const requestData = useCallback(async () => {
  // fetch
  setState();
}, []);

这就能保证函数 requestData()在多次渲染过程中是一致的(除非依赖项发生变化)。

1.1.2 作为 React.memo()等组件的 props; #

有一些我们是需要向子组件传入回调函数的场景,比如 onClick, onSuccess, onClose 等。

function Count({ onClick }) {
  const [count, setCount] = useState(count);

  const handleClick = () => {
    const nextCount = count + 1;
    setCount(nextCount);
    onClick(nextCount);
  };

  console.log('Count render', Date.now());

  return <button onClick={handleClick}>click me</button>;
}

function App() {
  const [now, setNow] = useState(0);
  const handleClick = count => {
    console.log('App count', count);
  };

  return (
    <div>
      <p>
        <button onClick={() => setNow(Date.now())}>set new time</button>
      </p>
      <Count onClick={handleClick} />
    </div>
  );
}

函数组件 <App /> 中的 handleClick 传给了子组件 <Count />,当父级组件触发更新时,子组件也会执行,只不过 state 没有变化而已。那么如何避免子组件必须要的刷新呢?这里我们就需要用到 React.memo 了(注意,这里不是 useMemo())。

React.memo()可接受 2 个参数,第一个参数为纯函数的组件;第二个参数是 compare(prevProps, nextProps)函数(可选),用于自行实现功能,对比 props ,控制是否刷新。

我们用React.memo()包裹住函数组件后,只需要保证传入的 props 不发生变化,那么函数组件就不会二次执行。

const MemoCount = React.memo(<Count />);

那传入的各种 callback 就得用useCallback()来封装了,如上面的 handleClick:

const handleClick = useCallback(count => {
  console.log('App count', count);
}, []);

1.2 useMemo #

useMemo() 与 useCallback() 的功能很像,只不过 useMemo 用来缓存函数执行的结果,而 useCallback()用来缓存函数体。

1.2.1 useMemo 的使用 #

如每次渲染时都要执行一段很复杂的运算,或者一个变量需要依赖另一个变量的运算结果,就都可以使用useMemo()

比如有一个计算百分比的场景:用户可以在某个项目中,捐赠自己的虚拟金币,不过项目接收的虚拟金币有上限,然后实时显示该项目的受捐进度。同时,进度展示这里,还有几个其他的规则:

  1. 进度的百分比的数字显示整数,向下取整;
  2. 只要有捐助行为,则百分比至少为 1%;
  3. 进度不能超过 100%(最后一次的捐赠可能会超过上限);

在某个组件获取进度百分比的时候,我们这里可以封装到useMemo()中,因为进度的百分比只跟当前进度和总上限有关系。

const curPercent = useMemo(() => {
  if (progress === 0 || topLimit === 0) {
    return 0;
  }
  const percent = (progress * 100) / topLimit;
  if (percent <= 1) {
    return 1;
  }
  if (percent >= 100) {
    return 100;
  }
  return Math.floor(percent);
}, [progress, topLimit]);

若当前进度和总上限没有变化时,则不用重新计算百分比。

1.2.2 其他变体 #

其实我们可以看到,useMemo()类似于 useEffect() 和 useState() 的组合体:

const [curPercent, setCurPercent] = useState(0);

useEffect(() => {
  if (progress === 0 || topLimit === 0) {
    setCurPercent(0);
    return;
  }
  const percent = (progress * 100) / topLimit;
  if (percent <= 1) {
    setCurPercent(1);
    return;
  }
  if (percent >= 100) {
    setCurPercent(100);
    return;
  }
  setCurPercent(Math.floor(percent));
}, [progress, topLimit]);

相应地,若遇到上面需要用 useEffect 和 useState 实现的场景,就可以直接用useMemo()来实现。

而且,useCallback()也是可以用 useMemo()来实现的。因为 useMemo()返回的是函数执行的结果,那我们返回的结果就是一个函数不就行了。

const handleClick = useMemo(() => {
  // 返回一个函数
  return () => {
    console.log(Date.now());
  };
}, []);

hanleClick();

2. 源码 #

我们了解了 useCallback() 和 useMemo() 的基本用法之后,再来了解下他们源码的实现。

我们在之前 renderWithHooks 的章节中也了解到,所有的 hooks 在内部实现时,都区分了 mount 阶段和 update 阶段,useCallback()和 useMemo() 两个 hooks 也不例外。

2.1 useCallback 的源码 #

useCallback()在 React 内部实现时,分成了 mountCallback()和 updateCallback()。

  • mountCallback: 生成 hook 节点,并存储回调函数 callback 和依赖项 deps;
  • updateCallback: 新的依赖项与之前存储的依赖项进行对比,若没有变化,则直接返回,否则存储新的回调函数和依赖项;

2.1.1 mountCallback #

初始化时很简单,就是把传入的 callback 和依赖项 deps 存储起来。

/**
 * useCallback的创建
 * @param callback
 * @param deps
 * @returns {T}
 */
function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook(); // 创建一个新的hook节点
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps]; // 直接将callback和依赖项进行存储
  return callback;
}

可以看到,这里用数组的方式,把 callback 和依赖项存储到了 hook 节点的 memoizedState 属性上,然后返回这个 callback。因此我们执行 useCallback()的返回值就是这个传入 callback。

2.1.2 updateCallback #

updateCallback 的实现相对来说,也比较简单,关键点就在于依赖项的对比。

/**
 * useCallback的更新
 * @param callback
 * @param deps
 * @returns {T|*}
 */
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState; // 取出上次存储的数据: [callback, prevDeps]

  // 若之前的数据不为空
  if (prevState !== null) {
    if (nextDeps !== null) {
      /**
       * 若依赖项不为空,且前后两个依赖项没有发生变化时,
       * 则直接返回之前的callback(prevState[0]);
       * 有个 areHookInputsEqual() 我们先不关心细节,只需要知道是用来对比依赖项的
       */
      const prevDeps: Array<mixed> | null = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 若依赖项没有变化,则返回之前存储的callback
        return prevState[0];
      }
    }
  }

  /**
   * 若依赖项为空,或者依赖项发生了变动,则重新存储callback和依赖项
   * 然后返回最新的callback
   */
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

若前后两个依赖项都不为空,且依赖项没有发生变动,则直接返回之前存储的 callback,达到了缓存的目的。

若依赖项为空,或者依赖项发生了变化,则重新存储 callback 和依赖项,然后返回最新的 callback。因此,若不设置依赖项,或者依赖项一直在变,则无法达到缓存的目的。

这里有个工具函数 areHookInputsEqual(),该函数的作用,就是用来对比前后两个依赖项中所有的数据是否发生了变化,只要有一项的数据发生了变化(相同位置前后的两个数据不相等),则认为依赖项产生了变动。

2.2 useMemo 的源码 #

useMemo()的实现,与 useCallback 很相似,只不过在 useMemo()中,执行了 callback,然后缓存的是其返回的结果。

useMemo()在 React 内部实现时,分成了 mountMemo()和 updateMemo()。

  • mountMemo: 生成 hook 节点,并存储回调函数 callback 执行的结果和依赖项 deps;
  • updateMemo: 新的依赖项与之前存储的依赖项进行对比,若没有变化,则直接返回,否则存储新的回调函数的执行结果和依赖项;

2.2.1 mountMemo #

初始节点源码的实现:

/**
 * useMemo的创建
 * @param nextCreate
 * @param deps 依赖项
 * @returns {T}
 */
function mountMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook(); // 在链表的末尾创建一个hook节点
  const nextDeps = deps === undefined ? null : deps;

  /**
   * 计算useMemo里callback的返回值
   * 这是与 useCallback() 不同的地方,这里会执行回调函数callback
   */
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps]; // 将返回值和依赖项进行存储
  return nextValue; // 返回执行callback()的返回值
}

我们从源码中可以看到,在 mountMemo()里,会执行回调函数 callback(),然后存储该函数的返回结果。

2.2.2 updateMemo #

在了解 updateCallback()的源码后,updaeMemo()的源码也很好理解。

/**
 * useMemo的更新
 * @param nextCreate
 * @param deps
 * @returns {T|*}
 */
function updateMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    // Assume these are defined. If they're not, areHookInputsEqual will warn.
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 若依赖项没有变化,则返回之前存储的结果
        return prevState[0];
      }
    }
  }
  // 重新计算callback的返回结果,并进行存储
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

当依赖项不为空,且没有变化时,直接返回之前存储的数据;否则执行最新的回调函数,然后存储该函数最新的返回结果,并返回。

3. 总结 #

这是 React 源码内部实现起来比较简单的 hooks,我们先做个开胃菜,后续比如 useState(), useEffect() 等 hooks,整体的逻辑会更加复杂一些。

参考链接:

标签:reacthooks
阅读(2566)
Simple Empty
No data