手寫防抖函數(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ù)語(yǔ)的說法典格,但實(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)類型語(yǔ)言,所以很少會(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í)利诺。