关注 小贼先生,查看更多前端文章
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
使得 bounceCount
参数值是相同的,所以通过 run-do
的打印次数才把问题暴露了出来。
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
,你有什么好的解决方案呢,欢迎留言讨论。
关注 小贼先生,查看更多前端文章
参考文档
- 官方文档