2020年4月

在原文:When to useMemo and useCallback 基础上扩展学习

Review

回顾一下useMemo和useCallback:

useMemo

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

返回一个记忆化值。

第一个参数是一个拥有一定计算量的,并且返回计算结果的生成函数;第二个参数是参与计算量的一些状态变量,可以理解成computed依赖项,只不过是显式的依赖。而useMemo返回这个计算量的缓存值,当依赖项没有产生变化时,从缓存中取计算结果,而不是重新执行计算量。

根据React的工作原理以及渲染流程,类组件会初始化实例,状态变更尝试复用实例,但函数组件每次都会执行用于获取rendered节点,因此props变化会使得函数组件函数体中的代码反复执行,如果有大计算量的代码片段,且props的变化可能不会引起计算量结果变化,这时候就需要缓存化操作来减轻计算量。

官方文档这里提到了不能在useMemo针对的计算量中执行一些副作用操作,比如启停定时器,应当使用useEffect,依赖useMemo返回的记忆值也可以做。另外,useMemo没有提供依赖项,则认为计算量需要在每次渲染时重新计算,理论上就和没有用useMemo一样。

你可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证

缓存系统一般来说为了保证空间效率,可能会选择放弃低热度的缓存,官方文档也提到了离屏组件中的缓存值,可能会被释放。

useCallback

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

返回一个记忆化回调函数

官方文档给出了useMemo和useCallback的关系:useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。显然,同样是针对计算量,useCallback缓存计算过程,什么时候决定计算量可能还需要业务逻辑决定。useMemo在依赖项变化时,自动执行计算量产生新的值,而useCallback在依赖项产生变化时,得到一个新的计算过程,需要执行的时候会按照新的计算过程进行计算。

当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

这个提示非常重要,在使用callback作为props向子组件传递时,往往面临着引用相当性问题,父组件因为props变化生成新的callback,然后传递给子组件,尽快这个callback内容并没有变化(产生闭包运输的值也没有变化),但对于子组件来说props是发生了变化,又会引起子组件的重新渲染。而useCallback产生的记忆化回调函数,仅在依赖项变化时产生新的回调,否则使用缓存值,这对于子组件来说引用没有变化,并不会引起多余的更新。

useMemo和useCallback的第二个参数只是指明了依赖项,useMemo的计算量和useCallback的callback都不会接受依赖项作为参数,也就是说计算量和callback不能得到新旧值,不过computed也并不需要旧值对吧。

从useMemo和useCallback的用法上,不难发现一个问题,如果不指明任何的依赖项,缓存会在每次更新时重新计算,传说中的“击穿”,这和不用缓存一样,而且还多了额外的匿名函数声明。所以useMemo和useCallback虽然能够作为性能优化的手段,但是还需要使用缓存的成本。

优化与代价

Demo场景

原文举了一个糖果提货机的例子:

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

先不谈优化,假如让你来编写类似的代码,你是否会使用useCallback:

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

像这样,将删除条目的动作缓存起来,并且注意这里回调中并没有读取外部数据,所以可以依赖项是空的。也就是说CandyDispenser组件重新渲染时,dispense总是新的。dispense被作为onClick的逻辑调用,那么你会看到onClick无论如何都会接受一个新的匿名函数,button也不是一个组件,没有减少更新的必要。因为原来给出了结论:不使用useCallback反而会更好。

useCallback会更糟?

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

使用useCallback可以认为是在原来的基础上增加了一步(内联函数一个匿名一个具名,这不会有太大差别)。在useCallback内部还有其他的操作,直观上来看确实会更多一些操作和消耗。不使用useCallback,函数组件每次重新渲染时,会声明新的dispense,而旧的被垃圾回收掉。但使用了useCallback,不仅要声明新的dispense,并且会把这个函数交给useCallback做缓存,在做缓存时,被缓存的callback和依赖项可能还会被内部持有引用以便进行比较,所以内存消耗上一定会有增加。

那么具有相同特性的useMemo是不是也会有更大的开销,比如将demo中的initialCandies进行缓存处理:

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

可以看到一个匿名内联函数用于返回一个字符串数组,这和直接声明一个字符串数组逻辑上几乎也没有区别,开销也是有的,尽管可以通过useMemo来lazy求值过程,但是似乎也没有那么必要。对于这些可能由props决定的值,或者仅仅是函数组价内部的一个上下文变量,不会再向下透传,那就放心大胆的声明使用吧,不用太操心因此产生的性能问题。

