介绍

在实际项目中,useCallback、useMemo这两个Hooks想必会很常见,可能我们会处于性能考虑避免组件重复刷新而使用类似useCallback、useMemo来进行缓存。接下来我们会从源码和使用的角度来聊聊这两个hooks。【源码地址

为什么要有这两个Hooks

在开始介绍之前我们先来了解下为什么有这两个hooks,其解决了什么问题?借用官网案例:

function ProductPage({ productId, referrer, theme }) {
  // 每当 theme 改变时,都会生成一个不同的函数
  function handleSubmit(orderDetails) {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }
  
  return (
    <div className={theme}>
      {/* 这将导致 ShippingForm props 永远都不会是相同的,并且每次它都会重新渲染 */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

每当切换主题theme,ProductPage就会重新渲染,而即使ShippingForm使用memo包裹并且没有做任何更改也会重新渲染,这就是常说的父组件渲染导致子组件跟着渲染。
再看另一种情况:

function createOptions() {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    }, [createOptions])

在useEffect中添加了createOptions作为依赖,但是createOptions函数每次执行都返回的不同函数导致useEffect会重新执行

所以为了解决类似上面两种问题,利用缓存封装了useCallback、useMemo等hooks。

useCallback

function useCallback<T extends (...args: any[]) => any>(callback: T, deps: DependencyList): T;

让我们带着上面这两个问题来了解useCallback。用白话来说useCallback就是接收一个callback和依赖deps,只要依赖的deps没有改变,通过useCallback返回的函数就是同一个,以此来避免重复刷新。如果deps改变则useCallback会返回新的callback并将其缓存,以便下次对比。

从源码来看几乎所有的Hooks都被拆分为了mount、upadte两种(useContext除外),React内部会根据当前渲染阶段来判断调用那个来处理callback

// 首次挂载时
const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  use,
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useInsertionEffect: mountInsertionEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useSyncExternalStore: mountSyncExternalStore,
  useId: mountId,
};

// 渲染更新时
const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,

  use,
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useInsertionEffect: updateInsertionEffect,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  useDebugValue: updateDebugValue,
  useDeferredValue: updateDeferredValue,
  useTransition: updateTransition,
  useSyncExternalStore: updateSyncExternalStore,
  useId: updateId,
};

以下会以useCallback为例,从源码上一步一步了解。

调用流程

从上面流程图能看出,当我们在组件内使用useCallback的时候,React会通过dispatcher根据渲染状态来进行不同的处理。

export function useCallback<T>(
  callback: T,
  deps: Array<mixed> | void | null,
): T {
  return useCallbackImpl(callback, deps);
}

function useCallbackImpl<T>(
  callback: T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useCallback(callback, deps);
}

这里的dispatcher 是一个对象,它会在不同的渲染阶段指向不同的实现。在初次渲染时,它会指向 HooksDispatcherOnMount,在更新时,它会指向 HooksDispatcherOnUpdate。

mountCallback

当首次渲染时,会执行mountCallbac返回新的callback并将其和所依赖的deps缓存到memoizedState中

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;
}

在首次渲染时候主要做了下列事情:

  • mountWorkInProgressHook: 会创建一个hook并绑定到当前渲染的fiber中
  • 获取依赖deps,并将callback和deps缓存到当前fiber的hook中

在Function Component中,每个fiber节点都有一个自己的副作用hook list,在协调器(Reconciler)的fiber构造的beginWork阶段会将当然fiber节点的hook保存在hook list中,详情可查看这篇文章:【React架构 - Fiber构造循环

updateCallback

更新渲染时,会执行updateCallback函数,会根据依赖是否变化来判断是否使用缓存

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;
}

updateCallback主要做了下列事情:

  • 通过updateWorkInProgressHook获取当前fiber节点对应的hook,并通过hook.memoizedState获取缓存的callback和deps
  • 当依赖存在时,通过areHookInputsEqual判断deps是否变化,如果没变则返回缓存中的callback,即prevState[0],否则缓存新的callback和deps,然后返回新的callback

在areHookInputsEqual中主要是通过Object.is来判断deps是否变化

function areHookInputsEqual(nextDeps, prevDeps) {
  if (prevDeps === null) {
    return false;
  }

  // 简单的长度检查
  if (nextDeps.length !== prevDeps.length) {
    return false;
  }

  // 逐一比较每一个依赖项
  for (let i = 0; i < nextDeps.length; i++) {
    if (Object.is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

Object.is() 与 == 运算符并不等价。== 运算符在测试相等性之前,会对两个操作数进行类型转换(如果它们不是相同的类型),这可能会导致一些非预期的行为,例如 “” == false 的结果是 true,但是 Object.is() 不会对其操作数进行类型转换。
Object.is() 也不等价于 === 运算符。Object.is() 和 === 之间的唯一区别在于它们处理带符号的 0 和 NaN 值的时候。=== 运算符(和 == 运算符)将数值 -0 和 +0 视为相等,但是会将 NaN 视为彼此不相等。详细查看MDN

useMemo

useCallback、useMemo都是处于性能考虑通过缓存来避免重复执行的hook,同useCallback一样,useMemo也接收两个参数callback、deps。其区别主要是:useCallback是缓存以及返回函数,并不会调用函数,而useMemo会执行函数,缓存并换回函数的执行结果
同其他hooks一样,useMemo也分为了mount和update两个,下面一一介绍。

mountMemo

function mountMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  // 创建一个添加到Fiber节点上的Hooks链表
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  // 计算需要memo的值
  const nextValue = nextCreate();
  // hook数据对象上存的值
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

初次渲染:

  • mountWorkInProgressHook: 会创建一个hook链表并绑定到当前渲染的fiber中
  • 执行传入的callback,并将其保存到memoizedState中

updateMemo

function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  // 找到该useMemo对应的hook数据对象
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  // 之前存的[nextValue, nextDeps]
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      // 判断依赖是否相等
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 相等就返回上次的值
        return prevState[0];
      }
    }
  }
  // 不相等重新计算
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

更新渲染:

  • 通过updateWorkInProgressHook获取当渲染fiber的hook链表
  • 根据areHookInputsEqual判断传入的依赖deps是否变化,如果变化则返回新的结果并缓存,否则使用缓存

总结

总的来说useMemo和useCallback相对来说源码比较简单,大致就是在首次渲染时,调用mountHook将callback/结果缓存到当前fiber节点的hoos链表(通过mountWorkInProgressHook创建)的memoizedState属性中,然后在更新渲染中获取当前fiber节点的hook信息(通过updateWorkInProgressHook获取),通过areHookInputsEqual判断是否使用缓存。

函数调用流程如下:
在这里插入图片描述
虽然useCallback、useMemo利用缓存避免了重复渲染,有利于性能优化,但是在实际项目中并不是所有的函数都需要用其包裹,大多情况下是没有意义的。主要场景就是上面提到的子组件更新和作为其他函数的依赖时:

  • 将其作为 props 传递给包装在 [memo] 中的组件。如果 props 未更改,则希望跳过重新渲染。缓存允许组件仅在依赖项更改时重新渲染。
  • 传递的函数可能作为某些 Hook 的依赖。比如,另一个包裹在 useCallback 中的函数依赖于它,或者依赖于 useEffect 中的函数。

当然如果能接受所有函数都被其包裹导致的代码可读性问题,这样记忆化处理也不会有什么问题。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部