任何刚开始写 react 组件的工程师应该都能发现,组件会一直重新渲染。
如果你还没有接触过 React,或者还没发现上述的现象,那么就跟着下面的思想实验继续看下去吧。
思想实验
已知信息
- React 组件中 props 或 state 发生变化,会触发重新渲染。
- props 或 state 可以是基本类型,也可以是引用类型。
- 当主线程被阻塞时,页面渲染所以会卡顿。
那么,我们可以推导出如下结论:
- 整体上多余的重新渲染必然发生。
- 如果存在优化的空间,那么一定是长时间阻塞主线程的任务。
- 渲染任务(不必要的重复渲染阻塞了其他渲染任务)
- 渲染任务之外的计算任务(阻塞了渲染任务)
何时做优化
很多有经验的工程师会告诉你,重新渲染并没有什么大不了的。
事实上,确实如此,进行不加思考的开盲盒式优化,往往会带来反向的收益。
性能优化没有银弹,任何的优化都应当建立在某些可以量化的指标之上。
如何做优化
简明 memoization 含义与 Hooks 逻辑
什么是 memoization?
- memoization 是一种用空间换时间的优化方法。
- 把计算结果缓存起来,取用前需通过校验,取出后实现复用。
- 保持返回值的引用相等。
几乎所有有依赖数组的 hooks 都共享同一套基础逻辑。
- 组件初次渲染时,执行一次。
- 组件重新渲染时,通过浅比较检查依赖数组有没有变化。如果没有,不重复执行。
理解 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 的思想就可以了。
-
组件初次渲染时,执行一次
computeFn
,把函数返回值缓存起来。 -
组件重新渲染时,通过浅比较检查依赖数组
dependencies
有没有变化。如果没有,不重复执行computeFn
,而是直接返回之前缓存的结果。
那么 useMemo 何时使用呢?请先进行如下判断。
-
检查是否是
computeFn
导致了页面的卡顿。 -
检查
computeFn
是否需要跟随组件渲染执行。 -
检查
computeFn
本身是否有优化空间。 -
如果以上两点都无法做到,最后考虑使用 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 呢?
- 检查组件是否是 Pure 的,即相同输入,相同输出。
- 检查组件是否经常被相同的 props 重复渲染,且导致了性能问题。
- 如果 props 中有回调函数,可以考虑搭配使用 useCallback 使用。
总结
看了 Dmitri Pavlutin 大师的三篇文章后,心有所感,记录下我的使用经验和心得。各位大佬关于 useMemo, useCallback 和 React.memo 的使用方式,实践中如果有什么好的看法,欢迎在评论区指出,希望不吝提出批评意见。链接放在最后,有兴趣的小伙伴自行取用原文。
参考资料
dmitripavlutin.com/react-useme…