所以说性能优化总会带来一定的成本,有的时候成本会大于效果,就比如例子中的情况,useMemo和useCallback本身带来的时空消耗,是否能改善应用的计算量,需要在做优化的时候进行衡量。而这个过程往往是在整体逻辑确定的情况下,更容易分析上下关系带来的影响,所以在开发过程中,先大方的写起来,然后在考虑是不是有性能问题。

useMemo和useCallback的时机

其实从上面的例子可以看出,不管是initialCandies还是dispense,使用都在组件内部,并且并不依赖组件状态。用不用useMemo和useCallback都可以实现业务逻辑,且针对这个例子,带来的消耗可以忽略不计,但是如果是更大的应用,更复杂的组件关系,如何不带来过分的消耗使用useMemo和useCallback,可以先来了解这两个hooks针对的场景,或者说优化需要关注的点是什么。

了解react的工作原理可能会有助于帮助理解useMemo和useCallback。在应用中,react做的最多的事可能就是“Updating”,也就是更新应用状态。试想一个组件状态变更了,由于View-Model特性视图层也要产生相应的变化,那么直接点可能就是把组件卸载然后在按新的状态重新挂载组件。而挂载组件可以认为是求rendered元素的过程,这个过程可简单可复杂。并且组件等级越高,卸载再挂载的成本会越高,因此被更多了解的“diff”过程用于找到最小的应变更元素。本段的描述可能不完全精确,主要是表达一些分析要素。

那么来想想优化通常是做什么。比如老生常谈的防抖和节流,是为了减少频繁的运算,顺着这个思路,react应用优化也可以从减少运算入手,最多的运算就是diff寻找更新点,对于函数组件来说每次求rendered节点,函数体都会被重新执行一次。简化来看就是“对比”和“重新求值”产生了react的主要计算量。

对于diff来说,react已经设计了很好的算法解决寻找树形结构中的差异点,但是这些差异点到底是不是会产生真正变化的要素,成为优化的一个关注点。也就是说如何让react做出最有效的“比较”。

从比较说起

JavaScript语言的一个特性,拥有两种比较“==”和“===”,简单理解成弱相等和强相等,具体可以去看真值表。基础类型变量的比较比较简单,引用类型的比较就会对编程产生一些些影响:

let a = {
    name: "A"
}

console.log(a === {name: "A"})

从人类语言逻辑来说,期望这个返回是true,因为“长”的都一样,在实际业务中,可能都对应着一个展示name字段的html片段。在状态变化前后,应用中某个组件先后接到这样两个props,从程序的比较上会认为这是不同的,但是变成逻辑上视图层不会有什么变化,因此就会产生一个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,人为逻辑上是希望当bar变化或者baz变化时进行一个buzz操作,因为这两个变量任意一个产生变化,都会使options产生变化。但是实际上,无论从Blub传下来的bar和baz是否变化,Foo组件重新渲染时,options本身都是新的,正是因为引用比较,并不能正常的进行watch逻辑。不管实际渲染到DOM上是否会有相应的操作(毕竟从字符串层面来说却是没有重新渲染的必要),但是这个副作用已经实实在在执行了,这其实就是一个多余的计算量,如果buzz是个运算量很大的过程,这个消耗就更大了。

针对上面的情况,Blub向下透传的都是基本类型数据,Foo中单独依赖就好了:

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

如果Blub向下透传的不是基本类型,而是一个回调函数,或者一个数组:

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

那就又回到了起点,Blub每次重新渲染的时候,bar和baz都是新的引用,传下去内容虽然相同,但是对于useEffect来说引用不同仍然要执行这个副作用。向下透传回调或一个集合非常常见,比如子组件需要修改父组件的状态,可能会将state的setter通过props传递下去,又或者需要传递很多显示字段,根据参数设置的规则,通常会使用集合来减少形参,子组件依赖数据集合计算自己的显示内容,就可能出现不必要的重复工作量。

没必要的工作量

看到上面的例子,应该已经可以看到useCallback和useMemo的使用场景了,最直观的就是那些会向下透传的回调和引用类型数据。不过要注意的是,这个向下透传是指向子组件透传,对DOM元素绑定事件响应函数可以不去过多的考虑。

逻辑上想要比较内容,可以比较过程在react内部,确确实实比较的是引用。要解决这样的问题,要么提供自己的比较方式,像是在类组件中使用shouldComponentUpdate周期中进行自己的比较(比不好也挺尴尬),函数组件没有这样的过程,每次重新渲染都是函数的执行,因此就需要一个“第三方”系统,来让这些内容没变的东西保持对应的引用,也就是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.useCallback(() => {}, [])
  const baz = React.useMemo(() => [1, 2, 3], [])
  return <Foo bar={bar} baz={baz} />
}

