解读 useMemo, useCallback 和 React.memo,不再盲目做优化

·  阅读 3602

任何刚开始写 react 组件的工程师应该都能发现,组件会一直重新渲染。

如果你还没有接触过 React,或者还没发现上述的现象,那么就跟着下面的思想实验继续看下去吧。

思想实验

已知信息

  • React 组件中 props 或 state 发生变化,会触发重新渲染。
  • props 或 state 可以是基本类型,也可以是引用类型。
  • 当主线程被阻塞时,页面渲染所以会卡顿。

那么,我们可以推导出如下结论:

  1. 整体上多余的重新渲染必然发生。
  2. 如果存在优化的空间,那么一定是长时间阻塞主线程的任务。
    • 渲染任务(不必要的重复渲染阻塞了其他渲染任务)
    • 渲染任务之外的计算任务(阻塞了渲染任务)

何时做优化

很多有经验的工程师会告诉你,重新渲染并没有什么大不了的。

事实上,确实如此,进行不加思考的开盲盒式优化,往往会带来反向的收益。

性能优化没有银弹,任何的优化都应当建立在某些可以量化的指标之上。

如何做优化

简明 memoization 含义与 Hooks 逻辑

什么是 memoization?

  1. memoization 是一种用空间换时间的优化方法。
  2. 把计算结果缓存起来,取用前需通过校验,取出后实现复用。
  3. 保持返回值的引用相等。

几乎所有有依赖数组的 hooks 都共享同一套基础逻辑。

  1. 组件初次渲染时,执行一次。
  2. 组件重新渲染时,通过浅比较检查依赖数组有没有变化。如果没有,不重复执行。

理解 React 中的浅比较

本文大部分内容都依赖浅比较的概念,请务必完全理解,再继续读下去。

首先我们要知道什么是基本类型和引用类型,这方面的文章够多了,就不赘述了。比方说,函数是引用类型,重新创建函数,引用地址会改变。

function factory() {
    return (a, b) => a + b
}
const sum1 = factory()
const sum2 = factory()

sum1 === sum2 // => false
sum1 === sum1 // => true
复制代码

通过源码我们可以知道,React 中浅比较的实现是以 Object.is 为基础,增加了对象第一层的属性与值的比较。

简单示例(不含基本类型):

shallowCompare({}, {}) // => true
shallowCompare([], []) // => true
shallowCompare({ a: 1, b: 2 }, { a: 1, b: 2 }) // => true
shallowCompare({ a: 1, b: 2 }, { a: 1, c: 2 }) // => false
shallowCompare({ a: 1, b: { 2 }}, { a: 1, b: { 2 }}) // => false

// 上一个片段中的 sum1 与 sum2
shallowCompare(sum1, sum2) // => false
shallowCompare(sum1, sum1) // => true
复制代码

引用类型为什么是个大麻烦

上文提到,hooks 通过依赖数组判断是否在重渲染时再次执行,想象一下往依赖数组里塞了一个引用类型会发生什么吧。

答案是无法通过浅比较检查,hooks 就不可避免在每次重渲染时重新执行,这可不是什么好事情。

引用类型还会带来的更多问题,我们需要通过 memoization 的思想,来保持引用不变。

缓存组件内部的函数返回值

首先让我们想想为什么要做这件事。

思想实验的结论告诉我们,可以通过优化长时间阻塞主线程的任务,来达到不阻塞渲染的目的。

那么,让我们来介绍一下 useMemo 吧。

const memoizedResult = useMemo(computeFn, dependencies)
复制代码

用法非常简单,只是在 hooks 逻辑里加上 memoization 的思想就可以了。

  1. 组件初次渲染时,执行一次 computeFn,把函数返回值缓存起来。

  2. 组件重新渲染时,通过浅比较检查依赖数组 dependencies 有没有变化。如果没有,不重复执行 computeFn,而是直接返回之前缓存的结果。

