React

Permormance Hooks - useMemo & useCallback

在这篇文章中,我们将深入的了解React组件重新渲染的确切原因。我们还将学习到如何优化过多的重新渲染。
This entry is part 3 of the seriesenjoy-react-hooks

如果你曾经在中型或大型项目中使用过React,你一定遇到过组件过度重新渲染的问题。在某些情况下,这可能会引发性能问题,比如应用程序顶端附近的小更改可能会引发组件树自顶向下的大面积重新渲染。

在本文中我们将更深入的了解React组件重新渲染的确切原因。我们还将介绍三个React用于优化渲染的工具

  • React.memo
  • useMemo
  • useCallback

React为什么会重新渲染

在介绍useMemouseCallback之前我们要先了解React为什么会重新渲染。

在之前的案例中,我们已经了解了如何通过调用set函数(例如:setCount)来触发重新渲染。React需要捕获给定count时组件的快照。

事实上我们对于重新渲染已经有了一定的认识,本节课我们尝试去更加详细的解释这一概念。

我们先给出我们的结论

组件的每一次重新渲染都由state改变触发

有的同学就有疑问了: 难道props发生变化时组件不会重新渲染吗? 其实,在React中,当父组件重新渲染时,通常情况下其子组件也会跟着重新渲染。这是因为React遵循的是“单向数据流”的原则,即状态和属性从上层组件传递到下层组件。当父组件的状态或属性发生变化时,这些变化会通过props传递给子组件,从而触发子组件的重新渲染。

让我们通过一个案例来解释这一结论:

在这个案例中,我们有三个组件: App-->ParentComponent-->ChildComponent

当我们点击按钮时count发生变化,触发ParentComponent重新渲染,而ChildComponent又是其子组件,因此也会被触发重新渲染。

下面是一个交互式图例,用于解释上面的流程, 点击Increase按钮触发重新渲染。

假设现在在ChildComponent旁添加一个纯文本的装饰组件Decoration, 当count发生变化时,Decoration会重新渲染吗❓

通过控制台打印信息能发现,当ParentComponent组件中的count发生变化时, ChildComponentDecoration都会跟着ParentComponent一切重新渲染。

这是因为React遵循的是“单向数据流”的原则,即状态和属性从上层组件传递到下层组件。当组件重新渲染时,它会尝试重新渲染所有后代,无论这些是否通过props传递了特定的状态变量

下面同样用一个交互式图例来解释上面的结论:

父组件重新渲染会沿着组件树重新渲染所有子组件,必然会影响系统整体性能,因为并不是所有子组件都需要重新渲染,起码在上面的案例中Decoration组件就没必要重新渲染。下面我们将介绍几种方式来规避这些不必要的渲染。

React.memo

使用React.memo可以帮助我们跳过不必要的渲染。其用法如下

React.memo包装Decoration其实就是告诉React, 除非传递给我的props发生变化,否则不要重新渲染我

ParentComponet中的count变更时,React会重新渲染ParentComponent,同时沿着组件树重新渲染所有子组件,当碰到Decoration时,发现其被React.memo包裹,而Decorationprops又没有发生变化,所以就会Decoration就会跳过此次渲染。

下面代码是更新后的版本,此时再点击increase按钮,控制台就不会打印Decoration组件中的日志,因为Decoration没有被重新渲染。

同样的,让我们用一个交互式的图例来解释上面的流程。

总结

  1. 触发重新渲染的唯一方式就是调用set函数(例如:setCount)更新state。
  2. 当组件重新渲染时,组件树中它的子组件也会被重新渲染。
  3. 我们可以用 React.memo 包装我们的组件来优化它,跳过不必要的渲染。

useMemo

useMemo 的基本思想是,它允许我们在渲染之间“记住”计算出的值

通过缓存昂贵的计算来帮助提高性能

跳过繁重的计算

假设我们有一个很占内存的数组initialItems, 它的长度是3_000, 我们把数组最后一个元素的isSelected设置为true,意为选中。

