跳到主要内容

使用 React.memo 和 React.useMemo 对项目性能优化

· 阅读需 17 分钟

之前文章已经大概介绍过 useMemo 的用法,这篇文章会详细介绍该何时、如何正确使用它,并且搭配 React.memo 来对我们的项目进行一个性能优化。

React.memo

示例

我们先从一个简单的示例入手

以下是一个常规的父子组件关系,打开浏览器控制台并观察,每次点击父组件中的 + 号按钮,都会导致子组件渲染。

实时编辑器
结果
Loading...

子组件的 name 参数明明没有被修改,为什么还是重新渲染?

这就是 React 的渲染机制,组件内部的 state 或者 props 一旦发生修改,整个组件树都会被重新渲染一次,即时子组件的参数没有被修改,甚至无状态组件。

如何处理这个问题?接下里就要说到 React.memo

介绍

React.memoReact 官方提供的一个高阶组件,用于缓存我们的需要优化的组件

如果你的组件在相同 props 的情况下渲染相同的结果,那么你可以通过将其包装在 React.memo 中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现。这意味着在这种情况下,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果。

让我们来改进一下上述的代码,只需要使用 React.memo 组件包裹起来即可,其他用法不变

使用

实时编辑器
结果
Loading...

再次观察控制台,应该会发现再点击父组件的按钮,子组件已经不会重新渲染了。

这就是 React.memo 为我们做的缓存优化,渲染 Child 组件之前,对比 props,发现 name 没有发生改变,因此返回了组件上一次的渲染的结果。

React.memo 仅检查 props 变更。如果函数组件被 React.memo 包裹,且其实现中拥有 useState,useReducer 或 useContext 的 Hook,当 state 或 context 发生变化时,它仍会重新渲染。

当然,如果我们子组件有内部状态并且发生了修改,依然会重新渲染(正常行为)。

FAQ

看到这里,不禁会产生疑问,既然如此,那我直接为每个组件都添加 React.memo 来进行缓存就好了,再深究一下,为什么 React 不直接默认为每个组件缓存呢?那这样既节省了开发者的代码,又为项目带来了许多性能的优化,这样不好吗?

使用太多的缓存,反而容易带来 负提升

前面有说到,组件使用缓存策略后,在被更新之前,会比较最新的 props 和上一次的 props 是否发生值修改,既然有比较,那就有计算,如果子组件的参数特别多且复杂繁重,那么这个比较的过程也会十分的消耗性能,甚至高于 虚拟 DOM 的生成,这时的缓存优化,反而产生的负面影响,这个就是关键问题。

当然,这种情况很少,大部分情况还是 组件树的 虚拟 DOM 计算比缓存计算更消耗性能。但是,既然有这种极端问题发生,就应该把选择权交给开发者,让我们自行决定是否需要对该组件进行渲染,这也是 React 不默认为组件设置缓存的原因。

也因此,在 React 社区中,开发者们也一致的认为,不必要的情况下,不需要使用 React.memo

什么时候该用? 组件渲染过程特别消耗性能,以至于能感觉到到,比如:长列表、图表等

什么时候不该用?组件参数结构十分庞大复杂,比如未知层级的对象,或者列表(城市,用户名)等

React.memo 二次优化

React.memo 默认每次会对复杂的对象做对比,如果你使用了 React.memo 缓存的组件参数十分复杂,且只有参数属性内的某些/某个字段会修改,或者根本不可能发生变化的情况下,你可以再粒度化的控制对比逻辑,通过 React.memo 第二个参数

function MyComponent(props) {
/* 使用 props 渲染 */
}
function shouldMemo(prevProps, nextProps) {
/*
如果把 nextProps 传入 render 方法的返回结果与
将 prevProps 传入 render 方法的返回结果一致则返回 true,
否则返回 false
*/
}
export default React.memo(MyComponent, shouldMemo);

如果对 class 组件有了解过的朋友应该知道,class 组件有一个生命周期叫做 shouldComponentUpdate(),也是通过对比 props 来告诉组件是否需要更新,但是与这个逻辑刚好相反。

总结

对于 React.memo,无需刻意去使用它进行缓存组件,除非你能感觉到你需要。另外,不缓存的组件会多次的触发 render,因此,如果你在组件内有打印信息,可能会被多次的触发,也不用去担心,即使强制被 rerender,因为状态没有发生改变,因此每次 render 返回的值还是一样,所以也不会触发真实 dom 的更新,对页面实际没有任何影响。

useMemo

示例

同样,我们先看一个例子,calculatedCount 变量是一个假造的比较消耗性能的计算表达式,为了方便显示性能数据打印时间,我们使用了 IIFE 立即执行函数,每次计算 calculatedCount 都会输出它的计算消耗时间。