将bar和baz置成一个缓存中的引用,并且这个引用期待他在依赖不变的情况下保持不变,也就是内容不变引用就不变,方便达成代码中的比较与你脑中的比较保持一致性(这句话并不严谨)。那么达成了这样的比较一致,引用不变依赖不变,useEffect的依赖也能保持你想要的比较。

React.memo

react提供一个顶层API,用于创建一个缓存的组件,先来看一下原文中的例子:

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

直白来说,子组件CountButton在响应用户操作时,会反向更新父组件的状态,点击一个按钮会导致父组件更新,然后重新渲染两个按钮。点第一个按钮,count1发生变化,但是count2并没有变,第二个按钮也被顺带着重新渲染了,这和之前讨论的内容不变的更新差不多,对于程序逻辑来说都是不必要的。这个例子只是为了说明什么样的更新是不必要的,仅针对这个例子这样做也不会产生太大的影响,可能代码会比优化后的更容易看懂。但是如果把小问题放到大规模里,可能依然会引起需要优化的问题,比如CountButton还有复杂的动画计算。

React.memo这个API接受一个组件,返回一个缓存化的组件,只有当组件接受的props发生变化时才会重新渲染。

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

不过回到比较的话题,上面的例子中向下透传一个回调,即使memo了,还是无法回避props变化的问题,此时useCallback也能起到减少不必要的渲染。不过还是尽量不要使用memo或者shouldComponentUpdate,因为会增加很多执行频繁的计算过程。

昂贵的计算

useMemo和useCallback都是用于解决引用比较产生的不必要的重新渲染问题,本质上都是通过让“内容一致”和“引用一致”对应起来实现的,大面上都是发生在子组件props比较时,但其实保证了引用一致,依赖计算这样的过程其实也可以优化。最常用的场景,依赖props产生局部状态。父组件透传一个数组到子组件,子组件要遍历衍生出自己需要的数据结构,这样的过程消耗相对其他的简单操作来说大得多。使用useMemo让计算过程更有保证的进行:

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

一方面能进行一个惰性计算,用到primes时才执行计算,另一方面也能在依赖项引用不变的情况下不再重新计算。同时缓存化提供的便利,也体现才输入相同时返回先前存储的结果。

总结

首先useMemo和useCallback是react针对函数组件提供的优化方式,这当然是可选的。是否使用优化取决于优化的成本,与优化收益的关系。开发过程中可能会产生不好的组件关系,产生不好的数据结构,这解决这些问题前,都不应当过早的进行优化。

原文提到的AHA编程原则:避免草率的抽象、宁可复制代码也不要错误的抽象(因为抽象通常是为了减少重复代码)和为了改变而优化。这个原则提示了优化之前应该做的事,以及优化应该在什么情况下进行。

useMemo和useCallback的成本:少量的内联函数和变量(这个影响可以忽略不计),机制内在的消耗,潜在的内存泄露,以及代码结构更加复杂。在使用之前,确定是否适用,判断是否会产生其他性能问题很重要。

其他

Hooks是否会因为创建内联函数而让渲染变慢

官方文档中Hooks FAQ有关于这个问题的解释:答案是否定的,闭包和类的性能差异在一般情况下并不显著。

相反的,hooks设计可能更高效:class相比函数来说开销大一些,比如继承关系,以及类组件需要额外的bind;在高级组件、render props和context代码库中,组件树小了。

类组件中shouldComponentUpdate遇到内联函数时可能会有比较问题,hooks体系提供了useCallback、useMemo和useReducer都是为了解决有关内联函数比较和引用比较。

React,内联函数与性能

原文可以阅读这里。内联函数与性能问题挂钩,主要是担心内联函数创建与垃圾回收的消耗,另外就是类组件中的shouldComponentUpdate问题。创建内联函数和回收可以认为不会产生性能问题,和bind对比的话,甚至bind消耗更多。

而shouldComponentUpdate在类组件中,作为一个关键环节,可以跳过不需要的diff,相当于用自己的比较方式来取代react的更新比较,这就有可能产生比原本diff消耗更高的代码。PureComponent则使用严格比较state和props的方式来决定更新。

从上面两个额外阅读来看,不用担心使用钩子时内联函数产生的影响,关注产生真正性能问题的部分。