那么 useMemo 何时使用呢?请先进行如下判断。

  1. 检查是否是 computeFn 导致了页面的卡顿。

  2. 检查 computeFn 是否需要跟随组件渲染执行。

  3. 检查 computeFn 本身是否有优化空间。

  4. 如果以上两点都无法做到,最后考虑使用 useMemo。

useMemo 不只是缓存了函数的返回值,同时保证了返回值的引用地址不变。这个非常重要。

缓存组件内部的回调函数

useCallback 与 useMemo 有些许不同,缓存的是函数本身以及它的引用地址,而不是返回值。

当然了,理解了这一点,我们可以很轻松的用 useMemo 模拟一个 useCallback 出来。

useCallback(callBackFn, deps)
useMemo(() => callBackFn, deps)
复制代码

但先别急着给每个回调函数都套上 useCallback,这样就掉进了盲目优化的陷阱。

可以思考一下这个例子。

// 不使用 useCallback
const handleLog = (message) => console.log(message)

// 使用 useCallback
const handleLogMessage = useCallback(handleLog, [message])
复制代码

代价是什么呢?使用 useCallback 要对依赖数组做浅比较,对性能带来的负面影响,同时又提升了代码的复杂度。如果使用不当,很可能得不偿失。

只有思考清楚为什么要保持回调函数的引用地址不变,才能理解这个 hooks 的真正价值。让我们接着看下去吧。

缓存组件的渲染结果

目的是为了优化性能,而不是阻止渲染。

主线程被阻塞的另一个原因可能来源于渲染,即上一次重复渲染还没有完成,又触发了新的重复渲染。

如果能够把组件的渲染结果缓存,并有效复用,就能够减少主线程的阻塞。这就是 React.memo,React 自带的高阶组件。

React.memo 的思想是,当 props 没有改变时,组件就不需要重新渲染。

const MemoComponent = React.memo(Component)
const MemoComponent = React.memo(Component, areEqual)
复制代码

与 Hooks 的依赖数组相同,检查 props 是否相同,是通过浅比较实现的。除了默认情况,如果有复杂的对象类型,也可以自己写一个比较函数 areEqual,需要作为第二个参数传入。

看到这里,结合浅比较的知识,不难发现如果 props 中存在回调函数或者多层嵌套的复杂对象,那么只用 React.memo 是无法达到目的的,需要自己写比较函数,或者搭配 useCallback 使用。

const propsExample = {
    title: '标题',
    content: [
        { body: '内容', extra: '附加内容' },
    ],
    onClick: () => {},
}

function Item({ title, content, onClick }) {
    return (
        <div>
        // ...
        </div>
    )
}

const MemoItem = React.memo(Item, areEqual)

const areEqual = (prevProps, nextProps) => {
    return prevProps.title === nextProps.title &&
           // 浅比较无能为力
           prevProps.content[0].body === nextProps.content[0].body &&
           prevProps.content[0].extra === nextProps.content[0].extra &&
           // 需要 useCallback 保持引用地址,避免 React.memo 失效
           prevProps.onClick === nextProps.onClick
}
复制代码

即使是非常简单的组件,只要 props 稍微复杂一点,都会带来巨大的代码复杂度提升。如果比较函数书写有误,往往会带来很难排查的 bug。这就是使用 React.memo 的代价。

那么,何时使用 React.memo 呢?

  1. 检查组件是否是 Pure 的,即相同输入,相同输出。
  2. 检查组件是否经常被相同的 props 重复渲染,且导致了性能问题。
  3. 如果 props 中有回调函数,可以考虑搭配使用 useCallback 使用。

总结

看了 Dmitri Pavlutin 大师的三篇文章后,心有所感,记录下我的使用经验和心得。各位大佬关于 useMemo, useCallback 和 React.memo 的使用方式,实践中如果有什么好的看法,欢迎在评论区指出,希望不吝提出批评意见。链接放在最后,有兴趣的小伙伴自行取用原文。

参考资料

dmitripavlutin.com/react-useme…

dmitripavlutin.com/dont-overus…

dmitripavlutin.com/use-react-m…

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改