理解这个机制,是成为React性能优化高手的关键

2024年 1月 16日 41.5k 0

本来是准备优先分享两个官方定义的 Hook useMemo,useCallback,不过这两个 hook 本身其实没有太多探讨的空间,他们只是两个记忆函数,本身并没有特殊的、更进一步的含义。

许多人的困惑往往来源于对于它们两个过度解读,认为他们的存在对 React 性能的优化有非常重要的意义。过渡解读导致了对他们的滥用。在我看过的项目中,有个别优秀前端团队里的项目规范里,也错误抬高了他们的作用,把他们用在了每一个组件里。

出现这样问题的根源就在于对 React 的自身机制理解不够精准。因此我决定换一个角度去带大家理解 React 本身的优化机制,从而能够正确的使用 useMemo 与 useCallback。

本文将会从应用层面来为大家分析我们应该怎么做。后续的章节将会从 Fiber 的双缓存策略开始分享底层的优化机制。

一、精简节点

首先我们要明确一些前置知识。

React 在内存中维护了一颗 虚拟 DOM 树,这颗树的每一个节点是一个 Fiber,每一个 Fiber 都由 JSX 中的组件解析而来。

type Fiber = {
  // 用于标记fiber的WorkTag类型,主要表示当前fiber代表的组件类型如FunctionComponent、ClassComponent等
  tag: WorkTag,
  // ReactElement里面的key
  key: null | string,
  // ReactElement.type,调用`createElement`的第一个参数
  elementType: any,
  // The resolved function/class/ associated with this fiber.
  // 表示当前代表的节点类型
  type: any,
  // 表示当前FiberNode对应的element组件实例
  stateNode: any,

  // 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
  return: Fiber | null,
  // 指向自己的第一个子节点
  child: Fiber | null,
  // 指向自己的兄弟结构,兄弟节点的return指向同一个父节点
  sibling: Fiber | null,
  index: number,

  ref: null | (((handle: mixed) => void) & { _stringRef: ?string }) | RefObject,

  // 当前处理过程中的组件props对象
  pendingProps: any,
  // 上一次渲染完成之后的props
  memoizedProps: any,

  // 该Fiber对应的组件产生的Update会存放在这个队列里面
  updateQueue: UpdateQueue | null,

  // 上一次渲染的时候的state
  memoizedState: any,

  // 一个列表,存放这个Fiber依赖的context
  firstContextDependency: ContextDependency | null,

  mode: TypeOfMode,

  // Effect
  // 用来记录Side Effect
  effectTag: SideEffectTag,

  // 单链表用来快速查找下一个side effect
  nextEffect: Fiber | null,

  // 子树中第一个side effect
  firstEffect: Fiber | null,
  // 子树中最后一个side effect
  lastEffect: Fiber | null,

  // 代表任务在未来的哪个时间点应该被完成,之后版本改名为 lanes
  expirationTime: ExpirationTime,

  // 快速确定子树中是否有不在等待的变化
  childExpirationTime: ExpirationTime,

  // fiber的版本池,即记录fiber更新过程,便于恢复
  alternate: Fiber | null,
}

state 的每次变化,都会引发整棵树的前后对比,从而导致许多组件重新执行。这也是 React 性能消耗的主要成本。但是 React 内部采用缓存机制和优秀的 Diff 算法极大的减少了这里的成本,后续我们会详细介绍这两个机制。

这里我要重点介绍的是,在使用中,我们可以通过减小这颗 Fiber tree 的方式来达到性能优化的目的。只要 Fiber tree 足够小,diff 的成本就会非常的低。

例如,我们有一个非常大的巨石项目,当我们路由切换的时候,会直接删掉前一个页面的所有内容,只渲染新页面的内容,那么,虽然随着访问页面的数量越来越多,缓存在全局状态管理器中的数据越来越复杂,但是 Fiber tree 的大小其实并没有变得越来越大,依然维持在一个页面的量级,此时的 diff 压力跟一个小型项目没有什么区别。通过这种手段,我们可以轻松保持一个巨石项目的高性能。

落实到具体的页面上,特别是在一些管理系统里,许多开发者喜欢在在列表页中,维护一个内容超级复杂的弹窗组件,弹窗的内容是列表的详情。此时,弹窗内容和列表内容同时存在,从而导致了 Fiber tree 的庞大。

从交互上,我们可以将复杂的弹窗内容移植到一个新的详情页,就能极大的缓解 diff 压力。

在某些项目中,一个详情页有几百条表单需要填写。我们可以通过分步骤的方式,把这几百个表单项切分到不同的步骤里,从而让同时渲染出来的表单项大量减少,性能也会有很大的提高。

总的来说,只要我们把 Fiber 节点数量控制在一定范围内,React 都能保持一个非常高的性能。因此大多数情况下,我们并不需要做额外的性能优化。

二、比较方式

由于大量的 re-render 存在,我们很容易能想到一个优化策略,在 diff 过程中,当我比较之后发现有的节点并没有发生任何变化,那么我们就可以跳过该组件的 re-render,从而提高性能。

而要让这个优化想法落地,我们就必须了解内部的比较规则,首先要考虑的第一个问题就是。

如何知道一个组件是否发生了变化

一个 React 组件是否发生了变化由三个因素决定。

  • props
  • state
  • context

这三个因素中的任何一个发生了变化,组件都会认为自己应该发生变化。state 和 context 都是不可变数据,而且由于是我们主动调用 dispatch 去触发他们发生改变,因此 state 和 context 的变化一般不会对我们造成理解上的困扰。

最麻烦的是 props。

React 组件的每次执行,都会传入新的 props 对象,虽然内容可能一样,但是在内存中却已经发生了变化。

function Child(props) {}

// 执行一次传入新的对象
Child({})

