节流与防抖都是通过延迟执行,减少调用次数,来优化频繁调用函数时的性能。不同的是,对于一段时间内的频繁调用,防抖是 延迟执行 一次调用,节流是 延迟定时 多次调用。
前言
不知道有多少人,简单的写了防抖、节流函数,然后遇到在 react hook 里失效的情况。
失效的原因: 每次 render 时,内部函数会重新生成并绑定到组件上去。
解决方案:也很简单,使用 useCallback ,依赖传入空数组,保证 useCallback 永远返回同一个函数。
上面呢,算是这个文章的一个契机吧。
关于手写防抖和节流的思路,个人认为关键在于都是对 闭包 和 高阶函数 的应用,以这个为切入点去思考,手写的时候就不会脑子一片空白了。
防抖(debounce)
触发事件后在 n 秒内函数只能执行一次,如果在 n 秒内又触发了事件,则会重新计算函数执行时间。
初步
import { useCallback } from 'react';
/**
* 防抖hook
* @param func 需要执行的函数
* @param wait 延迟时间
*/
export function useDebounce<A extends Array<any>, R = void>(
func: (..._args: A) => R,
wait: number,
) {
let timeOut: null | NodeJS.Timeout = null;
function debounced(..._args: A) {
if (timeOut) {
clearTimeout(timeOut);
timeOut = null;
}
timeOut = setTimeout(() => {
fn.apply(null, _args);
}, wait);
}
return useCallback(debounced, []);
}
这可以用,但并不够好。想要进阶更高级的工程师,就需要将问题再想深一层,考虑到更复杂的情况,从而自身得到成长。
进阶版
- 首先想到的是要返回一个 Promise ,用来传递返回值。
- 其次考虑到异步的情况,增加 async。
- 最后是防抖化之后是否可以立即执行和取消,所以增加2个新函数。
import { useCallback } from 'react';
/**
* 防抖hook
* @param func 需要执行的函数
* @param wait 延迟时间
*/
export function useDebounce<A extends Array<any>, R = void>(
func: (..._args: A) => R,
wait: number,
) {
let timeOut: null | NodeJS.Timeout = null;
let args: A;
function debounce(..._args: A) {
args = _args;
if (timeOut) {
clearTimeout(timeOut);
timeOut = null;
}
return new Promise<R>((resolve, reject) => {
timeOut = setTimeout(async () => {
try {
const result = await func.apply(null, args);
resolve(result);
} catch (e) {
reject(e);
}
}, wait);
});
}
//取消
function cancel() {
if (!timeOut) return;
clearTimeout(timeOut);
timeOut = null;
}
//立即执行
function flush() {
cancel();
return func.apply(null, args);
}
debounce.flush = flush;
debounce.cancel = flush;
return useCallback(debounce, []);
}
关于防抖函数还有功能更丰富的版本,可以看下 lodash 的 debounce 函数
节流(throttle)
连续触发事件但是在 n 秒中只执行一次函数
节流函数的2种思路
时间戳:通过记录上次执行的时间戳, 和当前时间戳比较来判断是否已到执行时间 ,如果是则执行,并更新上次执行的时间戳。(问题在于:事件停止触发时无法执行函数)
定时器:如果已经存在定时器,则不执行方法,直到定时器触发后被清除,然后重新设置定时器。(问题在于:事件停止触发后必然会再执行函数)
整合版
把两个整合一下,根据场景、需求等来决定,最后是否需要事件停止触发后定时器执行函数。
/**
* 节流hook
* @param func 需要执行的函数
* @param wait 延迟时间
* @param isTimer 是否开启定时器响应事件结束后的回调
*/
export function useThrottle<A extends Array<any>, R = void>(
func: (..._args: A) => R,
wait: number,
isTimer: boolean = false,
) {
let timeOut: null | NodeJS.Timeout = null;
let args: A;
let agoTimestamp: number;
function throttle(..._args: A) {
args = _args;
if (!agoTimestamp) agoTimestamp = +new Date();
if (timeOut) {
clearTimeout(timeOut);
timeOut = null;
}
return new Promise<R>((resolve, reject) => {
if (+new Date() - agoTimestamp >= wait) {
try {
const result = func.apply(null, args);
resolve(result);
agoTimestamp = +new Date();
} catch (e) {
reject(e);
}
} else if (isTimer) {
timeOut = setTimeout(async () => {
try {
const result = await func.apply(null, args);
resolve(result);
agoTimestamp = +new Date();
} catch (e) {
reject(e);
}
}, agoTimestamp + wait - +new Date());
}
});
}
//取消
function cancel() {
if (!timeOut) return;
clearTimeout(timeOut);
timeOut = null;
}
//立即执行
function flush() {
cancel();
return func.apply(null, args);
}
throttle.flush = flush;
throttle.cancel = flush;
return useCallback(throttle, []);
}
最后
有个地方有人可能有疑问,为什么没去用 useRef 去保存 timeOut 呢?
有人可能会认为这会有问题:因为每次组件重新渲染,都会执行一遍所有的 hooks,这样 useDebounce 高阶函数里面的 timeOut 就不能起到缓存的作用(在 useDebounce 里 console.log(timeOut),每次 render 时都打印出 null)。所以 timeOut 不可靠,防抖的核心就被破坏了。
但是呢,如果你在里面的函数 debounce 里 console.log(timeOut) 会发现,打印出来的,就是之前的 timeOut ,所以是没问题的。
最后,写的过程中,ts 才是我真正花费时间思考的地方。完成后,有点微妙的满足感。