🔥从零到一:深入理解 useMemo, useCallback 和 react.memo🔥

198 阅读10分钟

引言

React 作为现代Web开发中最为流行的前端库之一,以其简洁的组件化思想和高效的虚拟DOM机制赢得了广大开发者的心。随着应用复杂度的不断增加,如何高效管理组件的重渲染成为了开发者面临的一大挑战。不必要的重渲染不仅会消耗更多的计算资源,还会导致用户体验下降,特别是在大型应用中,这种影响尤为明显。

为了应对这一挑战,React 提供了一系列强大的工具和钩子来帮助开发者优化性能。其中,useMemo, useCallbackreact.memo 是三个非常重要的工具,它们在不同的场景下发挥着关键作用。通过合理使用这些工具,开发者可以显著减少不必要的重渲染,提高应用的整体性能和响应速度。

本文将深入探讨这三个工具的作用、用法以及最佳实践。我们将从React的渲染机制入手,逐步展开对useMemo, useCallbackreact.memo 的详细介绍,并通过具体的例子来说明它们在实际项目中的应用。希望通过本文,读者能够更好地理解和掌握这些工具,从而在自己的项目中实现更高效的性能优化。

useMemo

为什么使用 useMemo ?

useMemo是性能优化的重要一环。我们都知道当我们的某个数据发生变化的时候,与这个数据有关的组件都会进行重渲染,即从头到尾全部重新执行一遍,产生虚拟dom,再经由diff算法检测变化,最后修改真实dom,渲染到页面上。 它其实是很消耗性能的。 你平时用作练习的小型App可能看不出来什么,但是当操作的数据变大,当对dom的操作变多,页面更新的速度就可能没有那么快了。比如下面这个例子:

import { useState, useMemo } from 'react'

import './App.css'

const initialItems = new Array(29_999_999).fill(0).map((_, index) => {
  return {
    id: index,
    isSelected: index === 29_999_998,  
    // 初始化一个数组,数组里面包裹了3千万个对象,只有第29999998个对象中的isSelected为true
  };
})


function App() {
  const [count, setCount] = useState(0)
  const [items] = useState(initialItems);

  const selectedItem = items.find((item) => item.isSelected);
    // 利用数组的find方法,找到符合条件的元素
  return (
    <>
      <div className="counts">
        <h1>Count:{count}</h1>
        <h1>Selected Item:{selectedItem?.id}</h1>
        <button onClick={() => setCount(count + 1)}>Add</button>
      </div>
    </>
  )
}

export default App

在这里我们写了一个计数器,还有一个selectedItem,执行这个selectedItem要遍历一个长度为3千万的数组,这是比较耗时的,接下来我们来看效果:

count-ezgif.com-video-to-gif-converter.gif

哎!驻波驻波!为什么一开始它一个数字一个数字加,过了一会儿就跳跃式增加了?!

其实是主播的手速太快了,这是由渲染机制和JS的执行机制导致的:

In case Y'all din't noticed(以防你们没看见),我们每次让count更新的时候,App这个组件都要重新渲染一遍,而每渲染一遍都要执行const selectedItem = items.find((item) => item.isSelected);,也就是说每一次渲染,它都要遍历3千万长度的数组。由于JS是单线程,JS的执行线程渲染线程不能同时进行的,而React会批量处理状态更新,当当前渲染未完成时,新的点击事件会被排队,所以:

当上一次的遍历数组没执行完毕的时候,无数的setCount就全部进入队列了,等待上一次的数组遍历完毕后,立刻!!马上!!React就把这几个setCount一起执行渲染到了页面上了。

image.png

现在我们知道问题所在了,我们可以用useMemo解决它:

import { useState, useMemo } from 'react'

import './App.css'

const initialItems = new Array(29_999_999).fill(0).map((_, index) => {
  return {
    id: index,
    isSelected: index === 29_999_998,  
    // 初始化一个数组,数组里面包裹了3千万个对象,只有第29999998个对象中的isSelected为true
  };
})


function App() {
  const [count, setCount] = useState(0)
  const [items] = useState(initialItems);

  const selectedItem = useMemo(() => items.find((item) => item.isSelected), [items]);
    // 利用数组的find方法,找到符合条件的元素
  return (
    <>
      <div className="counts">
        <h1>Count:{count}</h1>
        <h1>Selected Item:{selectedItem?.id}</h1>
        <button onClick={() => setCount(count + 1)}>Add</button>
      </div>
    </>
  )
}

export default App

现在的效果是这样的:

count2-ezgif.com-video-to-gif-converter.gif

如何使用 useMemo ?

useMemo 是 React 中的一个 Hook,用于优化性能。它允许你“记住”一个计算结果,从而避免在每次渲染时都进行相同的计算,特别是在依赖项没有发生变化时。

它是这么用的:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
  • 第一个参数是一个函数,该函数返回你想要缓存的值(也就是你想优化的耗时操作)。
  • 第二个参数是一个依赖数组(类似于 useState 或 useEffect 中的依赖数组)。如果数组中的任何一个值发生了变化,那么 useMemo 会重新计算并返回新的值;否则,它将返回之前缓存的结果。

注意,useMemo也是在渲染过程中同步执行的,它属于 React 的  "渲染流程",如果它的计算时间过长,可能会导致掉帧,也就是页面UI无法及时更新。

当浏览器或应用无法在 16.7ms 内完成一帧的渲染任务时,会跳过某些帧,导致画面更新变慢,表现为掉帧。

useMemo 优化的内容是本组件的逻辑,如果要优化子组件的函数逻辑,那就得用react.memo了。

React.memo

React.memo 是 React 提供的一个高阶组件(HOC) ,用于优化函数组件的性能。它通过记忆(Memoization) 技术,避免组件在不必要的 props 变化时重新渲染,从而提升应用性能。

