手寫防抖函數(shù) debounce 和節(jié)流函數(shù) throttle

手寫防抖函數(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í)。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 前言 最近和前端的小伙伴們,在討論面試題的時(shí)候。談到了函數(shù)防抖和函數(shù)節(jié)流的應(yīng)用場(chǎng)景和原理。于是,想深入研究一下兩者...
    youthcity閱讀 23,791評(píng)論 5 78
  • 函數(shù)節(jié)流場(chǎng)景 例如:實(shí)現(xiàn)一個(gè)原生的拖拽功能(如果不用H5 Drag和Drop API),我們就需要一路監(jiān)聽mous...
    凡凡的小web閱讀 868評(píng)論 0 0
  • 防抖和節(jié)流是針對(duì)響應(yīng)跟不上觸發(fā)頻率這類問題的兩種解決方案 debounce,去抖動(dòng)。策略是當(dāng)事件被觸發(fā)時(shí),設(shè)定一個(gè)...
    皇甫圣坤閱讀 445評(píng)論 0 0
  • 前言 props與state都是用于組件存儲(chǔ)數(shù)據(jù)的一js對(duì)象,前者是對(duì)外暴露數(shù)據(jù)接口,后者是對(duì)內(nèi)組件的狀態(tài),它們決...
    itclanCoder閱讀 2,237評(píng)論 0 0
  • 文——梁花花 我總站在原地, 腳下踩著黝黑的土地 我把田地隆平, 打上整齊的溝渠! 夏出的稻子開始抽出嫩黃的穗兒,...
    衣飾憶流年閱讀 206評(píng)論 7 6

友情鏈接更多精彩內(nèi)容