節流與防抖都是通過延遲執行,減少調用次數,來優化頻繁調用函數時的性能。不同的是,對于一段時間內的頻繁調用,防抖是 延遲執行 一次調用,節流是 延遲定時 多次調用。
前言
不知道有多少人,簡單的寫了防抖、節流函數,然后遇到在 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 才是我真正花費時間思考的地方。完成后,有點微妙的滿足感。