函数式组件
注意⚠️:一切性能优化,都需要基于实际测量。为避免过早优化,都需要在使用
useMemo前,使用React DevTools Profiler等工具确认存在性能瓶颈。
众所周知,函数式组件重新渲染(re-render)的本质,是重新执行一遍组件函数。相应的,我们定义在组件函数内部的各种变量或者方法会被重新定义一遍,如果在里面执行了方法,会被重新执行一遍。
在这个过程中,有些变量的定义是需要经过复杂且耗时的计算的,如无必要,我们自然不希望在重新渲染的过程中,去做一次重复的计算,而是在发生过一次计算后,在重新渲染的过程中复用上一次计算的结果。
这个时候,官方推荐我们使用useMemo。
当然,React Compiler 现在可以自动地做这些事情。
useMemo 的核心作用
前面提到,useMemo在组件重新渲染中,可以帮助我们避免进行不必要的计算,我们可以用一段代码来说明一下原理。
这是没有使用到useMemo的组件,我们可以看到变量z是通过x和y经过计算得到,因此,每次组件重新渲染的时候,都会进行一次计算。
function ComponentA() {
const [x, setX] = useState(0);
const [y, setY] = useState(1);
const z = x + y;
return <>{/* 这里是组件 */}</>;
}
而且不是每次重新渲染,都是因为x或者y发生了变化而发起的重新渲染。这里就有个问题,假如 z 的计算依赖 x 和 y,但是计算过程特别复杂特别久会怎么样?
答案自不必说,肯定每次重新渲染都会因为这个计算而耗费很多时间。因此我们会想着优化一下,就是除非x或者y变化,其他东西引起的重新渲染,都不要重新计算,继续让z沿用本来的值。
不用useMemo,要怎么写呢?
function ComponentA() {
const xRef = useRef(null);
const yRef = useRef(null);
const zRef = useRef(null);
const [x, setX] = useState(0);
const [y, setY] = useState(1);
let z;
if (!zRef.current || xRef.current !== x || yRef.current !== y) {
z = x + y;
zRef.current = z;
xRef.current = x;
yRef.current = y;
} else {
z = zRef.current;
}
return <>{/* 这里是组件 */}</>;
}
这样是不是就实现了缓存计算结果了呢?是的,这样就可以了,重新渲染的时候,会先比对一下再决定是不是要重新运算。
但是要写的是不是太多了?对,也太多了,多了几个 useRef,所以直接用useMemo就能解决问题啦。
function ComponentA() {
const [x, setX] = useState(0);
const [y, setY] = useState(1);
const z = useMemo(() => x + y, [x, y]);
return <>{/* 这里是组件 */}</>;
}
这里我们提一下,可以把useMemo理解为每次渲染时都会被调用的函数(当然,本质上是在调用 Hook,但可以先理解为函数调用)。其内部会先判断第二个参数依赖数组,和上一次执行时接收的依赖数组进行比较(这里涉及到useMemo的实现,可能有点绕,但只需要记住:useMemo 会缓存每次执行时的依赖数组和计算结果即可)。
在比较过程中,React 会将前后依赖项数组中的每个依赖项,通过Object.is()进行浅比较。如果发现有依赖项不相等,就会执行第一个参数计算过程,得到新的计算结果并返回。反之,如果所有依赖项都相等,则直接返回上一次缓存的计算结果。
使用场景
在前一节里,我用了x + y这种简单计算作为例子,其实是不太妥当的,因为这不是复杂运算。对非复杂运算使用useMemo,依赖数组的比较本身的开销甚至比运算要高,反而是得不偿失的。那么什么场景下才用到useMemo?
复杂运算
- 处理的数据量级很大
当需要处理成百上千条数据的数组(例:从 1000 条列表项中筛选符合 3 个条件的项、给 500 条数据批量计算衍生字段),如果不是因为其所依赖的变量变化而引起的重新渲染,却仍然触发了重新运算,会造成不必要的计算消耗,有造成卡顿的风险,那么我们就可以使用useMemo来规避这种风险。
- 处理的步骤繁琐
当需要处理像多层嵌套循环(例:双重 for 循环处理二维数组)、多次数据转换(先过滤 → 再排序 → 再格式化)等场景,我们也可以使用useMemo来缓存计算结果。
- 组件本身会频繁渲染
像一些表单的受控组件(例:input 组件),其值会经常变化,因此会导致组件经常重新渲染。在封装这种受控组件过程中,更是要对其他state进行慎之又慎的处理,如无必要甚至不要额外引进变量。
作为其他 hook 的依赖项/memo 组件的 props
- 其计算结果是引用类型(如数组、对象等)的同时,又作为一些 hooks(如
useEffect)的依赖项的时候,则更是需要考虑缓存计算结果了,因为每次运算得到的结果都是一个新的引用,即使内容相同,通过引用比较也会判定为不相等,导致依赖该值的 hooks 无法正确判断依赖是否真正变化。我举个例子。
function TestComponent() {
// 每次执行的时候,都会生成一个新的数组!
const a = [1, 2];
useEffect(() => {
console.log(a);
}, [a]);
return <></>;
}
上述例子中,每次组件重新执行,都会生成一个新的数组a,这样useEffect只能认为依赖项出现变化,尽管依赖项a前后两个数组的内容都一样,但每次创建的都是新的引用(reference),useEffect等一众有依赖项的 hooks 通过引用比较来判断依赖是否变化。
当然,useMemo本身的依赖项设置也是如此,如果依赖项本身是对象或数组,需要注意引用稳定性。即使对象内容没变,如果每次渲染都创建新的对象引用,useMemo也会认为依赖变化而重新计算。
所以类似这种场景,就需要使用useMemo了。
- 作为 props 传给一个
memo()包住的组件,这样的原理跟前面说的一样,useMemo可以保持引用稳定。对于引用类型,如果每次渲染都创建新对象,即使内容相同,memo()也会认为 props 发生了变化(因为memo()默认使用浅比较)。使用useMemo可以确保引用稳定,从而让memo()正确判断是否需要重新渲染。
不适用的场景
上面是一些适用场景,那么有什么不适用的场景呢?要知道useMemo本身也有开销:需要存储依赖数组和上一次的计算结果,并进行依赖比较。只有当计算开销明显大于这些开销时,使用 useMemo 才有意义。当计算开销比不上对比开销的时候,例如简单运算、简单的属性读取等,就没有使用useMemo的必要啦。
总结
这次我从作用的角度,简单聊了一下这个 hook。希望能给读者留下印象,在面临性能瓶颈时,可以在脑海里多一个方案、方向。
后续我会继续分享更多常用的 hook,希望能和读者们共同进步。