现在,我们要从initialItems中遍历出这个被选中的元素, 如果这个数组长度不是3_000而是3_000_000,可想而知,这是一个多么繁重的计算。

当我们点击Increment按钮时, count发生改变,触发App的重新渲染,进而导致上面的计算又进行了一次

注意👇下面案例中的日志打印信息,你会发现,每点击一次都会循环上述步骤。

好在useMemo能够帮我解决这个问题,它能在渲染之间缓存计算值。

下面是使用useMemo的版本,点击按钮会发现,控制台不会再打印信息了。

前后两个版本的差异点在于后者使用了useMemo

useMemo有两个参数:

  1. calculateValue: 要缓存计算值的函数,它应该是一个没有任何参数的纯函数,并且可以返回任意类型。
  2. dependencies: 所有在 calculateValue 函数中使用的响应式变量组成的数组。

首次渲染时, useMemo返回 calculateValue函数的返回结果。

在后续渲染中,如果dependencies中的依赖项没有发生改变,它将返回上次缓存的只。否则将再次调用calculateValue,并返回最新结果。

减少重新渲染次数

在下面的案例中,我创建了一个Boxes组件,它用来展示一系列五颜六色的盒子,每个盒子中显示渲染时的时间。

App组件中,使用React.memo包装Boxes组件得到PureBoxes, 同时将数组boxes作为props传递给PureBoxes

点击按钮,注意三个盒子中的时间是否发生变化?

不难发现,每点击一次,三个盒子中的时间就刷新一次。这意为着点击按钮时PureBoxes组件会重新渲染。但PureBoxes是使用React.memo包装Boxes后得到, 这意为着只要Boxes组件接收到的props不发生变化,Boxes就不会跟着App重新渲染。

但结果是Boxes会跟着App重新渲染,原因只能是<PureBoxes boxes={boxes} />中的boxesApp渲染时发生了变化。

结合JavaScript知识点对象引用,我们可以知道,每当App重新渲染时, boxes都会被重新创建,虽然他们看起来相同,但引用却不相同。

换句话说,每次渲染时传递给Boxesboxes属性都是全新的数组,因此Boxes即使使用React.memo包装,也得重新渲染。

为了避免这个问题,我们可以使用useMemo来缓存boxes这个数组。这样一来,boxes数组只有在App首次渲染时会被创建,App重新渲染时不再创建,而是返回缓存的值。下面是更新后的代码,现在,再点击按钮,会发现三个盒子中的时间不会再跟着变化了。

useCallback

前面介绍了useMemo, 那useCallback又是干什么的呢?

简单点描述就是:useMemo用于缓存对象或数组,而useCallback用于缓存函数。

与数组和对象相同,函数也是通过引用来进行比较的:

这就意味着,当我们在组件内定义一个函数时,组件每次渲染都会重新生成一个全新的函数。

让我们看一个案例, 点击Click me!,观察控制台输出:

不难发现,每当点击Click me!时,ResetButton都会重新渲染。

其原因不难理解,当点击Click me!count改变,触发App重新渲染,handleRest函数被重新创建,然后handleReset作为props被传递给ResetButtonprops的改变导致ResetButton即使被React .memo包装,也会重新渲染。

此时,我们可以使用useCallback来缓存handleRest函数。

现在,再点击Click me, ResetButton就不会再跟着重新渲染了。

useCallbackuseMemo作用相同,不同的是useCallback是专门缓存函数的。我们交给它一个函数,它会缓存这个函数,直到其依赖发生变动。

下面两个表达式效果是相同的:

useCallback本质上是useMemo的一个语法糖。它的存在是为了让我们更直观的缓存函数。

Questions? Let's chat

discord logoOPEN DISCORD
6423
members online
previous article
Effect Hooks - useEffect
next article
Context Hooks - useContext

A TypeScript Full Stack Blog

Share articles about Typescript, React, Next.js, Node.js and Css from time to time.
No spam, unsubscribe at any time.