打开控制台,因为是 IIFE,所以首次会直接打印出时间。然后,再点击 + 号,会发现再次打印出了计算耗时。这是因为 React 组件重渲染的时候,不仅是 jsx,而且变量,函数这种也全部都会再次声明一次,因此导致了 calculatedCount 重新执行了初始化(计算),但是这个变量值并没有发生改变,如果每次渲染都要重新计算,那也是十分的消耗性能。

注意观察,在计算期间,页面会发生卡死,不能操作,这是 JS 引擎 的机制,在执行任务的时候,页面永远不会进行渲染,直到任务结束为止。这个过程对用户体验来说是致命的,虽然我们可以通过微任务去处理这个计算过程,从而避免页面的渲染阻塞,但是消耗性能这个问题仍然存在,我们需要通过其他方式去解决。

实时编辑器
结果
Loading...

介绍

const memoizedValue = useMemo(() => {
// 处理复杂计算,并 return 结果
}, []);

useMemo 返回一个缓存过的值,把 "创建" 函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算

第一个参数是函数,函数中需要返回计算值

第二个参数是依赖数组

  • 如果不传,则每次都会初始化,缓存失败
  • 如果传空数组,则永远都会返回第一次执行的结果
  • 如果传状态,则在依赖的状态变化时,才会从新计算,如果这个缓存状态依赖了其他状态的话,则需要提供进去。

这下就很好理解了,我们的 calculatedCount 没有任何外部依赖,因此只需要传递空数组作为第二个参数,开始改造

使用

实时编辑器
结果
Loading...

现在,"Memo Calculated Count 计算耗时"的输出信息永远只会打印一次,因为它被无限缓存了。

FAQ

何时使用?

当你的表达式十分复杂需要经过大量计算的时候

示例 2

下面示例中,我们使用状态提升,将子组件的 click 事件函数放在了父组件中,点击父组件的 + 号,发现子组件被重新渲染

实时编辑器
结果
Loading...

于是我们想到用 memo 函数包裹子组件,给缓存起来

实时编辑器
结果
Loading...

但是意外来了,即使被 memo 包裹的组件,还是被重新渲染了,为什么!

我们来逐一分析

  1. 首先,点击父组件的 + 号,count 发生变化,于是父组件开始重渲染
  2. 内部的未经处理的变量和函数都被重新初始化,useState 不会再初始化了, useEffect 钩子函数重新执行,虚拟 dom 更新
  3. 执行到 Child 组件的时候,Child 准备更新,但是因为它是 memo 缓存组件,于是开始浅比较 props 参数,到这里为止一切正常
  4. Child 组件参数开始逐一比较变更,到了 onClick 函数,发现值为函数,提供的新值也为函数,但是因为刚刚在父组件内部重渲染时被重新初始化了(生成了新的地址),因为函数是引用类型值,导致引用地址发生改变!比较结果为不相等, React 仍会认为它已更改,因此重新发生了渲染。

既然函数重新渲染会被重新初始化生成新的引用地址,因此我们应该避免它重新初始化。这个时候,useMemo 的第二个使用场景就来了

实时编辑器
结果
Loading...

这里我们将原本的 handleChildClick 函数通过 useMemo 包裹起来了,另外函数永远不会发生改变,因此传递第二参数为空数组,再次尝试点击 + 号,子组件不会被重新渲染了。

对于对象,数组,renderProps(参数为 react 组件) 等参数,都可以使用 useMemo 进行缓存

示例 3

既然 useMemo 可以缓存变量函数等,那组件其实也是一个函数,能不能被缓存呢?我们试一试

继续使用第一个案例,将 React.memo 移除,使用 useMemo 改造

实时编辑器
结果
Loading...

尝试点击 + 号,是的,ChilduseMemo 缓存成功了!

总结

同样的,不是必要的情况下,和 React.memo 一样,不需要特别的使用 useMemo

使用场景

  1. 表达式有复杂计算且不会频发触发更新
  2. 引用类型的组件参数,函数,对象,数组等(一般情况下对象和数组都会从 useState 初始化,useState 不会二次执行,主要是函数参数)
  3. react 组件的缓存

扩展

useCallback

前面使用 useMemo 包裹了函数,会感觉代码结构非常的奇怪

const handleChildClick = useMemo(() => {
return () => {
//
};
}, []);

函数中又 return 了一个函数,其实还有另一个推荐的 APIuseCallback 来代替于对函数的缓存,两者功能是完全一样,只是使用方法的区别,useMemo 需要从第一个函数参数中 return 出要缓存的函数,useCallback 则直接将函数传入第一个参数即可

const handleChildClick = useCallback(() => {
//
}, []);

代码风格上简介明了了许多


看完这篇文章,相信你对 React.memoReact.useMemo 已经有了一定的了解,并且知道何时/如何使用它们了