手寫防抖函數(shù) debounce 和節(jié)流函數(shù) throttle
本文參考:
基礎(chǔ)理論
最近看到這么一道面試題:手寫實(shí)現(xiàn) debounce 和 throttle。

一臉懵逼,真的是。這兩個(gè)英文單詞都是什么鬼(原諒我英文詞匯量太爛),后來看了下,原來是防抖和節(jié)流的意思啊。
那么,防抖和節(jié)流又是什么東西?
這兩個(gè)東西,其實(shí)都是用來處理某個(gè)工作短時(shí)間內(nèi)過于頻繁觸發(fā)的場(chǎng)景,只是根據(jù)不同的處理方式有不同的說法。
防抖:某個(gè)函數(shù)在短時(shí)間內(nèi)只執(zhí)行最后一次。
意思也就是說,函數(shù)被觸發(fā)時(shí),需要先延遲,在延遲的時(shí)間內(nèi),如果再次被觸發(fā),則取消之前的延遲,重新開始延遲。這樣就能達(dá)到,只響應(yīng)最后一次,其余的請(qǐng)求都過濾掉。
這種處理方式有很多實(shí)際的應(yīng)用場(chǎng)景:比如對(duì)輸入框數(shù)據(jù)的校驗(yàn)處理,沒必要每輸入一個(gè)字符就校驗(yàn)一遍;
節(jié)流:某個(gè)函數(shù)在指定時(shí)間段內(nèi)只執(zhí)行第一次,直到指定時(shí)間段結(jié)束,周而復(fù)始。
跟防抖不一樣的是,節(jié)流是指定時(shí)間段內(nèi)只執(zhí)行第一次,也就是這段時(shí)間內(nèi),只需要響應(yīng)第一次的請(qǐng)求即可,后續(xù)的請(qǐng)求都會(huì)被過濾掉,直到下個(gè)時(shí)間段,重新來過,周而復(fù)始。
應(yīng)用場(chǎng)景:Android 里的屏幕刷新機(jī)制,每個(gè)幀(16.6ms)內(nèi),不管進(jìn)行了多少次請(qǐng)求界面刷新的操作,只需響應(yīng)第一次的請(qǐng)求,去向底層注冊(cè)監(jiān)聽?zhēng)盘?hào)即可。因?yàn)榻邮盏綆盘?hào)后,是通過遍歷 View 樹來刷新界面,所以注冊(cè)的動(dòng)作只需要進(jìn)行一次就夠了。Vue 的虛擬 DOM 的刷新也是類似的機(jī)制。
以上這些概念還不足以明白的話,再看張圖(盜自開頭鏈接中的文章):

