你真的会用 useMemo、useCallback和memo吗?

你真的会用 useMemo、useCallback和memo吗?

在React框架下,Function Component必须重复地执行昂贵的计算,才能得到最终的render tree。React开发团队也意识到了这个问题,配套给出了一些优化手段,其本质是通过内部缓存来减少重复的代码执行,提高组件的性能。然而:

性能优化总是会有成本,并不总是带来好处。

本文将会来谈谈 useMemouseCallbackmemo 的成本和收益,并通过实例给出useMemouseCallbackmemo的正确打开方式。

引例

看一段简单代码:

import React from 'react';

export default function Main() {
  const data = ['apple', 'banana', 'pears', 'watermelon', 'coconut', 'mangosteen'];

  return (
    <div className={'main'}>
      <ul>
        {data.map((text) => (
          <li>{text}</li>
        ))}
      </ul>
    </div>
  );
}
复制代码

上述代码实现的是一个动态ul列表,为了“提高”性能,我们引用useMemo。使用useMemo包裹data,这样就可以缓存data,防止data重复定义。

import React, { useMemo } from 'react';

export default function Main() {
  const data = useMemo(() => {
    return ['apple', 'banana', 'pears', 'watermelon', 'coconut', 'mangosteen'];
  }, []);

  return (
    <div className={'main'}>
      <ul>
        {data.map((text) => (
          <li>{text}</li>
        ))}
      </ul>
    </div>
  );
}
复制代码

所以我的问题是,在这个特定的例子中,哪一个对性能更好?原来的 or 使用 useMemo后?

如果你选择的是 useMemo,那么,恭喜你,回答错误。正确答案是:使用原来的代码性能会更好!

???useMemo可是React官方推荐的优化手段,怎么会引起性能下降呢?

接下来就好好讨论一下这个问题...

useMemo

先看一下定义:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
复制代码

useMemo will only recompute the memoized value when one of the dependencies has changed. This optimization helps to avoid expensive calculations on every render.
useMemo只会在其中一个依赖项发生变化时重新计算记忆的值。这种优化有助于避免每次渲染的昂贵计算。

useMemo是一个React钩子,用来记忆函数的输出。useMemo接受两个参数:一个函数和一个依赖列表。useMemo将调用该函数并返回其返回值。因此,useMemo的优化思路就是引用缓存。
看一下useMemo源码useMemo的实现在mount阶段和update阶段是不同的,其中mountMemo<T>在组件mount阶段执行:

function mountMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}
复制代码

updateMemo<T>在组件update阶段执行:

function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    // Assume these are defined. If they're not, areHookInputsEqual will warn.
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}
复制代码

从上述代码可知无论是在mountMemo<T>还是updateMemo<T>内部都存在依赖比较和引用更新的逻辑,这些逻辑的执行需要消耗时间吗?答案是肯定的!这是肯定会有人说useMemo会记忆上一次cb(回调函数)运算的值啊,可以减少重复计算。
那如果该重复计算的耗时小于useMemo本身进行Equals判断的耗时呢?是不是就会出现因为使用useMemo而增加了耗时的情况。下面用一个详细例子来进行分析:

// T1
const [count, setCount] = useState(0);

// T2
const add = () => { 
  setCount((prev) => prev + 1);
};
复制代码

上面代码定义了一个count,其中add函数通过调用setCountcount加1。假设定义count耗时T1,定义add耗时T2,那么上面代码的总耗时是T1+T2。

直接定义耗时:T1+T2

接下来使用useMemo进行改写:

const [count, setCount] = useState(0);

const memoAdd = useMemo(() => {
  return () => {
    setCount((prev) => prev + 1);
  };
}, []);
复制代码

上面代码定义了一个count,其中memoAdd函数是一个用useMemo包裹的函数,用来让count加1。上面这段代码等价于下面的写法:

// T1
const [count, setCount] = useState(0);

// T2'
const add = () => {
  return () => {
    setCount((prev) => prev + 1);
  };
};

// T3
const memoAdd = useMemo(add, []);
复制代码

改写后跟第一版未使用useMemo的写法相比不仅有T1,T2'这两部分的耗时,还增加useMemo内部计算的耗时T3。

引入useMemo耗时:T1+T2'+T3

哪个耗时较大?即:Max(T1+T2, T1+T2'+T3) = ?
表达式两边去掉同样的耗时T1,所以变为 Max(T2, T2'+T3) = ? ,假设无论函数复杂度多少,js定义函数的耗时都是一样的,即有 T2 === T2';
又因为 T3 > 0,所以得到 Max(T1+T2, T1+T2'+T3) = T1+T2'+T3,即T1+T2'+T3 > T1+T2
使用useMemo后代码整体耗时增加了!

当然上面的例子为了更加显著的对比,而采用了useMemo直接返回结果的做法,因此cb函数本身的运行耗时被抹平了。但这恰恰也是大量开发者在实际开发过程中犯的错误。明明是想获取cb里直接return的计算结果,中间没有任何额外的复杂运算,还喜欢用useMemo进行包裹,其结果是费力不讨好,事倍功半!

useCallback

说完了useMemo,再看一下useCallback

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);
复制代码

useCallback只能返回一个callback function。

useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).

根据官方文档,可以知道useCallback其实就是特异化的useMemo
看一下useCallback源码,同样分为mount阶段实现和update阶段实现,mountCallback<T>在组件mount阶段执行:

function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}
复制代码

