React memo的原理、实践与思考

京东云开发者
• 阅读 264

前言

在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



React 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年前
React之集成测试 – 测试概览
你可以用像测试其他JavaScript代码类似的方式测试React组件。现在有许多种测试React组件的方法。大体上可以被分为两类:渲染组件树在一个简化的测试环境中渲染组件树并对它们的输出做断言检查。运行完整应用在一个真实的浏览器环境中运行整个应用(也被称为“端到端(endtoend)”测试)。本章节主要专
Easter79 Easter79
3年前
taro 组件的外部样式和全局样式
自定义组件对应的样式文件,只对该组件内的节点生效。编写组件样式时,需要注意以下几点:1.组件和引用组件的页面不能使用id选择器(a)、属性选择器(\a\)和标签名选择器,请改用class选择器。2.组件和引用组件的页面中使用后代选择器(.a.b)在一些极端情况下会有非预期的表现,如遇,请避免使用。3.子
爱丽丝13 爱丽丝13
3年前
快速了解 React Hooks 原理
为了保证的可读性,本文采用意译而非直译。我们大部分React类组件可以保存状态,而函数组件不能?并且类组件具有生命周期,而函数组件却不能?React早期版本,类组件可以通过继承PureComponent来优化一些不必要的渲染,相对于函数组件,React官网没有提供对应的方法来缓存函数组件以减少一些不必要的渲染,直接16.6出来的Rea
亚瑟 亚瑟
3年前
自定义 Hook – React
自定义Hook_Hook_是React16.8的新增特性。它可以让你在不编写class的情况下使用state以及其他的React特性。通过自定义Hook,可以将组件逻辑提取到可重用的函数中。在我们学习时,我们已经见过这个聊天程序中的组件,该组件用于显示好友的在线状态:importReact,{useSta
Wesley13 Wesley13
3年前
uikiller使用手册(一)
一、前言uikiller是使用名命规则来控制UI节点、组件和触摸事件,减少UI相关的代码与编辑器设置,实现原理是提前对UI树的遍历。在CocosCreator中UI编程基于组件模式,我根据自己的项目经验,将组件分为两类:法宝型与结界型。法宝型组件法宝型组件:以装饰宿主节点为己任,从不控制其它节点。特
Easter79 Easter79
3年前
VUE+ElementUI布局随笔
1.elcontainer标签非必须。2.每一个vue文件中,所有的html代码都应该写在同一个dom中,否则会报错。3.elaside默认宽度为300px,可以通过在标签中修改width属性来调整。4.若routerview想通过name属性来指定渲染的组件,则在router.js中,该组件在注册时,必须是component
Stella981 Stella981
3年前
React 第一个小游戏(井字棋)知识关键点
1、React是一个声明式,高效且灵活的用于构建用户界面的JavaScript库通过使用组件来告诉React我们希望在屏幕上看到什么。当数据发生变化时,React会高效的更新并重新渲染我们的组件2、render返回了一个React元素,这是一种对渲染内容的轻量级描述。大多数的React开发者使用了一种名为"JSX"的特
Wesley13 Wesley13
3年前
DOM元素的自动隐藏
在一些有悬浮元素的场景中,比如点击一个按钮弹出菜单后,点击菜单以外的地方,菜单应该被隐藏起来。隐藏的方式最好是自动隐藏,或至少是组件内的自动隐藏。蒙层比如,一个模态框组件(闭包实现)点击蒙层时,响应蒙层的点击事件,可以在事件处理函数中隐藏整个组件。在Vue和React等框架的组件中,这一点非常容易实现。<divclass"com