memo、useMemo及useCallback解析

10,326 阅读7分钟

前言

在hooks诞生之前,如果组件包含内部 state,我们都是基于 class 的形式来创建组件。

在react中,性能优化的点在于:

  1. 调用 setState,就会触发组件的重新渲染,无论前后 state 是否相同
  2. 父组件更新,子组件也会自动更新

基于上面的两点,我们通常的解决方案是:使用 immutable 进行比较,在不相等的时候调用 setState, 在 shouldComponentUpdate 中判断前后的 propsstate,如果没有变化,则返回 false 来阻止更新。

hooks 出来之后,函数组件中没有 shouldComponentUpdate 生命周期,我们无法通过判断前后状态来决定是否更新。useEffect 不再区分 mount update 两个状态,这意味着函数组件的每一次调用都会执行其内部的所有逻辑,那么会带来较大的性能损耗。

对比

我们先简单的看一下useMemo和useCallback的调用签名:

function useMemo<T>(factory: () => T, deps: DependencyList | undefined): T; function useCallback<T extends (...args: any[]) => any>(callback: T, deps: DependencyList): T;

useCallbackuseMemo 的参数跟 useEffect 一致,他们之间最大的区别有是 useEffect 会用于处理副作用,而前两个hooks不能。

useCallbackuseMemo 都会在组件第一次渲染的时候执行,之后会在其依赖的变量发生改变时再次执行;并且这两个hooks都返回缓存的值,useMemo 返回缓存的 变量useCallback 返回缓存的 函数

React.memo()

在 class 组件时代,为了性能优化我们经常会选择使用 PureComponent,每次对 props 进行一次浅比较,当然,除了 PureComponent 外,我们还可以在 shouldComponentUpdate 中进行更深层次的控制。

在 Function 组件中, React 贴心的提供了 React.memo 这个 HOC(高阶组件),与 PureComponent 很相似,但是是专门给 Function Component 提供的,对 Class Component 并不适用。

但是相比于 PureComponent ,React.memo() 可以支持指定一个参数,可以相当于 shouldComponentUpdate 的作用,因此 React.memo() 相对于 PureComponent 来说,用法更加方便。

(当然,如果自己封装一个 HOC,并且内部实现 PureComponent + shouldComponentUpdate 的结合使用,肯定也是 OK 的,在以往项目中,这样使用的方式还挺多)

首先看下 React.memo() 的使用方式:

function MyComponent(props) {
  /* render using props */
}
function areEqual(prevProps, nextProps) {
  /*
  return true if passing nextProps to render would return
  the same result as passing prevProps to render,
  otherwise return false
  */
}
export default React.memo(MyComponent, areEqual);

使用方式很简单,在 Function Component 之外,在声明一个 areEqual 方法来判断两次 props 有什么不同,如果第二个参数不传递,则默认只会进行 props 的浅比较

最终 export 的组件,就是 React.memo() 包装之后的组件。

实例:

  • index.js:父组件

  • Child.js:子组件

  • ChildMemo.js:使用 React.memo 包装过的子组件

index.js

import React, { useState, } from 'react';
import Child from './Child';
import ChildMemo from './Child-memo';

export default (props = {}) => {
    const [step, setStep] = useState(0);
    const [count, setCount] = useState(0);
    const [number, setNumber] = useState(0);

    const handleSetStep = () => {
        setStep(step + 1);
    }

    const handleSetCount = () => {
        setCount(count + 1);
    }

    const handleCalNumber = () => {
        setNumber(count + step);
    }

    return (
        <div>
            <button onClick={handleSetStep}>step is : {step} </button>
            <button onClick={handleSetCount}>count is : {count} </button>
            <button onClick={handleCalNumber}>numberis : {number} </button>
            <hr />
            <Child step={step} count={count} number={number} /> <hr />
            <ChildMemo step={step} count={count} number={number} />
        </div>
    );
}

child.js

这个子组件本身没有任何逻辑,也没有任何包装,就是渲染了父组件传递过来的 props.number

需要注意的是,子组件中并没有使用到 props.stepprops.count,但是一旦 props.step 发生了变化就会触发重新渲染。

import React from 'react';

export default (props = {}) => {
    console.log(`--- re-render ---`);
    return (
        <div>
            {/* <p>step is : {props.step}</p> */}
            {/* <p>count is : {props.count}</p> */}
            <p>number is : {props.number}</p>
        </div>
    );
};

childMemo.js

这个子组件使用了 React.memo 进行了包装,并且通过 isEqual 方法判断只有当两次 props 的 number 的时候才会重新触发渲染,否则 console.log 也不会执行。

import React, { memo, } from 'react';

const isEqual = (prevProps, nextProps) => {
    if (prevProps.number !== nextProps.number) {
        return false;
    }
    return true;
}

export default memo((props = {}) => {
    console.log(`--- memo re-render ---`);
    return (
        <div>
            {/* <p>step is : {props.step}</p> */}
            {/* <p>count is : {props.count}</p> */}
            <p>number is : {props.number}</p>
        </div>
    );
}, isEqual);

效果对比