为什么需要 react.memo

当父组件重新渲染时,即使子组件的 props 实际未发生变化,React 默认也会重新渲染子组件。React.memo 可以阻止这种不必要的渲染,只有在 props 真正改变时才会重新渲染子组件。

这个API主要就是优化子组件的渲染所用的,当它没有必要渲染的时候,就别渲染了。

如何使用 react.memo

const MyComponent = React.memo(function MyComponent(props) {
  // 组件逻辑
});

memo 的作用是返回一个可复用的优化组件,它会以后面的组件函数创建一个新的,可以优化的,避免不必要渲染的组件。

我们来看一个实际例子,我们建立了一个计数器(属于父组件)和一个输入框(子组件):

import React, { useState } from "react";

function Child({ text }) {
  console.log("Child 渲染了!"); // 仅在 text 变化时打印
  return <div>子组件接收的文本: {text}</div>;
};

// 父组件
function Parent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("Hello");

  return (
    <div>
      <h2>父组件计数器: {count}</h2>
      <button onClick={() => setCount(count + 1)}>增加计数</button>

      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="输入文本"
      />

      {/* Child 组件只依赖 text,不依赖 count */}
      <Child text={text} />
    </div>
  );
}

export default Parent;

当count发生变化时,父组件会重新渲染,由于父组件重新渲染时,子组件也会重新渲染,所以会是这样:

ezgif.com-video-to-gif-converter.gif

而当我们给子组件函数加上memo后,就能阻止渲染了:

const Child = React.memo(function Child({ text }) {
    console.log("Child 渲染了!"); 
    return <div>子组件接收的文本: {text}</div>;
});
ezgif.com-video-to-gif-converter (1).gif

现在利用useMemoReact.memo,我们可以对本组件以及子组件进行优化了,但是还有一种情况我们没有优化:当我们给子组件传入父组件的函数时,在父组件每次渲染时都会重新创建,导致每次都会被当作新函数传入子组件,引起渲染,要解决这个问题,就需要利用useCallback了。

useCallback

useCallback 是 React 中的一个 Hook,用于优化性能,避免不必要的函数重新创建。它通过缓存(记忆)函数,在依赖项不变时返回相同的函数引用,从而减少子组件的不必要渲染。

为什么需要 useCallback

在 React 中,每次组件重新渲染时,其内部的函数会被重新创建。如果将这些函数作为 props 传递给子组件(尤其是用 React.memo 优化的子组件),子组件会因接收到新的函数引用而触发不必要的渲染。useCallback 通过缓存函数解决这个问题。

React.memouseCallback 通常是配合使用的,目的是避免子组件因父组件的无关更新(如状态变化)而触发不必要的渲染:

React.memo 会对组件的 props 进行浅比较,如果 props 未变化,则跳过子组件的渲染。

但如果父组件传递的是新创建的函数(如内联函数或未缓存的函数),每次父组件渲染时,子组件的 props 会被判定为“变化”,导致重新渲染。

useCallback 缓存函数,在依赖项不变时返回相同的函数引用。

这样,父组件重新渲染时,如果依赖项未变,子组件接收到的函数引用不变,React.memo 的浅比较会认为 props 未变化,从而跳过子组件的渲染。

如何使用 useCallback ?

const memoizedCallback = useCallback(fn, dependencies);
  • fn: 需要缓存的函数。
  • dependencies: 依赖项数组,只有当依赖项变化时,才会重新创建函数。

拿下面的例子来直观展示吧!

const Child = React.memo(({ onClick }) => {
  console.log("Child rendered");
  return <button onClick={onClick}>Click</button>;
});

function Parent() {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    console.log("Button clicked");
  }; 

  return (
    <>
      <Child onClick={handleClick} />
      <button onClick={() => setCount(count + 1)}>Parent: {count}</button>
    </>
  );
}

<Child />组件重渲染时,会触发console.log("Child rendered");

<Child />组件传入了父组件的handleClick()方法。

ezgif.com-video-to-gif-converter (2).gif

我们可以看到,当父组件的数据变化时,父组件重新渲染了,导致父组件的函数也重新创建,子组件检测到这不是以前传入的函数了(即使函数名函数内容都一样,他也不同了),所以子组件也重新渲染了。

我们说过渲染会耗费很大的性能,所以为了避免组件进行不必要的渲染,我们就用到了React.memo,现在我们要用useCallback来进一步优化这种触发不必要渲染的情况

于是将const handleClick改为:

const handleClick =
        useCallback(() => {
            console.log("Button clicked");
        }, []); // 无依赖,函数始终不变

结果就成为了:

ezgif.com-video-to-gif-converter (3).gif

在这里我们看到父组件的重新渲染并没有触发子组件的渲染。

总结

React是数据驱动的,数据驱动页面的变化,驱动页面的渲染,但是我们知道有的时候页面的渲染不会合我们心意,比如:

组件某个部分发生变化,导致整个组件重新渲染的时候,其他地方跟着一起渲染。

父组件发生重新渲染的时候,导致没有任何变化的子组件重新渲染。

为了解决这种无意义的渲染所造成的掉帧等性能问题,我们引入了useMemo,React.memo以及useCallback.

useMemo主要用于修改本组件的逻辑,保护本组件一些逻辑不受其他变量的变化所影响,以至于重新渲染。

React.memouseCallback主要用于保护子组件的逻辑不受父组件的变化影响,而导致重新渲染,这两个都是配合使用的,React.memo像是把门关了90%,而配合上useCallback就把整个门全部关严实了————无论父组件如何变化,都不会导致子组件的无意义渲染,子组件只会在该渲染的时候进行渲染。