Hooks-useCallback和useMemo

记忆函数

在开发实践中,有一种优化手段叫做记忆函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function summation(target) {
let sum = 0;
for (let i = 1; i <= target; i++) {
sum += i;
}
console.log('sum: ', sum);
return sum;
}

summation(10);
summation(100);
summation(100);
summation(100);
summation(100);

这是一个求和函数,每次执行函数都会重新计算结果。当我们重复调用summation(100)时,内部的循环计算是不是有点冗余?因为传入相同的参数,必定得到相同的结果,因此如果传入参数一样,是不是可以不再重复计算直接使用上次的计算结果呢?

是的,利用闭包能够实现我们的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// memoFn.jsx
// 初始化一个非正常数字
let preTarget = -1;
let memoSum = 0;

export function memoSummation(target) {
// 传入参数与上次一样,直接返回缓存结果
if (prevTarget > 0 && prevTarget === target) {
return memoSum;
}

// 缓存本次传入的参数
prevTarget = target;
let sum = 0;
console.log('我出现,表示又重新计算了一次');
for (let i = 1; i <= target; i++) {
sum += i;
}
// 缓存本次计算结果
memoSum = sum;
return sum;
}
1
2
3
4
5
6
7
8
9
10
11
import { memoSummation } from './memoFn';

memoSummation(10);
memoSummation(50);
memoSummation(100);
memoSummation(50);
memoSummation(100);
memoSummation(100);
memoSummation(100);
memoSummation(100);
memoSummation(100);

'我出现,表示又重新计算了一次'只打印了 5 次。

memoFn.jsx模块用伪代码表示,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const memoFn = (function () {
let prevTarget = -1;
let memoSum = 0;
return function memoFn(target) {
// 传入参数与上次一样,直接返回缓存结果
if (prevTarget > 0 && prevTarget === target) {
return memoSum;
}

// 缓存本次传入的参数
prevTarget = target;
let sum = 0;
console.log('我出现,表示又重新计算了一次');
for (let i = 1; i <= target; i++) {
sum += i;
}
// 缓存本次计算结果
memoSum = sum;
return sum;
};
})();

const memoSummation = memoFn;

hooks 中的记忆函数
hooks 提供的 api 中,大多都有记忆功能,例如:

  • useState
  • useEffect
  • useCallback
  • useMemo
  • useLayoutEffect
  • useReducer
  • useRef

这里主要讲useCallbackuseMemo

useMemo

useMemo 缓存计算结果。它接收两个参数,第一个参数是回调函数(返回计算结果),第二个参数是依赖项(数组),当依赖项中某一项发生变化时,结果将会重新计算。
const memorizedValue = useMemo(() => {}, deps)

useCallback

useCallback 缓存函数。 他的使用跟useMemo几乎一样,当依赖项中的某一个发生变化时,返回一个新函数。
const memorizedFn = useCallback(() => {}, deps)

伪代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* useCallback 简易实现(不考虑多个useCallbacks的情况)
*/
let memoizedState = [];
let hookIndex = 0;
function useCallbacks(callback, nextDeps) {
// debugger
const prevState = memoizedState[hookIndex];

if (prevState) {
// 更新
let [prevCallback, prevDeps] = prevState;

// 对比依赖项
let same = nextDeps.every((item, index) => item === prevDeps[index]);

// 依赖项相同
if (same) {
console.log('返回旧函数');
return prevCallback;
}
}

// 首次挂载时 或者 更新时但依赖项不同,都返回新函数
memoizedState[hookIndex] = [callback, nextDeps];
console.log('返回新函数');
return callback;
}

export default useCallbacks;

useCallback 和 useMemo 的区别

  • useCallback 缓存的是callback函数本身,useMemo 缓存的是callback函数的计算结果,也可以是一个函数
  • 都是利用闭包缓存计算结果。只有在函数或计算的过程非常复杂时才考虑使用
  • useCallback/useMemo 可以用来优化子组件和当前组件。优化子组件时,可以防止子组件没必要的重复渲染,useCallback 需要配合 React.memo 一起使用,useMemo 不需要;优化当前组件时,useCallback 主要用于某个会多次 re-render 组件中的没有依赖项的函数,useMome 主要用于缓存复杂的计算逻辑。
  • useCallback(fn, deps) 相等于 useMemo(() => fn, deps)

是否需要优化

通过记忆函数的原理,我们应该知道,创建记忆函数并不是没有代价的,我们需要创建闭包,占用更多的内存,用以解决计算上的冗余

对于一个函数组件来说,我们在内部会创建许多函数,是否都有必要使用 useCallback 呢?思考下面代码

useCallback 是否有必要包裹 setCount 函数?

1
2
3
const memoAdd = useCallback(() => {
setCount(count++);
}, [count]);

存在依赖项时,如果 memoFn 不作为 props 传给子组件,仅做一个缓存函数的作用,这样是不是也有达到优化效果?