通过上图可以发现,在点击 step 和 count 的时候,props.step 和 props.count 都发生了变化,因此 Child.js 这个子组件每次都在重新执行渲染(----re-render----),即使没有用到这两个 props。

而这种情况下,ChildMemo.js 则不会重新进行 re-render。

只有当 props.number 发生变化的时候,ChildMemo.jsChild.js 表现是一致的。

从上面可以看出,React.memo() 的第二个方法在某种特定需求下,是必须存在的。 因为在实验的场景中,我们能够看得出来,即使我使用 React.memo 包装了 Child.js,也会一直触发重新渲染,因为 props 浅比较肯定是发生了变化。

React.useMemo() 细粒度性能优化

上面 React.memo() 的使用我们可以发现,最终都是在最外层包装了整个组件,并且需要手动写一个方法比较那些具体的 props 不相同才进行 re-render。

而在某些场景下,我们只是希望 component 的部分不要进行 re-render,而不是整个 component 不要 re-render,也就是要实现 局部 Pure 功能。

useMemo() 基本用法如下:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

useMemo() 返回的是一个 memoized 值,只有当依赖项(比如上面的 a,b 发生变化的时候,才会重新计算这个 memoized 值)

memoized 值不变的情况下,不会重新触发渲染逻辑。

说起渲染逻辑,需要记住的是 useMemo() 是在 render 期间执行的,所以不能进行一些额外的副操作,比如网络请求等。

如果没有提供依赖数组(上面的 [a,b])则每次都会重新计算 memoized 值,也就会 re-redner

上面的代码中新增一个 Child-useMemo.js 子组件如下:

import React, { useMemo } from 'react';

export default (props = {}) => {
    console.log(`--- component re-render ---`);
    return useMemo(() => {
        console.log(`--- useMemo re-render ---`);
        return <div>
            {/* <p>step is : {props.step}</p> */}
            {/* <p>count is : {props.count}</p> */}
            <p>number is : {props.number}</p>
        </div>
    }, [props.number]);
}

与上面唯一的区别是使用的 useMemo() 包装的是 return 部分渲染的逻辑,并且声明依赖了 props.number,其他的并未发生变化。

效果对比:

上面图中我们可以发现,父组件每次更新 step/count 都会触发 useMemo 封装的子组件的 re-render,但是 number 没有变化,说明并没有重新触发 HTML 部分 re-render

只有当依赖的 props.number 发生变化的时候,才会重新触发 useMemo() 包装的里面的 re-render

React.useCallback()

讲完了useMemo,接下来是 useCallback。useCallback 跟 useMemo 比较类似,但它返回的是缓存的函数。我们看一下最简单的用法:

const fnA = useCallback(fnB, [a])

实例:

import React, { useState, useCallback } from 'react';
import Button from './Button';

export default function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  const handleClickButton1 = () => {
    setCount1(count1 + 1);
  };

  const handleClickButton2 = useCallback(() => {
    setCount2(count2 + 1);
  }, [count2]);

  return (
    <div>
      <div>
        <Button onClickButton={handleClickButton1}>Button1</Button>
      </div>
      <div>
        <Button onClickButton={handleClickButton2}>Button2</Button>
      </div>
    </div>
  );
}

Button组件

// Button.jsx
import React from 'react';

const Button = ({ onClickButton, children }) => {
  return (
    <>
      <button onClick={onClickButton}>{children}</button>
      <span>{Math.random()}</span>
    </>
  );
};

export default React.memo(Button);

这里或许会注意到 React.memo 这个方法,此方法内会对 props 做一个浅层比较,如果如果 props 没有发生改变,则不会重新渲染此组件。

上面的 Button 组件都需要一个 onClickButton 的 props ,尽管组件内部有用 React.memo 来做优化,但是我们声明的 handleClickButton1 是直接定义了一个方法,这也就导致只要是父组件重新渲染(状态或者props更新)就会导致这里声明出一个新的方法,新的方法和旧的方法尽管长的一样,但是依旧是两个不同的对象,React.memo 对比后发现对象 props 改变,就重新渲染了。

const handleClickButton2 = useCallback(() => {
  setCount2(count2 + 1);
}, [count2]);

上述代码我们的方法使用 useCallback 包装了一层,并且后面还传入了一个 [count2] 变量,这里 useCallback 就会根据 count2 是否发生变化,从而决定是否返回一个新的函数,函数内部作用域也随之更新。

由于我们的这个方法只依赖了 count2 这个变量,而且 count2 只在点击 Button2 后才会更新 handleClickButton2,所以就导致了我们点击 Button1 不重新渲染 Button2 的内容。

总结

  1. 在子组件不需要父组件的值和函数的情况下,只需要使用 memo 函数包裹子组件即可。

  2. 如果有函数传递给子组件,使用 useCallback

  3. 如果有值传递给子组件,使用 useMemo

  4. useEffectuseMemouseCallback 都是自带闭包的。也就是说,每一次组件的渲染,其都会捕获当前组件函数上下文中的状态(state, props),所以每一次这三种hooks的执行,反映的也都是当前的状态,你无法使用它们来捕获上一次的状态。对于这种情况,我们应该使用 ref 来访问。