React memo的原理、实践与思考

前言

在react中,对一个组件进行点击事件等操作时,该组件以及该组件的子组件都会重新渲染。避免组件的重新渲染一般可以借助 React.memo、useCallback 等来实现。

什么是 memo

memo 原理

memo 类似于 class 中 pureComponent 的特性,用于在函数式组件的父组件中对子组件进行缓存,避免在父组件重新渲染时重新渲染子组件,只有在属性发生变化时重新渲染组件。

在 React v18.2.0 源码中,主要通过 packages/react-reconciler/src/ReactFiberBeginWork.new.js 的updateMemoComponent 方法实现 memo 的特性。

function updateMemoComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  updateLanes: Lanes,
  renderLanes: Lanes,
): null | Fiber {
  if (current !== null) {
    // ...
    
    const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
      current,
      renderLanes,
    );
    if (!hasScheduledUpdateOrContext) {
      // This will be the props with resolved defaultProps,
      // unlike current.memoizedProps which will be the unresolved ones.
      const prevProps = currentChild.memoizedProps;
      // Default to shallow comparison
      let compare = Component.compare;
      compare = compare !== null ? compare : shallowEqual;
      if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
        return bailoutOnAlreadyFinishedWork(
          current, 
          workInProgress, 
          renderLanes
        );
      }
    }
  }

  // ...
}

updateMemoComponent 首先会检查是否有已调度的更新或上下文更改。在存在更新时,他会去获取 memo 的 compare 方法,未自定义则取默认的比较方法 shallowEqual。去比较依赖数组中新老属性变化,确认不需要重新渲染时,会调用 bailoutOnAlreadyFinishedWork 方法来阻止组件的重新渲染。

在 memo 的应用中,一般需要结合 useMemo、useCallback 来配合处理依赖数组和子组件的传入属性,下面将介绍这两者的原理。

useMemo、useCallback 原理

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

function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  // 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];
    }
  }
  if (shouldDoubleInvokeUserFnsInHooksDEV) {
    nextCreate();
  }
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

在 React v18.2.0 源码中,主要通过 packages/react-reconciler/src/ReactFiberHooks.js 的 updateCallback 和 updateMemo 方法实现 useCallback 与 useMemo 的特性。观察源码可以发现,useMemo 与 useCallback 的实现原理基本一致,均会通过 areHookInputsEqual 方法对依赖数组的各项进行比较。不同在于返回值上,useCallback 返回回调函数,useMemo 返回数据对象。useMemo 对属性进行缓存,通过对依赖数组进行监听,更新缓存的属性。

为什么使用 memo

memo 原理流程图

在这个流程中,在当前组件对应 fiber 节点的 tag 为 MemoComponent 且 没有其他更高优先级任务时,react.memo 会在新的 props 到达时,比较新旧 props 是否相等。如果相等,则不会重新渲染组件,直接返回之前的渲染结果。如果不相等,则重新执行渲染逻辑,更新组件的 DOM。这样可以避免不必要的组件重新渲染,提高性能。

memo 使用场景

memo 应用

让我们从一个示例出发:

export default const ChildCom = (props: any) => {
  const { propParam1, propParam2, func1 } = props
  console.log('child component update');
  return (
    <div onClick={func1}>
      child component
      {propParam1}
      {propParam2.a}
    </div>
  )
}

import ChildCom from './ChildCom'
const MemoChildCom = React.memo(ChildCom)
const App = () => {
  const [parentParam1, setParentParam1] = useState<number>(0)
  const [childParam1, setChildParam1] = useState<number>({ a: 2 })
  const childFunc1 = () => { console.log("parent param1 val: ", parentParam1) }
  return (
    <div className="App">
      <button onClick={() => setParentParam1(parentParam1+1)}>
          observe child component update
      </button>
      <MemoChildCom propParam1={1} propParam2={childParam1} func1={childFunc1}/>
    </div>
  )
}

代码块 memo1 中,React.memo 有两个参数,第一个为需要缓存的组件,第二个是缓存规则的方法(非必传)。对组件进行缓存后,react 会根据缓存规则的实现方法,判断是否对子组件执行重新渲染。当第二参数未传时,react 会浅比较子组件的 props 对象里各属性的变化,若全部未更新,react 在 render 中,不会给此 fiber 打上更新的 tag,不会执行重新渲染。

注意的是,组件 ChildCom 中 props 属性 propParam2、func1 均为引用类型。在 button 被点击时,触发 setSate,App 组件会重新渲染,因此 childParam1 和 childFunc1 的引用地址会更新,在 react 进行浅比较会检测到更新, 使得 childCom 发生重新渲染。这种场景下,需要借助 useMemo 和 useCallBack 的特性。