答案是没必要。创建函数的消耗很小,依赖项的对比反而有一定的性能开销。

当一个函数执行完毕,就会从函数调用栈被弹出,里面的内存也会被回收。对于函数组件来说也一样,当内部函数执行完毕后也会被释放掉。所以函数式组件的性能是非常快的。
而当我们使用 useCallback 时,由于新增了对闭包的使用,新增了依赖项的对比逻辑,如果盲目使用他们,可能会让组件变得更慢。大多数情况下你不需要使用 useCallback/useMemo。

那么,什么时候使用 useCallback 比较合适呢?

适用场景

函数或计算的过程非常复杂时,才优先考虑使用useCallback/useMemo

当函数(或子组件)非常复杂时

默认情况下,父组件的重新渲染会导致子组件也重新渲染。如果子组件的 props 未发生变化时,子组件就没有重新渲染的必要。

所以当函数非常复杂时,我们可以将 useCallbak 的返回值”缓存函数”传给子组件,然后配合子组件使用 React.memo 解决子组件不必要的渲染问题。

案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import React, { useState, useCallback, useMemo } from 'react';
import Button from './Button';

export default function UseCallback() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const [count3, setCount3] = useState(0);

// 未使用useCallback的情况下,handleClickButton1 函数引用每次都会变化
// 这会破坏子组件 memo 效果
const handleClickButton1 = () => {
setCount1(count1 + 1);
};

// 缓存函数:只有在依赖项发生改变时,才返回新的函数,否则返回缓存函数
// 子组件发现onClickButton属性没发生改变,就不会重新渲染,从而达到优化效果

// 使用useCallback,如果 count2 不变,handleClickButton2 引用不变
const handleClickButton2 = useCallback(() => {
setCount2(count2 + 1);
}, [count2]);

return (
<div>
<h1>未使用useCallback</h1>
<div>
<Button onClickButton={handleClickButton1}>Button1</Button>
</div>
<div>
<Button onClickButton={handleClickButton2}>Button2</Button>
<p>Button2 上的on函数使用了useCallback</p>
</div>
<div>
<Button
onClickButton={() => {
setCount3(count3 + 1);
}}
>
Button3
</Button>
</div>
</div>
);
}

子组件要配合 React.memo 一起使用。
Button 子组件,假设这个组件非常复杂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Button.jsx
import React from 'react';

// 假设这个组件非常复杂
const Button = ({ onClickButton, children, count }) => {
return (
<>
<button onClick={onClickButton}>{children}</button>
<span>{Math.random()}</span>
{count && <span>count: {count}</span>}
</>
);
};

// 注意,useCallback一定要配合React.memo一起使用,否则就是反向优化
export default React.memo(Button);

会多次re-render的组件中,且函数没有任何依赖时

如果某组件一定会多次re-render,且函数没有任何依赖,可以考虑使用 useCallback 降低多次执行带来的重复创建同样方法的负担。

1
2
3
4
5
<input type="text" onChange={inputChange} />;

const inputChange = useCallback((e) => {
setValue(e.target.value);
}, []);

同样的场景,如果函数只会渲染一次,那么使用 useCallback 就完全没必要。

闭包带来的问题

通过前面的例子可以知道,handleClickButton2缓存函数仅在 count不变时保持稳定。
如果想要保持handleClickButton2引用一直稳定,要把依赖项移除,用空数组作为参数,这会导致访问到的count总是初始值,逻辑上引发了更大的问题,也就是闭包问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React, { useState, useCallback, useMemo } from 'react';
import Button from './Button';

export default function UseCallback() {
const [count4, setCount4] = useState(0);

// 缓存函数引用保持稳定时状态不更新问题
const stableClickFn = useCallback(() => {
console.log(count4);
// 访问到的总是初始值
setCount4(count4 + 1);
}, []);

return (
<div>
<h1>缓存函数引用保持稳定时状态不更新问题</h1>
<Button onClickButton={stableClickFn}>stable button4</Button>
<div>count 只会更新一次</div>
<div>count4: {count4}</div>
</div>
);
}

想要解决这个问题,可以看hooks 闭包陷阱中的解决办法。

react 团队为了解决这个问题,准备引入useEvent,它具备以下属性:

  • 每次重新渲染时都不会重新创建该函数
  • 该函数将可以访问 props/state 的最新值

总结

useCallback/useMemo的作用在于利用记忆函数减少无效的re-render,来达到性能优化的作用。记忆函数的原理,是创建闭包,但创建闭包是有代价的,会占用更多的内存,用以解决计算上的冗余。所以我们需要根据场景来权衡是否有必要使用useCallback/useMemo

参考:
https://zhuanlan.zhihu.com/p/56975681
https://mp.weixin.qq.com/s/MnkycB8D9kCRrsXaLBfgXg

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×