這樣一來就理解了吧,第一行表示不做任何處理,頻繁調(diào)用函數(shù),每次都會(huì)響應(yīng);
經(jīng)過 debounce 防抖處理后,只響應(yīng)最后一次,因?yàn)榉蓝侗举|(zhì)上就是通過延遲,所以實(shí)際執(zhí)行函數(shù)時(shí)機(jī)會(huì)晚于函數(shù)的請(qǐng)求時(shí)機(jī);
而經(jīng)過 throttle 節(jié)流處理后,是按一定的頻率來處理這堆頻繁調(diào)用的函數(shù),每個(gè)周期內(nèi),只響應(yīng)第一次,過濾后面的請(qǐng)求,直到下個(gè)周期。
其實(shí),或許你并沒有接觸到 debounce 防抖或 throttle 節(jié)流這種專業(yè)術(shù)語的說法,但實(shí)際開發(fā)中,你肯定或多或少有進(jìn)行過類似防抖或節(jié)流的處理。下面講講它的實(shí)現(xiàn),你就會(huì)發(fā)現(xiàn),很似曾相識(shí)。
手寫 throttle 節(jié)流函數(shù)
節(jié)流,顧名思義,就是節(jié)省流量。那么,為什么可以節(jié)流,自然就是這頻繁被觸發(fā)的工作,其實(shí)沒必要次次響應(yīng)。
我們上面舉了個(gè) Android 的屏幕刷新機(jī)制的例子,也就是在一個(gè)周期內(nèi),可以有無數(shù)次會(huì)觸發(fā)屏幕刷新的操作,但其實(shí)只要第一次的操作去注冊(cè)一下幀信號(hào)就可以了。
實(shí)現(xiàn)上,其實(shí)也很簡(jiǎn)單,就是加個(gè)標(biāo)志位而已:
function throttle(fn, interval = 200) {
let flag = null;
return function(...args) {
if (!flag) {
flag = true;
setTimeout(() => {
flag = false;
fn.call(this, ...args);
}, interval);
}
}
}
是吧,就是簡(jiǎn)單的加個(gè)標(biāo)志位來進(jìn)行過濾。這里有個(gè)關(guān)鍵的點(diǎn):fn.call(this, ...args),為什么要通過 call 這種修改函數(shù)內(nèi)部 this 的方式來調(diào)用原函數(shù)?直接 fn() 不行嗎?
原因在手寫 debounce 里分析吧,因?yàn)槟抢镆彩且粯拥奶幚怼?/p>
那么,看到這個(gè)實(shí)現(xiàn)方案,有沒有感覺有點(diǎn)熟悉,在項(xiàng)目中肯定會(huì)有所接觸的,雖然由于這里的 throttle 函數(shù)是個(gè)通用的工具函數(shù),而且是高階函數(shù),可能在項(xiàng)目中看到的不多。至少,我好像并沒有在實(shí)際項(xiàng)目中使用過。
但這樣的,你肯定經(jīng)常寫:
var flag = null;
function a() {
if (!flag) {
flag = true;
// do something
// 在某個(gè)回調(diào)里將 flag = false;
}
}
這種通過 flag 標(biāo)志位過濾重復(fù)事件的處理,其實(shí)就跟節(jié)流的思想有點(diǎn)類似。區(qū)別只是,節(jié)流是通過一定的頻率來修改標(biāo)志位,來重新放行,而上面這種用法,則是依賴于某個(gè)任務(wù)完成后,再去回調(diào)修改標(biāo)志位,也就是任務(wù)不完成,重復(fù)的事件都會(huì)被過濾。但兩者的思想其實(shí)很類似。
手寫 debounce 防抖函數(shù)
防抖處理我實(shí)際中用得比較多,所以打算講講,網(wǎng)上大眾的實(shí)現(xiàn),以及我針對(duì)具體項(xiàng)目的場(chǎng)景下的實(shí)現(xiàn)。
js 版
網(wǎng)上基本都是用的高階函數(shù)實(shí)現(xiàn),即封裝一個(gè)工具函數(shù) debounce,它以參數(shù)形式接收原函數(shù),并返回一個(gè)經(jīng)過防抖處理的新函數(shù),后續(xù)涉及到需要防抖處理的,都需要使用新函數(shù)來替代原函數(shù)。
function debounce(fn, delay = 200) {
if (typeof fn !== 'function') { // 參數(shù)類型為函數(shù)
throw new TypeError('fn is not a function');
}
let lastFn = null;
return function(...args) {
if (lastFn) {
clearTimeout(lastFn);
}
let lastFn = setTimeout(() => {
lastFn = null;
fn.call(this, ...args);
}, delay);
}
}
其實(shí)很簡(jiǎn)單,就是每次調(diào)用函數(shù)前,先移除上次還處于延遲中的任務(wù),然后重新發(fā)起一次新的延遲等待。
上面最重要的地方在于 fn.call(this, ...args),這里之所以要通過 call 方式來修改原函數(shù)的 this,是因?yàn)?,原函?shù)通過參數(shù)進(jìn)行傳遞時(shí),是只會(huì)被當(dāng)做普通函數(shù)處理,不管原函數(shù)本來是否掛載在某個(gè)對(duì)象上。
所以,如果 debounce 內(nèi)部直接以 fn() 方式調(diào)用原函數(shù),會(huì)導(dǎo)致原函數(shù)的內(nèi)部 this 指向發(fā)生變化。
有兩種解決方式:
一是:debounce 以 fn() 方式調(diào)用,但在使用 debounce 的地方,傳遞 fn 原函數(shù)時(shí)需要先進(jìn)行綁定,如:
var o = {
c: 1,
a: function() {
console.log(this.c);
}
}
var b = debounce(o.a.bind(o));
這是一種方式,缺點(diǎn)是需要使用者手動(dòng)進(jìn)行顯示綁定 this。
另一種方式:debounce 內(nèi)部通過 apply 或 call 方式來調(diào)用原函數(shù)。
但這種方式也有一個(gè)前提,就是 debounce 返回的新函數(shù)需要把它當(dāng)做原函數(shù),和原函數(shù)一樣的處理。如果原函數(shù)本來掛載在某對(duì)象上,新生成的函數(shù)也需要掛載到那對(duì)象上,因?yàn)?debounce 內(nèi)部的 fn.call(this) 時(shí),這個(gè) this 是指返回的新函數(shù)調(diào)用時(shí)的 this。所以,需要讓新函數(shù)的 this 和原函數(shù)是一致的,才會(huì)是期望的正常行為。
var o = {
c: 1,
a: function() {
console.log(this.c);
}
}
o.b = debounce(o.a);
總之,debounce 的用途就是通用的工具函數(shù),所以需要防抖處理的工作,都可以通過 debounce 進(jìn)行包裝轉(zhuǎn)換。
就算你沒寫過這個(gè)通用的工具函數(shù),至少在項(xiàng)目中,也寫過直接定義一個(gè)全局變量來進(jìn)行防抖處理吧,類似這樣:
var flag = null;
function task() {
if (flag) {
clearTimeout(flag);
}
flag = setTimeout(() => {
flag = null;
// do something
}, 200);
}
這其實(shí)也是防抖的處理,只是實(shí)現(xiàn)方式是直接對(duì)需要進(jìn)行防抖處理的函數(shù),在其代碼基礎(chǔ)上,直接進(jìn)行改動(dòng)。不具有通用性。
所以我才說,網(wǎng)上大眾版的 debounce 防抖函數(shù),也許你沒接觸過,也沒見過,但不代表你沒接觸到防抖處理的思想,在實(shí)際項(xiàng)目中,其實(shí)或多或少都會(huì)有所接觸了,只是實(shí)現(xiàn)的方式、通用性等不一樣而已。
當(dāng)然,以上的 js 版實(shí)現(xiàn),只是一種最基礎(chǔ)的方案,文章開頭給出的鏈接中,還有很多擴(kuò)展的實(shí)現(xiàn),比如增加了支持第一次觸發(fā)立即執(zhí)行的功能;和 throttle 節(jié)流結(jié)合用法;手動(dòng)取消延遲的功能等等。
感興趣的可以自行查閱,我是覺得,大概知道基礎(chǔ)思想就夠了,實(shí)際項(xiàng)目中再根據(jù)需要去進(jìn)行擴(kuò)展。
ts + angular 版
我還想講講我在實(shí)際項(xiàng)目中所進(jìn)行的防抖處理,上面的 js 版在每篇防抖文章中,基本都是那樣實(shí)現(xiàn),都是封裝一個(gè)高階函數(shù)。
但我實(shí)際開發(fā)中,使用的是 TypeScript,這是一種類似于 Java 思想的強(qiáng)類型語言,所以很少會(huì)用到高階函數(shù)的思想,更多的是封裝工具類。
再加上,我框架是使用 angular,項(xiàng)目中除了有防抖處理的場(chǎng)景,還有其他諸如延遲任務(wù)的場(chǎng)景,輪詢?nèi)蝿?wù)的場(chǎng)景等等。這些不管是從用法、實(shí)現(xiàn)上等來說,都很相似,所以我都統(tǒng)一封裝在一起。
另外,涉及 setTimeout,setInterval 這兩個(gè) API,如果沒有進(jìn)行清理工作,很容易造成內(nèi)存泄漏,因此跟 setTimeout 和 setInterval 相關(guān)的用法,我都將它跟 angular 的組件進(jìn)行綁定處理,避免開發(fā)人員忘記清理,至少我還可以在組件銷毀時(shí)去自動(dòng)清理。
export class PollingTaskUtils {
constructor(){}
static tag(component: {ngOnDestroy}, tag: string = 'default'): PollingMgr {
let taskTag = `__${tag}__`;
if (component[taskTag] == null) {
let pollingMgr = new PollingMgr(component, taskTag);
component[taskTag] = pollingMgr;
}
return component[taskTag];
}
}
export class PollingMgr {
private readonly AUTO_CLEAR_FLAG_PREFIX = '__auto_clear_flat';
private _delay: number = 0; // 任務(wù)的延遲時(shí)長(zhǎng)
private _isLoop: boolean = false; // 是否是循環(huán)任務(wù)
private _interval: number; // 循環(huán)任務(wù)的間隔
private _resolve: {loop: (interval?: number) => any}
private _pollingTask: (resolve?: {loop: (interval?: number) => any}) => any;
private _pollingFlag = null;
private _pollingIntervalFlag = null;
constructor(protected component: {ngOnDestroy}, protected tag: string) {
this._resolve = {
loop: (interval?: number) => {
if (interval > 0) {
this._isLoop = true;
this._interval = interval;
} else {
this._isLoop = false;
}
this._handleLoop();
}
};
}
run(task: (resolve?: {loop: (interval?: number) => any}) => any) {
this._clear(this.component);
this._pollingTask = task;
if (this._delay) {
this._pollingFlag = setTimeout(() => {
this._pollingFlag = null;
task.apply(this.component, [this._resolve]);
}, this._delay);
} else {
task.apply(this.component, [this._resolve]);
}
this._handleAutoClear();
}
clear(component?: {ngOnDestroy}) {
this._clear(component);
}
delay(dealy: number): PollingMgr {
this._delay = delay;
return this;
}
runInterval(task: () => any, interval: number) {
if (!this._pollingIntervalFlag) {
this._pollingIntervalFlag = setInterval(() => {
task.apply(this.component);
}, interval);
}
this._handleAutoClear();
return this._pollingIntervalFlag;
}
private _handleLoop() {
if (this._isLoop) {
this._clear(this.component);
this._pollingFlag = setTimeout(() => {
this._pollingFlag = null;
this._pollingTask.apply(this.component, [this._resolve]);
}, this._interval);
}
}
private _handleAutoClear() {
if (this.component[this.AUTO_CLEAR_FLAG_PREFIX + this.tag] == null) {
this.component[this.AUTO_CLEAR_FLAG_PREFIX + this.tag] = true;
let originFun = this.component['ngOnDestroy'];
this.component['ngOnDestroy'] = (): void => {
originFun.apply(this.component);
delete this.component[this.tag];
delete this.component[this.AUTO_CLEAR_FLAG_PREFIX + this.tag];
this._pollingTask = null;
this._clear(this.component);
if (this._pollingIntervalFlag) {
clearInterval(this._pollingIntervalFlag);
this._pollingIntervalFlag = null;
}
};
}
}
private _clear(component: {ngOnDestroy}) {
this._isLoop = false;
if (this._pollingFlag) {
clearTimeout(this._pollingFlag);
this._pollingFlag = null;
}
}
}
當(dāng)初封裝的時(shí)候沒有寫注釋,感興趣的再細(xì)看吧,這里就是做個(gè)記錄,方便后續(xù)查閱,下面看看用法:
/**
* 輪詢、延遲、防抖的任務(wù)工具類
* 入口接收兩個(gè)參數(shù):
* component:當(dāng)前的組件類,使用時(shí)必須掛載在某個(gè)組件上,在組件銷戶時(shí),如果有輪詢?nèi)蝿?wù),會(huì)去進(jìn)行釋放定時(shí)器
* tag:可選參數(shù),用于標(biāo)識(shí)不同的任務(wù),相同的 tag,多次調(diào)用都會(huì)被視為同個(gè)任務(wù)進(jìn)行防抖處理
*/
PollingTaskUtils.tag(component, tag?: string);
// 1. 延遲任務(wù)用法,比如延遲5s后處理
PollingTaskUtils.tag(this).delay(5000).run(() => {
// do something
});
// 因?yàn)?tag 沒傳,該任務(wù)會(huì)和上面的被視為同個(gè)任務(wù),如果上個(gè)任務(wù)延遲未被執(zhí)行,則先取消,以下面為主
PollingTaskUtils.tag(this).delay(5000).run(() => {
// do something
});
// tag 參數(shù)指定為 'task',表示一個(gè)新的任務(wù),會(huì)上述的延遲任務(wù)相互獨(dú)立
PollingTaskUtils.tag(this).delay(5000, 'task').run(() => {
// do something
});
// 2. 輪詢?nèi)蝿?wù),比如每隔 10s 發(fā)起一次請(qǐng)求
PollingTaskUtils.tag(this).run(resolve => {
// 模擬請(qǐng)求
setTimeout(() => {
// do something
resolve.loop(10000); // 設(shè)置輪詢間隔
}, 2000)
});
// 3. 輪詢?nèi)蝿?wù),符合一定條件停止輪詢
PollingTaskUtils.tag(this).run(resolve => {
// 模擬請(qǐng)求
setTimeout(() => {
if (flag) {
resolve.loop(-1); // <=0 或者不調(diào)用時(shí)停止輪詢
} else {
resolve.loop(3000);
}
}, 2000);
});
// 4. 防抖處理
let i = 0;
while(i++ < 10) {
PollingTaskUtils.tag(this).delay(500).run(() => {
// do something
});
}
// 5. 由于 run 內(nèi)部是通過 setTimeout 來實(shí)現(xiàn)輪詢?nèi)蝿?wù),但這個(gè)并不精準(zhǔn),當(dāng)要求較精準(zhǔn)的輪詢時(shí),比如時(shí)鐘,使用 setInterval 會(huì)比較精準(zhǔn)
PollingTaskUtils.tag(this).runInterval(() => {
// do something
}, 1000);
其實(shí)用法跟直接用 setTimeout 和 setInterval 沒多大區(qū)別,但好處在于,增加了跟組件的綁定,增加了對(duì)任務(wù)標(biāo)識(shí)的處理,這樣一來,即使忘記清理,內(nèi)部也可以在組件銷毀時(shí)自動(dòng)去清理,即使多次調(diào)用,只要任務(wù)標(biāo)識(shí)不一樣,內(nèi)部就會(huì)進(jìn)行防抖處理??梢允〉粢徊糠值墓ぷ髁?。
當(dāng)然,這些所有的出發(fā)點(diǎn),僅適用于我的項(xiàng)目,因?yàn)楫吘故菑捻?xiàng)目中遇到的需求中來進(jìn)行封裝處理的,并不一定適用于你。
我想說的是,這些工具函數(shù)的封裝,重要的是掌握其思想,為什么需要進(jìn)行防抖處理?防抖處理的基本實(shí)現(xiàn)是什么?知道這些即可,其余的,再自行根據(jù)需要擴(kuò)展學(xué)習(xí)。