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 算法会为我们最后把关。

相关推荐
Cosolar2 小时前
银河麒麟 / aarch64 系统:Docker + Docker Compose 完整安装教程
后端·程序员·架构
人邮异步社区5 小时前
想要系统地学习扩散模型,应该怎么去做?
人工智能·学习·程序员·扩散模型
SelectDB10 小时前
Apache Doris 在小米统一 OLAP 和湖仓一体的实践
运维·数据库·程序员
文心快码BaiduComate10 小时前
Agent如何重塑跨角色协作的AI提效新范式
前端·后端·程序员
大模型教程12 小时前
爆肝6周,手把手教你搭建一套生产级RAG论文研究助手
程序员·llm·agent
大模型教程12 小时前
技术干货丨AI 大模型微调到底是什么?一篇通俗文帮你弄明白
程序员·llm·agent
陈随易12 小时前
MoonBit语法基础概述
前端·后端·程序员
AI大模型14 小时前
别再瞎学大模型了,这份GitHub神级课程火爆全网
程序员·llm·agent
程序员鱼皮14 小时前
MySQL 从入门到删库跑路,保姆级教程!
java·计算机·程序员·编程·编程经验