性能优化 - 何时用useMemo及useCallback

2,274 阅读11分钟

本文为译文,仅作为个人学习之用。有翻译不到位地方,欢迎指出,我会及时修正。 英文原文:kentcdodds.com/blog/usemem…

性能优化总是有代价的,但却不总是能带来收益的。让我们讨论以下useMemo和useCallback的代价和收益。 这里有一个糖果自动售卖机: 这是它的实现:

function CandyDispenser() {
  const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
  const [candies, setCandies] = React.useState(initialCandies)
  const dispense = candy => {
    setCandies(allCandies => allCandies.filter(c => c !== candy))
  }
  return (
    <div>
      <h1>Candy Dispenser</h1>
      <div>
        <div>Available Candy</div>
        {candies.length === 0 ? (
          <button onClick={() => setCandies(initialCandies)}>refill</button>
        ) : (
          <ul>
            {candies.map(candy => (
              <li key={candy}>
                <button onClick={() => dispense(candy)}>grab</button> {candy}
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  )
}

现在我想问你一个问题,我希望你继续之前认真思考这个问题。接下来我要改变它,希望你来告诉我哪种能表现更好的性能特征。

唯一我打算改变的是,把dispatch函数包裹在React.useCallback中:

const dispense = React.useCallback(candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}, [])

这是一开始的写法:

const dispense = candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}

那么我的问题是,在这个例子中,哪个可以表现更好性能?提交你的猜想并且继续看下去。

让我给你一些空间先不要打开答案。

继续向下,你已经点了回答了,对吗?

好了,是时候解开答案了。

为什么使用useCallback是错误的?!

我们听了很多次了,你应该使用React.useCallback提升性能,并且“内联函数可能会对性能造成问题”。那么不使用Callback怎么会更好呢?

回顾以下我们这个例子,最终从React的角度考虑下面这个问题:每一行代码的执行都会带来代价。让我们稍微重构这个useCallback例子(不是真正的改变,只移动一下代码位置),来让问题展示得更明显:

const dispense = candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}
const dispenseCallback = React.useCallback(dispense, [])

下面是一开始的写法:

const dispense = candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}

注意到了吗?看一下不同点:

const dispense = candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}
+ const dispenseCallback = React.useCallback(dispense, [])

是的,它们完全相同,只是useCallback版本要做更多的工作。除了定义了函数(函数还是会被创建),还要定义一个数组([])并调用 React.useCallback,它(指useCallback)本身会设置属性和运行逻辑表达式等。

所以这些例子中,JavaScript必须在每次的render中为函数定义分配内存,同时取决于useCallback的调用方式,你也许会为函数定义获取更多的内存分配。(实际上这不是这例子的关键,但是这一点也是成立的)。这就是我试图通过我的 Twitter 民意调查得到的:

image.png

🌟我还想提一下,在组件的第二次渲染中,原来未被useCallback 包裹的 dispense 函数被垃圾收集(释放内存空间),然后创建一个新的 dispense 函数。 但是使用 useCallback 包裹时,原来的 dispense 函数不会被垃圾收集,并且会创建一个新的 dispense 函数,所以从内存的角度来看,这会变得更糟。

作为一个相关的说明,如果有其它依赖,那么React很可能会挂起对之前函数的引用,因为 memoization通常意味着我们保留旧值的副本,以便在我们获得与先前给出的相同依赖的情况下返回。 特别聪明的你会注意到,这意味着React还必须挂在对这个等式检查依赖项的引用上(由于闭包,这种情况可能会偶然发生,但无论如何它都值得一提)。

useMemo不同于useCallback,为什么相似?

useMemouseCallback相似,但是useMemo允许你任意数据类型(不仅仅是functions)使用内存。 useMemo接受一个返回value的function,这个function仅仅在需要取这个值的时候调用(通常仅仅在每次render时,依赖数组发生变化的时候) 所以如果我不想在每一次render时都初始化数组initialCandies,我可以这样改:

- const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
+ const initialCandies = React.useMemo(
+  () => ['snickers', 'skittles', 'twix', 'milky way'],
+  [],
+ )

这样改可以避免上面那个问题(重复初始化),但是节省的成本(savings)是如此之小,以至于换来使代码更加复杂的成本是不值得的。实际上,这里使用useMemo 也可能会更糟,因为我们再次进行了函数调用,并且代码会执行属性赋值等。

在这种特定情况下,更好的办法是进行此更改:

