React memo的原理、实践与思考

前言

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

什么是 memo

memo 原理

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

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

csharp 复制代码
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 原理

ini 复制代码
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 应用

让我们从一个示例出发:

typescript 复制代码
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 应用

typescript 复制代码
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 进行精细化的监听操作。如下示例:

typescript 复制代码
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 算法会为我们最后把关。

相关推荐
我是陈泽18 小时前
一行 Python 代码能实现什么丧心病狂的功能?圣诞树源代码
开发语言·python·程序员·编程·python教程·python学习·python教学
肖哥弹架构2 天前
Spring 全家桶使用教程
java·后端·程序员
IT杨秀才5 天前
自己动手写了一个协程池
后端·程序员·go
程序员麻辣烫7 天前
像AI一样思考
程序员
一颗苹果OMG8 天前
关于进游戏公司实习的第一周
前端·程序员
万少8 天前
你会了吗 HarmonyOS Next 项目级别的注释规范
前端·程序员·harmonyos
楽码9 天前
彻底理解时间?在编程中使用原子钟
后端·算法·程序员
江南一点雨9 天前
又一家培训机构即将倒闭!打工人讨薪无果,想报名的小伙伴擦亮眼睛~
java·程序员
用户861782773651810 天前
ELK 搭建 & 日志集成
java·后端·程序员
河北小田10 天前
局部变量成员变量、引用类型、this、static
java·后端·程序员