updateCallback<T>在组件update阶段执行:

function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps: Array<mixed> | null = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}
复制代码

可以发现useCallback的实现跟useMemo非常相似,这也就意味着如果获取cb的耗时小于useCallback本身引用相等判断的耗时,那么使用useCallback不能带来性能提升,反而会增加性能损耗。

关键点

Performance optimizations are not free. They ALWAYS come with a cost but do NOT always come with a benefit to offset that cost.
性能优化不是免费的。 它们总是带来成本,并且优化带来的好处并不总是能抵消成本。

何时useMemo和useCallback

首先需要明白useMemouseCallback内置于React的初衷:

  1. 保证引用相等
  2. 避免昂贵的计算

保证引用相等

引用相等这个问题较为基础,这里不过多展开,从下面例子就很好的展示了值相等和引用相等的区别:

// 判断值相等
true === true // true
false === false // true
1 === 1 // true
'a' === 'a' // true

// 判断引用相等
{} === {} // false
[] === [] // false
() => {} === () => {} // false

const obj = {}
obj === obj // true
复制代码

在react里保证引用相等可以带来什么好处呢?考虑一下useEffect的依赖列表,看一下下面的这个例子: 组件Blub使用了Foo组件,其中Foo组件的useEffect依赖了传入的参数bar, baz。看起来很完美,只有当bar和baz改变时才会触发useEffect里的计算逻辑。

function Foo({bar, baz}) {
  React.useEffect(() => {
    const options = {bar, baz}
    buzz(options)
  }, [bar, baz]) // we want this to re-run if bar or baz change
  return <div>foobar</div>
}

function Blub() {
  return <Foo bar="bar value" baz={3} />
}
复制代码

那如果bar, baz其中一个或者两个都是引用类型的值呢?

function Blub() {
  return <Foo bar={['bar','value']} baz={() => {}} />
}
复制代码

那么Blub每次渲染时 bar, baz 都将是新的引用,所以当React测试依赖列表的值是否在渲染之间发生变化时,它将始终计算为 true,意味着每次渲染后都会调用 useEffect 回调,而不是仅在 bar 和 baz 的值更改时调用。
问题已经明确了,怎么解决?
这时轮到 useCallback 和 useMemo 出场了。通过引用记忆可以这样解决这类问题:

function Foo({bar, baz}) {
  React.useEffect(() => {
    const options = {bar, baz}
    buzz(options)
  }, [bar, baz])
  return <div>foobar</div>
}

function Blub() {
  const bar = React.useMemo(() => [1, 2, 3], []);
  const baz = React.useCallback(() => {}, []);
  return <Foo bar={bar} baz={baz} />
}
复制代码

我们使用useMemo包裹bar,使用useCallback包裹baz,实现了对变更引用的记忆,有效避免了组件更新时因为引用不一致导致的依赖列表重刷,从而触发冗余计算的问题。

小提示:引用缓存技巧不仅对于 useEffect,对于useLayoutEffect , useCallback和 useMemo 的依赖列表元素也同样适用。

避免昂贵的计算

看下面的例子(这里只是举一个例子来表现“昂贵的计算”,不代表实际代码场景):

const [count, setCount] = useState(0);

const add = () => {
  setCount((prev) => prev + 1);
};

const memoAdd = useMemo(() => {
  for (let i = 0; i < 1000000; i++) {
    console.log(i);
  }
  return add;
}, []);
复制代码

仍然是返回一个add方法,但是在返回之前增加了for (let i = 0; i < 1000000; i++),执行成本成倍增加。此时因为有useMemo的缓存加持,for循环只会在初始阶段执行一次,后续将不在执行,极大提高了性能。

React.memo()

React.memo() 是 React v16.6 中引入的新功能。 它与 React.PureComponent 类似,它有助于控制 函数组件 的重新渲染。
React.memo() 对应的是函数式组件,React.PureComponent 对应的是类组件。关于memo()的基本用法,网上已经有大量文档资料,此处不再展开。下面将重点对memo()使用中的误区进行剖析,并给出其解决方案。

先看一个例子

import React, { useState } from 'react';

interface ButtonProps {
  onClick: (param: any) => void;
  text: string;
}
const Button = function ({ onClick, text }: ButtonProps) {
  return <button onClick={onClick}>{text}</button>;
};

