React Hooks 源码解读之 useMemo

1,106 阅读7分钟

「这是我参与11月更文挑战的第3天,活动详情查看:2021最后一次更文挑战

react 版本:v17.0.3

1、Hook 入口

React Hooks 源码解读之Hook入口 一文中,我们介绍了 Hooks 的入口及hook处理函数的挂载,从 hook 处理函数的挂载关系我们可以得到这样的等式:

  • 挂载阶段:

    useMemo = ReactCurrentDispatcher.current.useMemo = HooksDispatcherOnMount.useMemo = mountMemo;

  • 更新阶段:

    useMemo = ReactCurrentDispatcher.current.useMemo = HooksDispatcherOnUpdate.useMemo = updateMemo;

因此,组件在挂载阶段,执行 useMemo,其实执行的是 mountMemo,而更新阶段,则执行的是updateMemo 。

2、挂载阶段

组件在挂载阶段,执行 useMemo,实际上执行的是 mountMemo,下面我们来看看 mountMemo 的实现:

2.1 mountMemo

// packages/react-reconciler/src/ReactFiberHooks.new.js

function mountMemo<T>(
  nextCreate: () => T,  // useMemo 的第一个参数:callback
  deps: Array<mixed> | void | null, // useMemo 的第二个参数:依赖项数组
): T {
  // 创建 hook 对象,将 hook 对象添加到 workInProgressHook 单向链表中,返回最新的 hook 链表
  const hook = mountWorkInProgressHook();
  // 根据 useMemo 的第二个参数初始化 依赖项数组
  const nextDeps = deps === undefined ? null : deps;
  // 执行 useMemo 的第一个参数 callback,计算需要缓存的值
  const nextValue = nextCreate();
  // 将计算后的值和相应的依赖项保存到 hook 对象的 memoizedState 属性上
  hook.memoizedState = [nextValue, nextDeps];
  // 返回缓存后的值
  return nextValue;
}

组件在挂载时,执行 useMemo,首先会创建一个 hook 对象,该 hook 对象将会被添加到 workInProgressHook 单向链表中:

const hook = mountWorkInProgressHook();

接着根据 useMemo 的第二个参数 deps 来初始化需要保存起来的 依赖项数组:

const nextDeps = deps === undefined ? null : deps;

然后执行 useMemo 的第一个参数 callback,计算需要缓存起来的值:

 const nextValue = nextCreate();

最后将计算的值和相应的依赖项保存到 hook 对象的 memoizedState 属性上,并返回缓存后的值:

// 将计算后的值和相应的依赖项保存到 hook 对象的 memoizedState 属性上
hook.memoizedState = [nextValue, nextDeps];
// 返回缓存后的值
return nextValue;

由此可知道,useMemo 的 memoizedState 存储的并不是一个具体的值,而是一个包含有缓存值和依赖项的数组。

2.2 mountWorkInProgressHook

在 mountMemo() 函数中,使用 mountWorkInProgressHook() 函数创建了一个新的 hook 对象,我们来看看它是如何被创建的:

// packages/react-reconciler/src/ReactFiberHooks.new.js

// 创建一个新的 hook 对象,并返回当前的 workInProgressHook 对象
// workInProgressHook 对象是全局对象,在 mountWorkInProgressHook 中首次初始化
function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };
  
   // Hooks are stored as a linked list on the fiber's memoizedState field
  // 将 新建的 hook 对象以链表的形式存储在当前的 fiber 节点memoizedState属性上

  // 只有在第一次打开页面的时候,workInProgressHook 为空
  if (workInProgressHook === null) {
    // This is the first hook in the list
    // 链表上的第一个 hook
    
    // currentlyRenderingFiber: The work-in-progress fiber. I've named it differently to distinguish it fromthe work-in-progress hook.
    
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    // 已经存在 workInProgressHook 对象,则将新创建的这个 Hook 接在 workInProgressHook 的尾部,形成链表
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

可以看到,在新建一个 hook 对象时,如果全局的 workInProgressHook 对象不存在 (值为 null),即组件在首次渲染时,将新建的 hook 对象赋值给 workInProgressHook 对象,也同时将 hook 对象赋值给 currentlyRenderingFiber 的 memoizedState 属性,如果 workInProgressHook 不为 null,则将 hook 对象接在 workInProgressHook 的尾部,从而形成一个单向链表。

3、更新阶段

组件在更新阶段,执行 useMemo,实际上执行的是 updateMemo,下面我们来看看 updateMemo 的实现。

3.1 updateMemo

// packages/react-reconciler/src/ReactFiberHooks.new.js

function updateMemo<T>(
  nextCreate: () => T, // useMemo 的第一个参数:callback
  deps: Array<mixed> | void | null, // useMemo 的第二个参数:依赖项数组
): T {
  // 找到该useMemo对应的hook 对象
  const hook = updateWorkInProgressHook();
  // 根据 useMemo 的第二个参数计算新的 依赖
  const nextDeps = deps === undefined ? null : deps;
  // 之前的 缓存值和依赖项 [nextValue, nextDeps]
  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)) {
        // 前后的依赖相同,说明不需要重新执行 nextCreate 计算新值,而是直接返回上一次计算的值
        return prevState[0];
      }
    }
  }

  // 前后的依赖数组不同或者没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值

  // 执行 useMemo 的第一个参数 callback,计算新的值
  const nextValue = nextCreate();
  // 将计算后的新值和相应的依赖项保存到 hook 对象的 memoizedState 属性上
  hook.memoizedState = [nextValue, nextDeps];
  // // 返回计算后的新值
  return nextValue;
}

