何时使用Memo和使用Callback(附代码示例)

155 阅读11分钟

这里有一个糖果分配器。

糖果分配器

可用的糖果

  • 士力架
  • 斯基托尔糖
  • 特力克
  • 奶糖

下面是它的实施方式:

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>
  )
}

现在我想问你们一个问题,我希望你们在前进之前认真思考一下。我要对这个进行修改,我希望你能告诉我哪个会有更好的性能特点。

我唯一要改变的是将dispense 函数包裹在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 ,以提高性能,而且 "内联函数可能对性能有问题",那么不使用 useCallback ,怎么会更好呢?

从我们的具体例子中退一步,甚至从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 ,而这个数组本身就是在设置属性/运行逻辑表达式等。

所以在这两种情况下,JavaScript必须在每次渲染时为函数定义分配内存,而且根据useCallback 的实现方式,你可能会得到更多的函数定义分配(实际上不是这样的,但观点仍然成立)。这就是我在这里的twitter投票所要表达的意思。

假设这段代码出现在React函数组件中,在每次渲染时,这段代码发生了多少次函数分配? const a = () => {} 而这段代码发生了多少次? const a = useCallback(() => {}, [] )

22 55 154

当然,有几个人告诉我这句话的措辞很差,所以如果你得到了错误的答案,但实际上知道正确的答案,我很抱歉。

我还想提一下,在组件的第二次渲染时,原来的dispense 函数会被垃圾回收(释放内存空间),然后创建一个新的函数。然而,在useCallback ,原来的dispense 函数不会被垃圾回收,而会创建一个新的,所以从内存的角度来说,你的情况也会更糟。

与此相关的是,如果你有依赖关系,那么React很有可能挂在以前的函数的引用上,因为记忆化通常意味着我们保留旧值的副本,以便在我们得到与以前相同的依赖关系时返回。特别精明的人会注意到,这意味着React也必须为这个平等检查保留对依赖关系的引用(顺便说一下,由于你的闭包,这可能正在发生,但无论如何这是值得一提的)。

useMemo 有什么不同,但又相似?

useMemo 除了允许你对任何值类型(不仅仅是函数)应用记忆化之外, 是类似的。它通过接受一个返回值的函数来做到这一点,然后该函数useCallback 在需要检索该值时才被调用(这通常只发生在每次依赖关系数组中的元素在渲染之间变化时)。

因此,如果我不想在每次渲染时都初始化那个initialCandies ,我可以做这样的改变:

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

我可以避免这个问题,但节省的费用是如此之少,以至于使代码更加复杂的代价是不值得的。事实上,使用useMemo 可能会更糟糕,因为我们又一次进行了一个函数调用,而且该代码正在进行属性赋值等。

在这种特殊情况下,最好的办法是进行这种改变:

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

但有时你没有这种奢侈,因为这个值要么来自props ,要么来自在函数主体中初始化的其他变量。

关键是,无论哪种方式都不重要。优化这些代码的好处是微乎其微的,你的时间最好花在担心如何使你的产品更好。

什么是重点?

重点是这个。

性能优化不是免费的,它们总是伴随着成本,但并不总是伴随着利益来抵消成本。

因此,要负责任地进行优化

那么,我应该在什么时候useMemouseCallback

这两个钩子被内置到React中是有特殊原因的:

  1. 参照平等
  2. 计算成本高的计算

参照平等

如果你是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中,有两种情况下参考性平等是很重要的,让我们一个一个来看看。

依赖关系列表

让我们回顾一个例子。

警告,你将会看到一些严重伪造的代码。请不要吹毛求疵,只关注概念,谢谢:

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 将在每次渲染之间对options 进行引用平等检查,由于 JavaScript 的工作方式,options 每次都是新的,所以当 React 测试options 在两次渲染之间是否有变化时,它总是评估为true ,这意味着useEffect 回调将在每次渲染之后被调用,而不是只有在barbaz 变化时被调用。

有两件事我们可以做来解决这个问题:

// option 1
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>
}

这是一个很好的选择,如果这是一个真实的事情,我也会这样解决。

但有一种情况下,这不是一个实用的解决方案。如果barbaz 是(非原始的)对象/数组/函数/等等。

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 (和朋友)

警告,你将会看到一些更多的臆造的代码。请你也不要吹毛求疵,而要把注意力放在概念上,谢谢。

看看这个吧。

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's的状态就会发生变化,从而重新渲染,这反过来又会重新渲染CountButton's。然而,唯一真正需要重新渲染的是被点击的那个,对吗?因此,如果你点击了第一个,第二个就会被重新渲染,但没有任何变化。我们把这称为 "不必要的重新渲染"。

**大多数情况下,你不需要优化不必要的重新渲染。**React非常快,而且我可以想到有很多事情可以让你利用你的时间去做,这比优化这样的事情要好。事实上,需要用我要告诉你的东西来优化是非常罕见的,在我为PayPal产品工作的3年里,以及在我使用React的更长时间里,我实际上从来没有需要这样做。

然而,在有些情况下,渲染会花费大量的时间(想想高度互动的图形/图表/动画/等等)。多亏了React的实用主义性质,才有了一个逃生舱口。

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

现在,React只会在其道具改变时重新渲染CountButton 。呜!但我们还没有完成。还记得那整个参考性平等的事情吗?在DualCounter 组件中,我们在组件函数中定义了increment1increment2函数,这意味着每次DualCounter 被重新渲染时,这些函数将是新的,因此React将重新渲染这两个CountButtons。

因此,这也是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} />
    </>
  )
}

现在我们可以避免所谓的 "不必要的重新渲染 "的CountButton

我想再次重申,我强烈建议不要在没有测量的情况下使用React.memo (或者它的朋友PureComponentshouldComponentUpdate ),因为这些优化是有代价的,你需要确保你知道这个代价是什么,以及相关的好处,这样你才能确定它在你的情况下是否真的有帮助(而不是有害),而且正如我们上面观察到的**,要一直正确是很困难的,所以你可能根本就没有收获任何好处。**

昂贵的计算

这是useMemo 是React的一个内置钩子的另一个原因(注意,这个不适用于useCallback )。useMemo 的好处是,你可以取一个值,比如。

const a = {b: props.b}

并懒洋洋地得到它。

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

这对上面的情况并不真正有用,但想象一下,你有一个函数,同步计算一个计算成本很高的值(我是说有多少应用程序真正需要计算这样的素数,但这是一个例子)。

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>
}

这样做的原因是,尽管你在每次渲染时都定义了计算素数的函数(这非常快),但React只在需要该值时才调用该函数。除此之外,React还存储了以前的输入值,并在给定相同的输入时返回以前的值。这就是记忆化的作用。

总结

我只想总结一下,每一个抽象(和性能优化)都是有代价的。应用AHA编程原则,等到抽象/优化在你面前大喊大叫的时候再去应用,你就可以避免自己在没有收获好处的情况下产生成本。

具体来说,useCallbackuseMemo 的代价是,你让你的同事觉得代码更复杂了,你可能在依赖关系数组中犯错,而且你有可能通过调用内置钩子和防止依赖关系和备忘值被垃圾回收而使性能变差。如果你能获得必要的性能优势,这些都是可以承担的成本,但最好还是先测量一下。

P.S. 如果你是少数几个担心向钩子迁移的人,担心它迫使我们在我们的函数组件中定义函数,而我们以前是在我们的类上将函数定义为方法,那么我想请你考虑一个事实,即我们从第一天起就在我们组件的渲染阶段定义了方法...比如说。

class FavoriteNumbers extends React.Component {
  render() {
    return (
      <ul>
        {this.props.favoriteNumbers.map(number => (
          // TADA! This is a function defined in the render method!
          // Hooks did not introduce this concept.
          // We've been doing this all along.
          <li key={number}>{number}</li>
        ))}
      </ul>
    )
  }
}