function NoMemoCandy() {
  const [count, setCount] = useState(0);
  const addCount = () => {
    setCount((prev) => prev + 1);
  };
  const clearCount = () => {
    setCount(0);
  };

  return (
    <div className={'memo-candy'}>
      <div>{count}</div>
      <div className={'button-container'}>
        <Button onClick={addCount} text={'Add'} />
        <Button onClick={clearCount} text={'Clear'} />
      </div>
    </div>
  );
}

export default NoMemoCandy;
复制代码

上面代码是一个很简单的add/clear计数器,点击Add计数器加1,点击Clear计数器清零。 效果图:

image.png 上面代码中Button组件没有使用memo包裹,因此每一次父组件的渲染都会引起Button的渲染,造成额外的渲染开销(因此这里add和clear只是想更新<div>{count}</div>部分)。

安装React Developer Tools插件后打开Chrome 的devtools
-> 选择 Components
-> 勾选 Highlight updates when components render

1.gif

可以发现点击Add/Clear之后不仅父组件执行了render,两个Button组件也执行了render。

引入memo

这里我们遇到了减少渲染的场景,根据上文的内容大家肯定会想到引入memo进行优化。说干就干~

...
function MemoCandy() {
  // 跟NoMemoCandy一致,此处不再贴出
  const MemoButton = memo(Button);

  return (
    <div className={'memo-candy'}>
      <div>{count}</div>
      <div className={'button-container'}>
        <MemoButton onClick={addCount} text={'Add'} />
        <MemoButton onClick={clearCount} text={'Clear'} />
      </div>
    </div>
  );
}
...
复制代码

优化的措施就是使用memoButton组件进行包裹得到MemoButton组件,然后在代码改用MemoButton组件,其余代码完全一致。优化后再看效果: 1.gif

额额,效果就是...没啥效果!? memo没有起到应有的作用。

让memo真正作用起来

看一下memo作用的官方解释:

React.memo 仅检查 props 变更。如果函数组件被 React.memo 包裹,且其实现中拥有 useStateuseReducer 或 useContext 的 Hook,当 state 或 context 发生变化时,它仍会重新渲染。
默认情况下其只会对复杂对象做浅层对比,如果你想要控制对比过程,那么请将自定义的比较函数通过第二个参数传入来实现。

回到我们例子里,因为memo检查props的变更时采用的是标准的js相等判断逻辑:对于基本数据类型执行值相等判断,对于引用数据类型执行引用相等判断。所以当组件props里的值为引用类型时,我们知道父组件的每一次render都会传递新的引用对象,memo进行相等判断时走引用相等逻辑判断,从而每次都会得到false的判断结果,也就不会阻止render过程,造成重复的渲染计算。
既然知道了问题所在,那就好办了,只需重点关注memo组件的引用属性,保证其引用相等即可。useMemo/useCallback闪亮登场!
在上面的例子中,MemoButton组件的onClick属性是引用类型,传入的是React.Dispatch<React.SetStateAction<number>>类型值(其实就是Function),所以可以使用useCallback包裹值保证其在每次render后引用值相等:

...
function MemoCandy() {
  ...
  const MemoButton = memo(Button);
  const addCount = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);
  const clearCount = useCallback(() => {
    setCount(0);
  }, []);

  return (
    <div className={'memo-candy'}>
      <div>
        <div>{count}</div>
      </div>
      <div className={'button-container'}>
        <MemoButton onClick={addCount} text={'Add'} />
        <MemoButton onClick={clearCount} text={'Clear'} />
      </div>
    </div>
  );
}
...
复制代码

上面代码的主要改变是将原来的addCount/clearCount函数用useCallback包裹:

const addCount = () => {
    setCount((prev) => prev + 1);
  };
const clearCount = () => {
    setCount(0);
  };
复制代码

优化后:

const addCount = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);
const clearCount = useCallback(() => {
    setCount(0);
  }, []);
复制代码

再看效果:

2.gif

两个Button组件不在重复执行render了,完美达到我们的既定目标。

总结

  • 性能优化都是有代价的。直到确实需要抽象或优化时才去做,这样可以避免承担成本而不会获得收益的情况。
  • 使用useCallback 和 useMemo的成本是:对于你的同事来说,你使代码更复杂了;你可能在依赖项数组中犯了一个错误,并且你可能通过调用内置的 hook、并防止依赖项和 memoized 值被垃圾收集,而使性能变差。如果你获得了必要的性能收益,那么这些成本都是值得承担的,但请谨记非必要不优化这一原则
  • 单纯使用memo只是让组件拥有了 memoized 能力,而真正发挥出组件的 memoized 能力往往需要搭配useCallback 和 useMemo使用。

参考

useCallback
React.memo
When to useMemo and useCallback
You’re overusing useMemo: Rethinking Hooks memoization

分类:
前端