在更新阶段,执行 useMemo,首先会调用 updateWorkInProgressHook() ,获取当前正在执行 update 任务的fiber 节点上的hook 对象:

const hook = updateWorkInProgressHook();

接着获取新的依赖,方便与旧的依赖做比较,从而决定是否需要重新执行 useMemo 的第一个参数,计算新的值:

const nextDeps = deps === undefined ? null : deps;

然后从当前的 hook 对象中获取之前的依赖项数组,调用 areHookInputsEqual() 来比较前后的依赖项数组是否相同:

// 之前的 缓存值和依赖项 [nextValue, nextDeps]
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)) {
      // 前后的依赖相同,说明不需要重新执行 nextCreate 计算新值,而是直接返回上一次计算的值
      return prevState[0];
    }
  }
}

如果 areHookInputsEqual() 返回的结果为 true,说明前后的 依赖项数组 是一样的,说明不需要重新执行 nextCreate 计算新值,而是直接返回上一次计算的值。

如果 areHookInputsEqual() 返回的结果为 false,则会执行下面的语句:

// 前后的依赖数组不同或者没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值

  // 执行 useMemo 的第一个参数 callback,计算新的值
  const nextValue = nextCreate();
  // 将计算后的新值和相应的依赖项保存到 hook 对象的 memoizedState 属性上
  hook.memoizedState = [nextValue, nextDeps];
  // // 返回计算后的新值
  return nextValue;

只要是前后的依赖项数组不同或者没有提供依赖项数组,useMemo 在每次渲染时都会执行上面的代码计算新值。因此,我们在使用 useMemo 的时候,应该传入依赖项数组,有助于避免每次渲染时都进行高开销的计算。

3.2 updateWorkInProgressHook

在 updateMemo() 函数中,通过 updateWorkInProgressHook() 函数获取到了当前正在工作中的 Hook,即 workInProgressHook,我们来看看 updateWorkInProgressHook 的实现:

// packages/react-reconciler/src/ReactFiberHooks.new.js

function updateWorkInProgressHook(): Hook {
  // This function is used both for updates and for re-renders triggered by a
  // render phase update. It assumes there is either a current hook we can
  // clone, or a work-in-progress hook from a previous render pass that we can
  // use as a base. When we reach the end of the base list, we must switch to
  // the dispatcher used for mounts.

  // 获取 当前 hook 的下一个 hook
  let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next;
  }

  // 取下一个 hook 为当前的hook
  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // There's already a work-in-progress. Reuse it.
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;

    currentHook = nextCurrentHook;
  } else {
    // Clone from the current hook.

    // 拷贝当前的 hook,作为当前正在工作中的 workInProgressHook

    invariant(
      nextCurrentHook !== null,
      'Rendered more hooks than during the previous render.',
    );
    currentHook = nextCurrentHook;

    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,

      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,

      next: null,
    };

    if (workInProgressHook === null) {
      // This is the first hook in the list.
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      // Append to the end of the list.
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }
  return workInProgressHook;
}

这里分两种情况:

  • 如果是在 render 阶段,则会取下一个 hook 作为当前的hook,并返回 workInProgressHook;
  • 如果是在 re-render 阶段,则在当前处理周期中,继续取当前的 workInProgressHook 做更新处理,最后再返回 workInProgressHook。

前后「依赖项数组」是否一样,是通过 areHookInputsEqual() 来判断的,我们来看看它的实现。

3.3 areHookInputsEqual

// packages/react-reconciler/src/ReactFiberHooks.new.js

function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
) {
    
  // 删除了 dev 代码
    
  if (prevDeps === null) {
    
    // 删除了 dev 代码
    
    return false;
  }

  // 删除了 dev 代码
    
  // deps 是一个 Array,循环遍历去比较 array 中的每个 item
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    // is比较函数是浅比较
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

在 areHookInputsEqual() 函数中,循环遍历 deps ,调用 is 方法去比较依赖项数组中的每个依赖,值得注意的是,is 方法是浅比较,也就是说如果是深比较那一定会更新的。下面是 is 方法的源码:

// packages/shared/objectIs.js

function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
  );
}

const objectIs: (x: any, y: any) => boolean =
  typeof Object.is === 'function' ? Object.is : is;

export default objectIs;

Object.is() 方法判断两个值是否为同一个值,若当前浏览器支持该方法,则调用该方法来判断前后两个依赖项是否相同,若不支持,则调用React自己实现 is 方法来比较。

4、useMemo 流程图

5、总结

我们知道 useMemo 将会返回计算后的 memoized 值,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。虽然 useMemo 可以对数据进行缓存,但是也不能因此而滥用,我们应该考虑哪些数据值得去缓存,因为对于 useMemo 来说,除了计算值耗时以外,对比依赖项的比较也是需要时间的,我们应该对此进行衡量,才能够更好的去用 hook,而不是为了优化而优化。