useMemo、useCallback应用

useCallback 应用

export default const ChildCom = (props: any) => {
  const { propParam1, propParam2, func1 } = props
  console.log('child component update');
  return (
    <div onClick={func1}>
      child component
      {propParam1}
      {propParam2.a}
    </div>
  )
}

import ChildCom from './ChildCom'
const App = () => {
  const [parentParam1, setParentParam1] = useState<number>(0)
  const [childParam1, setChildParam1] = useState<number>({ a: 2 })
  const childFunc1 = () => { console.log("parent param1 val: ", parentParam1) }

  const callbackFunc1 = useCallback(childFunc1, [parentParam1])
  const MemoChildCom = React.memo(ChildCom, (pre, cur) => {
     if(Object.is(pre, cur) && pre.propParam2.a !== cur.propParam2.a) {
       return true
     }
     else return false
  })
  return (
    <div className="App">
      <button onClick={() => setParentParam1(parentParam1+1)}>
          observe child component update
      </button>
      <MemoChildCom propParam1={1} propParam2={childParam1} func1={callbackFunc1}/>
    </div>
  )
}

观察代码块 memo1 中可发现,两个引用类型的变量,其依赖于 parentParam1 和 childParam1 的属性 a。首先借助 useCallback 对方法进行缓存,并在 childCom 组件的调用上,参数 func1 传入 callbackFunc1。另外,在 React.memo 的比较策略的方法实现中,需要额外比较引用类型的变更。

useMemo 应用

useMemo 对于属性的缓存特性,其更多用于对复杂对象的处理并用于部分 hooks 的依赖数组上。例如 useEffect 的依赖数组中,直接监听大对象的行为是绝对要避免的,监听的对象中未参与的属性在 useState 发生变化后会引起 useEffect 不必要的执行,甚至发生死循环。这种场景一般可以借助 useMemo 进行精细化的监听操作。如下示例:

type TAddressDTO = {
  id?: string
  provinceId: number
  provinceName: string
  cityId: number
  cityName: string
  countyId: number
  countyName: string
  townId?: number
  townName?: string
  address: string
  fullAddress?: string
  type: number
  defaultFlag?: number
  name: string
  mobile?: string
  mobileITC?: string
  encryptMobile?: string
  phone?: string
  extNumber?: string
  encryptPhone?: string
  company?: string
  shortName?: string
}
const memoSendAddress = useMemo(() => {
  const {
    provinceId = '',
    cityId = '',
    countyId = '',
    townId = '',
    address = '',
  } = storeSendAddress || {}
  return `${provinceId}${cityId}${countyId}${townId}${address}`
}, [storeSendAddress])
useEffect(() => {
  // useEffect logic
}, [memoSendAddress])

代码块 useMemo1 中,useEffect 针对地址对象的监听,直接监听 storeSendAddress 的话,地址大对象中任意属性的变更均未造成 useEffect logic 的执行。但实际地址对象 TAddressDTO 需要监听的属性只有各级地址。通常 useEffect logic 中会包含接口调用、数据存取等异步任务,通过 useMemo 进行筛选是很有必要的。

需要注意的是,useMemo 第一个参数的返回值,需要为基础数据类型,否则 memoChildParam1 在 App 组件重新渲染后依然会返回新的引用地址造成 MemoChildCom 的重新渲染。

memo 应用中的坑

实际应用中,memo 的使用并不频繁,个人有如下的几点看法。

1.在代码的可维护性上,过多的使用 React.memo 对组件进行缓存并同时自定义比较方法,无形中增加了代码的复杂度,对于之后的维护上,对于他人甚至于自己,都会带来额外理解的时间;

2.对于 react 性能的疑虑上,diff 算法会作为最后的关卡,去优化真实 DOM 的渲染过程;

3.父组件重新渲染触发子组件的 render,可以避免相当一部分的 bug。笔者实际开发遇到的场景中,在例如列表渲染的诸多场景,列表的每一项往子组件传入的对象往往比较大,当对象中深层的某个属性发生改变,但由于其他 state 的变化触发父组件的重新渲染,这种触发子组件重新渲染的场景是普遍存在的。

小结

本文主要讲述了 React 中组件缓存中 memo的原理和一般应用,对于组件重新渲染的必要与否,更需要我们根据实际场景,测量分析缓存的消耗与节约的成本及潜在风险之间的关系,避免负优化的发生。毕竟,react 的 diff 算法会为我们最后把关。

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值