+ const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
  function CandyDispenser() {
-   const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
    const [candies, setCandies] = React.useState(initialCandies)

但是有时候我们不这样费尽心思地做,因为这个值既不是衍生自props也不是函数体内初始化的其它变量。 关键是这两种方式无关紧要,这种优化代码的收益是如此小,你的时间应该更好地用在让你的产品更好。

关键点是什么?

关键是: 性能优化不是免费的。它总是需要付出代价,但是又不是总能带来收益去抵消代价。 因此,性能优化需要承担责任。

所以什么时候我应该用useMemo和useCallback

这里有两个原因,是这两个hooks内置在React中的原因:

  1. 引用相等(Referential equality)
  2. 昂贵的计算(Computationally expensive calculations)

1. 引用相等

如果你是JavaScript或者编程新手,你很快就会明白为什么会这样:

true === true // true
false === false // true
1 === 1 // true
'a' === 'a' // true
{} === {} // false
[] === [] // false
() => {} === () => {} // false
const z = {}
z === z // true
// NOTE: React actually uses Object.is, but it's very similar to ===

我不打算深入讲这些,但是这些例子足够说明,当你在你的React函数组件中定义一个对象时,它不会引用跟上一次相同的地址(即使它们拥有相同的属性、相同的值)。

在React中,有两种情况下引用相等(referential equality)很重要(matters),让我们一个个地来看。

依赖项

让我们看几个例子

注意,你将会看到一些严格的人为代码。不要杠,只需要关注概念,谢谢。

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

这段代码有问题的原因是useEffect在每一次render时,在对option做一个引用是否相等的检查,因为JavaScript的工作方式,options每次都是最新的(引用),因此React测试options是否在每次render中变化时,判断值始终是true,这意味着useEffect在每次render时都会调用。而不是只有两个属性值barbaz发生变化时才被调用。

下面两件事,可以修复这个问题:

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

这是个不错的选择,如果这是真的,我就会这么做。 但是在下面这种情况下,这样做时不切实际的:如果bar或者baz类型非基本类型,而为:objects/arrays/functions/等。

function Blub() {
  const bar = () => {}
  const baz = [1, 2, 3]
  return <Foo bar={bar} baz={baz} />
}

这就是useCallbackuseMemo存在的原因。因此你将如何修改?

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

请注意,同样的事情也适用于传递给 useEffect, useLayoutEffect, useCallback, 和 useMemo 的依赖项数组。

React.memo (and friends)

React.memo,与 PureComponent 类似,对传入组件的新旧数据进行浅比较,如果相同则不会触发渲染。

检查下面的代码:

function CountButton({onClick, count}) {
  return <button onClick={onClick}>{count}</button>
}
function DualCounter() {
  const [count1, setCount1] = React.useState(0)
  const increment1 = () => setCount1(c => c + 1)
  const [count2, setCount2] = React.useState(0)
  const increment2 = () => setCount2(c => c + 1)
  return (
    <>
      <CountButton count={count1} onClick={increment1} />
      <CountButton count={count2} onClick={increment2} />
    </>
  )
}

每次你点击这些按钮的其中一个,DualCounter的状态就会改变,组件重新渲染,2个CountButton也都重新渲染。然而理应只有一个(那个被点击的)发生重新渲染。现在情况是,当你点击第一个按钮,会引发第二个按钮re-render,尽管它没有任何改动。这种我们称之为“unnecessary re-render”。

大多数时候,你不需要考虑去优化不必要的重新渲染。因为React是非常快的。比起做这些类似的优化,我能想到很多你可以花时间去做的事情。事实上,我展示给你看的代码很少有优化的需求,以至于我在 PayPal 工作的3年里从未需要这样做,甚至在我使用 React 更长的时间里。 然而,有些情况下渲染可能会花费大量时间(比如重交互的图表、动画等)。多亏 React 的实用性,有一个逃生舱(escape hatch):

const CountButton = React.memo(function CountButton({onClick, count}) {
  return <button onClick={onClick}>{count}</button>
})

现在React仅会在CountButton的props发生改变时re-render!但是还没完。记得“引用相同”吗?在DualCounter这个函数组件中,我们定义了increment1 and increment2这两个function,这一位置,每次DualCounterre-rendered时,这两个方法都是新的。因此 React 无论如何会重新渲染两个 CountButton。

所以这就是另一种情况,可以借助useCallbackuseMemo解决:

const CountButton = React.memo(function CountButton({onClick, count}) {
  return <button onClick={onClick}>{count}</button>
})
function DualCounter() {
  const [count1, setCount1] = React.useState(0)
  const increment1 = React.useCallback(() => setCount1(c => c + 1), [])
  const [count2, setCount2] = React.useState(0)
  const increment2 = React.useCallback(() => setCount2(c => c + 1), [])
  return (
    <>
      <CountButton count={count1} onClick={increment1} />
      <CountButton count={count2} onClick={increment2} />
    </>
  )
}

现在我们避免了DualCounter的不必要重新渲染。

我想重申,我仍然强烈反对无限制地使用React.memo(或者它的朋友PureComponentshouldComponentUpdate),因为这些优化伴随着代价,你需要确保你知道这些代价以及同时带来的收益,这样你可以确定在实际例子中它是否真正有帮助(无害)。正如我们上面所说的那样,一直保持正确是一件很困难的事情,所以你可能无法获得任何好处。

2. 昂贵的计算

还有另一个useMemo内置在React的原因(注意这一点不适用于useCallback)。useMemo的好处是你可以通过它取值,像:

const a = {b: props.b}

懒获取:

const a = React.useMemo(() => ({b: props.b}), [props.b])

在上面的例子中这个发挥不了真正作用,但想象一下,你要获取一个function,是需要同步计算一个需要昂贵计算的值。(我的意思是有多少应用真实地需要像这样计算素数,但这就是一个例子):

function RenderPrimes({iterations, multiplier}) {
  const primes = calculatePrimes(iterations, multiplier)
  return <div>Primes! {primes}</div>
}

这段代码给定正确的iterationsmultiplier得出结果可能会很慢,而您对此无能为力。你不能自动地让用户硬件变快。但是你可以实现避免连续多次计算同一个数据,这就是useMemo能为你做到的。

function RenderPrimes({iterations, multiplier}) {
  const primes = React.useMemo(() => calculatePrimes(iterations, multiplier), [
    iterations,
    multiplier,
  ])
  return <div>Primes! {primes}</div>
}

可以这样做的原因是,即使定义了function在每次render时计算素数(计算非常快),React只在需要value的时候调用function。 除此之外,React还会在给定输入的情况下存储先前的值,并在给定跟之前相同输入的情况下返回先前的值。 这是memoization在起作用。

结论

综上所述,每一个抽象(以及性能优化)都伴随着代价。采用AHA编程法则,直到确实需要抽象或优化时才去做,这样可以避免承担了成本却不能获得收益的情况。

明确useCallbackuseMemo的代价是,

  1. 会让你的代码在你的协作者看来更复杂,
  2. 你可能会写一个错误的依赖列表,
  3. 通过调用内置hooks、防止依赖项和memoized值被垃圾回收,使代码性能变得更差了。 如果你获得了必要的收益,这些都是可能导致的代价都是可以承担的,但是最好是做之前先衡量。

我的收获

性能优化的两种思路

  1. 减少不必要渲染
  2. 减少昂贵的计算带来的消耗

useCallback

  1. 不能滥用React.useCallbackReact.useCallback包裹后的function不会被垃圾回收机制回收,同时直接创建一个新的引用。同时,React.useCallback所制造的闭包将保持对回调函数和依赖项的引用。因此滥用会导致内存负担重。创建React.useCallback本身就是性能消耗。

  2. React.useCallback的目标是:减少不必要重新渲染,而不用是解决组件内部函数多次创建的问题。React.useCallback可以保存一个function的引用。当dependent list不发生变化,该function的引用不会发生改变。这样的function作为参数传入函数组件,不会因为引用不相等,引起组件的不必要渲染。

  3. React.memo包裹后的函数组件,只有props发生变化,才会触发函数re-render。如果props重有引用类型数据,该prop可以传入之前使用React.useCallback包裹。因此React.memo + React.useCallback可以打个好配合,减少unnecessary re-render

  4. React.memo也不要滥用。只有当组件内部本身就比较复杂,重新渲染的代价很高时,再去考虑用React.memo包裹。

useMemo

  1. React.useMemo的目标是:解决昂贵的计算(比如成千上万次的计算)带来的性能影响。利用缓存的数据(记忆值)缓解复杂计算带来的性能问题。

  2. 不能滥用React.useMemo。如果计算不够复杂,React.useMemo自身带来的消耗没有必要。基于闭包,占用内存。