// 执行一次传入新的对象
Child({})

与 state 不一样的是,props 并没有缓存在别的地方,因此,一个组件 的 props 哪怕什么都没有变化,比较的结果也是 false。

var preProps = {}
var curProps = {}

preProps === curProps // false
var preProps = { name: 'Jake' }
var curProps = { name: 'Jake' }

preProps === curProps // false

呵!没想到吧。

也就是说,当一个子组件接收一个函数作为 props,为了保证函数的引用不发生变化,有的人选择使用 useCallback 来缓存函数引用,从而期望子组件不会因为 props 发生了变化而导致子组件重新渲染。

function Demo() {
  ...

  const change = useCallback(() => {}, [])

  return (
    
      ...
      
    
  )
}

结合我们刚才说的,这里只使用 useCallback 是做了无用功。

preProps = { change: change }
curProps = { change: change }

preProps === curProps // false

那么问题就来了,如果这样子的话,岂不是每个组件的 props 都会发生变化了?

当然不是,React 内部针对 props 有另外一个策略:

如果父组件被判定为没有变化,那么,在判断子组件是否发生变化时,不会比较子组件的 props。

源码里少一个判断,却衍生出这样一个精妙的设计。

高级!

除此之外,Fiber Tree 的根节点,被判定为始终不会发生变化。

这样,根节点的子组件在比较时,react 就一定会跳过 props 的比较,以此类推。我们就有机会构造一个高性能的更新过程。

回到我们经典的数字递增案例,来分析这个案例。

function Child() {
  console.log('我不想重新渲染')
  
  return (
    我不想重新渲染
  )
}

export default function Demo02() {
  const [count, setCount] = useState(0)

  return (
    
       setCount(count + 1)}>{count}
      
    
  )
}

当我们点击数字的时候,数字递增,父组件 Demo02 被判定为改变,因此,内部的所有子组件都需要比较 props,props 为不可变数据,子组件 Child 的 props 进行了如下比较,结果为 false 。

{} === {} // false

因此,Child 虽然不想 re-render,但是每次 count 变化都 render 了。

调整的方式非常简单,只需要让父组件的 state 没有发生变化即可,把变化的部分单独封装在另外一个子组件里。

function Change() {
  const [count, setCount] = useState(0)

  return (
     setCount(count + 1)}>{count}
  )
}

export default function Demo02() {
  return (
    
      
      
    
  )
}

这个时候,父组件被判定为没有发生变化,因此子组件就会跳过 props 的比较,从而 Child 判定为没有发生变化。这样我们的目的就达到了。

但是,这里有一个前期条件,那就是我们需要确保 Demo02 的父组件也被判定为没有发生变化,因此,如果你是 React 架构师,顶层结构的设计是你需要关注的重中之重,因为如果顶层出了问题,导致父组件不满足这样的稳定结构,那么后续的子组件都会 re-render。

那么理解这个规则很难吗?其实不难,难就难在,在看这篇文章之前,可能你压根就不知道这个设计啊。

如果我们有一个不靠谱的 React 架构师,顶层组件的稳定结构出了问题,那么我们有什么手段,能够低成本的让你能接触到的页面结构保持稳定呢?

答案就是 React.memo。

memo 函数会让组件的 props 比较方式发生变化,我们之前都是一直用的 === 全等比较,使用 memo 包裹组件之后,React 内部会改变比较策略,他会遍历 props 的每个属性,如果每个属性都能通过全等比较,那么就判定为 props 没有发生变化。

这个遍历过程只会发生在 props 对象的第一层属性,不会更进一步深入。

因此,当我们无法确定上层组件是否发生变化时,我们可以在某一个节点使用 memo 来确保从这一层开始建立稳定的高性能模式。

function _Child() {
  console.log('我不想重新渲染')
  
  return (
    我不想重新渲染
  )
}

var Child = memo(_Child)

export default function Demo02() {
  const [count, setCount] = useState(0)
  return (
    
       setCount(count + 1)}>{count}
      
    
  )
}

当我们使用 memo 包裹子组件导致 props 的比较方式发生变化时,useCallback 缓存引用就有用了。这也是 useCallback 的主要作用,他一定要结合 memo 去使用。

当然,我们也可以用一些骚操作来达到同样的目标,利用 useMemo 来缓存组件。

export default function Demo02() {
  const [count, setCount] = useState(0)

  const _child = useMemo(() => {
    return 
  }, [])

  return (
    
       setCount(count + 1)}>{count}
      {_child}
    
  )
}

当你决定要自己设计比较规则时就可以采用这样的方式。

三、总结

这篇文章分享了两个 React 项目性能优化的最重要的手段。我们只要了解了真实的底层机制,就能写出高性能的代码,他们的理解难度并不高。我们只需要在项目中正确的去编写符合他们机制的代码即可。

如果你是 React 项目架构师,那么你一定要吃透这个机制,在顶层架构中,我们会额外添加 Router/Redux 等诸多顶层组件,他们会不会导致高性能结构的崩塌,你一定要非常明确。

除此之外,当顶层的父组件不变判定被破坏,我们也不需要每一个组件都用 memo 包裹起来,只需要在合适的节点包裹一个组件即可。因为 memo 的比较本身也会增加程序的执行成本,大量的 memo 反而会导致性能变得更低。

除此之外,我们要明确,组件的 re-render 是内存行为,他是执行了一次 JS 函数,他并不会导致浏览器真的发生渲染行为,因此 re-render 的执行也是非常快速的,大多数情况下的 re-render 都可以接受,不过超大量的 re-render 会导致执行压力变大,所以用大量 memo 减少 re-render 并不一定是一件划算的事情。

利用少量的 memo 与 React 本身的缓存机制减少大量的 re-render 才是合理的方案。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论