关注 小贼先生,查看更多前端文章
Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
useCallback
和useMemo
是其中的两个 hooks,本文旨在通过解决一个需求,结合高阶函数,深入理解useCallback
和useMemo
的用法和使用场景。
之所以会把这两个 hooks 放到一起说,是因为他们的主要作用都是性能优化,且使用useMemo
可以实现useCallback
。
需求说明
先把需求拎出来说下,然后顺着需求往下捋useCallback
和useMemo
,这样更好理解为什么要使用这两个 hooks。
需求是:当鼠标在某个 dom 标签上移动的时候,记录鼠标的普通移动次数和加了防抖处理后的移动次数。[如图]:
技术储备
- 本文主要介绍
useCallback
和useMemo
,所以遇到useState
时就不做特殊说明了,如果对useState
还不了解,请参看官方文档。 - 该需求需要用到防抖函数,为方便调试,先准备一个简单的防抖函数(一个高阶函数):
function debounce(func, delay = 1000) {
let timer;
function debounced(...args) {
debounced.cancel();
timer = setTimeout(() => {
func.apply(this, args);
}, delay);
}
debounced.cancel = function () {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
}
return debounced
}
不合格的解决方案
根据需求,写出来组件大致会是这样:
function Example() {
const [count, setCount] = useState(0);
const [bounceCount, setBounceCount] = useState(0);
const debounceSetCount = debounce(setBounceCount);
const handleMouseMove = () => {
setCount(count + 1);
debounceSetCount(bounceCount + 1);
};
return (
<div onMouseMove={handleMouseMove}>
<p>普通移动次数: {count}</p>
<p>防抖处理后移动次数: {bounceCount}</p>
</div>
)
}
效果貌似是对的,在debounced
里打印日志看下:
function debounce(func, delay = 1000) {
// ... 省略其他代码
timer = setTimeout(() => {
// 在此处添加了一行打印代码
console.log('run-do');
func.apply(this, args);
}, delay);
// ... 省略其他代码
}
当鼠标在div
标签上移动时,打印结果[如图]:
我们发现,当鼠标停止移动后,run-do
被打印的次数,跟鼠标移动次数相同,这说明防抖功能并未生效。是哪里出问题了呢?
首先我们要清楚的是,使用debounce
的目的是通过debounce
返回一个debounced
函数(注意:此处是debounced
,而不是debounce
,下文同样要注意这个细节,否则意思就完全不对了),然后每次执行debounced
时,通过闭包内的timer
清掉之前的setTimeout
,达到一段时间不活动后执行任务的目的。
再来看看我们的Example
组件,每次Example
组件的更新渲染,都会通过debounce(setBounceCount)
生成一个新的debounceSetCount
,也就是每次的更新渲染,debounceSetCount
都是指向不同的debounced
,不同的debounced
使用着不同的timer
,那么debounce
函数里的闭包就失去了意义,所以才会出现截图中的情况。
但是,为什么bounceCount
的值看着像是进行过防抖处理一样呢?
因为debounceSetCount(bounceCount + 1)
在多次执行时,debounce
内的setTimeout
。setBounceCount
是在setTimeout
内执行的,也就是异步的,等待时间约是1000ms,而handleMouseMove
虽然是事件回调函数,但鼠标移动时,这个函数的执行间隔相比1000ms要短很多,最终使得bounceCount
参数值总是相同的,所以整个效果才像经过了防抖处理一样。
useCallback
我们使用useCallback
修改下我们的组件:
function Example() {
// ... 省略其他代码
// 相比之前的 Example 组件,我们只是增加了 useCallback hook
const debounceSetCount = React.useCallback(debounce(setBounceCount), []);
// ... 省略其他代码
}
这时再用鼠标在div
标签上移动时,效果跟我们的需求一致了,[如图]:
通过useCallback
,我们貌似解决了之前存在的问题(其实这里面还有问题,我们后面会说到)。
那么,useCallback
是怎么解决问题的呢?
看下useCallback
的调用签名:
function useCallback<T extends (...args: any[]) => any>(callback: T, deps: ReadonlyArray<any>): T;
// 示例:
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);
通过useCallback
的签名可以知道,useCallback
第一个参数是一个函数,返回一个 memoized 回调函数,如上面代码中的 memoizedCallback 。useCallback
的第二个参数是依赖(deps),当依赖改变时才更新 memoizedCallback ,也就是在依赖未改变时(或空数组无依赖时), memoizedCallback 总是指向同一个函数,也就是指向同一块内存区域。当把 memoizedCallbac 当作 props 传递给子组件时,子组件就可以通过shouldComponentUpdate
等手段避免不必要的更新。
当Example
组件首次渲染时,debounceSetCount
的值是debounce(setBounceCount)
的执行结果,因为通过useCallback
生成debounceSetCount
时,传入的依赖是空数组,所以Example
组件在下一次渲染时,debounceSetCount
会忽略debounce(setBounceCount)
的执行结果,总是返回Example
第一次渲染时useCallback
缓存的结果,也就是说debounce(setBounceCount)
的执行结果通过useCallback
缓存了下来,解决了debounceSetCount
在Example
每次渲染时总是指向不同debounced
的问题。
我们上面说过,这里面其实还有一个问题,那就是每次Example
组件更新的时候,debounce
函数都会执行一次,通过上面的分析我们知道,这是一次无用的执行,如果此处的debounce
函数里有大量的计算的话,就会很影响性能。
useMemo
看下使用useMemo
如何解决这个问题呢:
function Example() {
const [count, setCount] = useState(0);
const [bounceCount, setBounceCount] = useState(0);
const debounceSetCount = React.useMemo(() => debounce(setBounceCount), []);
const handleMouseMove = () => {
setCount(count + 1);
debounceSetCount(bounceCount + 1);
};
return (
<div onMouseMove={handleMouseMove} >
<p>普通移动次数: {count}</p>
<p>防抖处理后移动次数: {bounceCount}</p>
</div>
)
}
现在,每次Example
更新渲染时,debounceSetCount
都是指向同一块内存,而且debounce
只会执行一次,我们的需求完成了,我们的问题也都得到了解决。
useMemo
是怎么做到的呢?
看下useMemo
的调用签名:
function useMemo<T>(factory: () => T, deps: ReadonlyArray<any> | undefined): T;
// 示例:
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
通过useMemo
的签名可以知道,useMemo
第一个参数是一个 factory 函数,该函数的返回结果会通过useMemo
缓存下来,只有当useMemo
的依赖(deps)改变时才重新执行 factory 函数,memoizedValue 才会被重新计算。 也就是在依赖未改变时(或空数组无依赖时),memoizedValue 总是返回通过useMemo
缓存的值。
看到这里,相信细心的你也已经发现了,useCallback(fn, deps)
其实相当于 useMemo(() => fn, deps)
,所以在最开始我们说:使用useMemo
完全可以实现useCallback
。
特别注意
React 官方有这么一句话:
你可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证。将来,React 可能会选择“遗忘”以前的一些 memoized 值,并在下次渲染时重新计算它们,比如为离屏组件释放内存。先编写在没有 useMemo
的情况下也可以执行的代码 —— 之后再在你的代码中添加 useMemo
,以达到优化性能的目的。 查看原文
显然,我们的代码中,如果去掉useMemo
是会出问题的,对此,可能有人会想,改装下debounce
防抖函数就可以了,例如:
function debounce(func, ...args) {
if (func.timeId !== undefined) {
clearTimeout(func.timeId);
func.timeId = undefined;
}
func.timeId = setTimeout(() => {
func(...args);
}, 200);
}
// 使用 useCallback
function Example() {
// ... 省略其他代码
const debounceSetCount = React.useCallback((...args) => {
debounce(setBounceCount, ...args);
}, []);
// ... 省略其他代码
}
// 不使用 useCallback
function Example() {
// ... 省略其他代码
const debounceSetCount = changeCount => debounce(setBounceCount, changeCount);
// ... 省略其他代码
}
貌似去掉了useMemo
也能实现我们的需求,但显然,这是一种非常将就的解决方案,一旦遇到像修改前的debounce
这样的高阶函数就束手无策了。
那么,如果不使用useMemo
,你有什么好的解决方案呢,欢迎留言讨论。
关注 小贼先生,查看更多前端文章
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。