女友都懂系列之防抖與節流分析

Wing93發表於2020-02-27

前言

在日常開發或者面試中,防抖與節流應該都是屬於高頻出現的點。這篇文章主要是基於冴羽(後續用他代稱)大神的兩篇文章 防抖節流來寫的。因為自己在看他文章的時候也對其中的程式碼產生了一些困惑,有一些卡住的地方,所以想把自己遇到的問題都丟擲來,一步步的去理解。 文中具體的場景demo以他的為例,就不單獨在舉場景例子了。

防抖與節流的定義

  • 防抖:事件持續觸發,但只有當事件停止觸發後n秒才執行函式。
  • 節流:事件持續觸發時,每n秒執行一次函式。

防抖

持續觸發事件不執行,等到事件停止觸發後n秒才去執行函式。

// 第一版
const debounce = function(func, delay) {
    let timeout;
    return function () {
        const context = this;
        const args = arguments;
        clearTimeout(timeout)
        timeout = setTimeout(() => {
            func.apply(context, args)
        }, delay);
    }
}
複製程式碼

第一版沒什麼難點,當使用者持續觸發就一直清除計時器,當他最後一次觸發後,會生成一個計時器,同時計時器中的方法將在delay秒執行。

新增需求:不等到事件停止觸發後才執行,希望立即執行函式。然後等到停止觸發n秒後,才重新觸發執行。

先來拆分需求:

  • 立即執行函式
  • 停止觸發n秒後,才重新觸發

立即執行函式很容易實現func.apply(context, args)即可。但是不可能當使用者持續觸發的時候一直去呼叫func這個函式,所以這裡想到需要一個欄位來判斷何時能夠去執行func函式。

// 第二版
const debounce = function (func, delay) {
    let timer,
        callNow = true; // 是否立即執行函式的標識
    return function () {
        const context = this;
        const args = arguments;
        if (timer) clearTimeout(timer);
        if(callNow) {
            func.apply(context, args); // 觸發事件立即執行
            callNow = false; // 將標識設定為false,保證後續在delay秒內觸發事件都無法執行函式。    
        } else {
            timer = setTimeout(() => {
                callNow = true; // 過delay秒後才能再次觸發函式執行。
            }, delay) 
        }
    }
}
複製程式碼

新增需求:加個immediate引數來判斷是否立刻執行。

其實通過上面那個簡化版,這次加個引數欄位來區分就很好實現了。

const debounce2 = function (func, delay, immediate = false) {
    let timer,
        callNow = true;
    return function () {
        const context = this;
        const args = arguments;
        if (timer) clearTimeout(timer);
        if (immediate) {
            if(callNow) func.apply(context, args); // 觸發事件立即執行
            callNow = false;
            timer = setTimeout(() => {
                callNow = true; // 過n秒後才能再次觸發函式執行。
            }, delay)
        } else {
            timer = setTimeout(() => {
                func.apply(context, args);
            }, delay)
        }
    }
}
複製程式碼

返回值

getUserAction函式可能是有返回值的,所以這裡也需要返回函式的結果。但當immediatefalse的時候,因為setTimeout的緣故,在最後return的時候值會一直是undefined。所以只在immediatetrue的時候返回函式的執行結果。

const getUserAction = function(e) {
    this.innerHTML = count++;
    return 'Function Value';
}

const debounce = function (func, delay, immediate = false) {
    let timer,
        result,
        callNow = true;
    return function () {
        const context = this;
        const args = arguments;
        if (timer) clearTimeout(timer);
        if (immediate) {
            if(callNow) result = func.apply(context, args);
            callNow = false;
            timer = setTimeout(() => {
                callNow = true; // 過n秒後才能再次觸發函式執行。
            }, delay)
        } else {
            timer = setTimeout(() => {
                func.apply(context, args);
            }, delay)
        }
        return result;
    }
}
    
// demo test    
const setUseAction = debounce(getUserAction, 2000, true);
    // 展示函式返回值
    box.addEventListener('mousemove', function (e) {
        const result = setUseAction.call(this, e);
        console.log('result', result);
    })
複製程式碼

取消

希望能夠取消debounce函式,可以讓使用者執行此方法(cancel)後,取消防抖,當使用者再次去觸發時,就可以又立刻執行了。

需求思考:取消防抖,其實說白了就是清除掉之前存在的計時器。這樣當使用者再次觸發的時候就能立刻執行函式啦。嘿嘿?是不是很簡單啊!

const debounce = function (func, delay, immediate = false) {
    let timer,
        result,
        callNow = true;
    const debounced = function () {
        const context = this;
        const args = arguments;
        if (timer) clearTimeout(timer);
        if (immediate) {
            if(callNow) result = func.apply(context, args);
            callNow = false;
            timer = setTimeout(() => {
                callNow = true; // 過n秒後才能再次觸發函式執行。
            }, delay)
        } else {
            timer = setTimeout(() => {
                func.apply(context, args);
            }, delay)
        }
        return result;
    };
    debounced.cancel = function(){
        clearTimeout(timer);
        timer = null;
    }
}
複製程式碼

經過這樣的一系列拆分是不是頓時覺得防抖也就那麼回事嘛,並沒有多難~

節流

節流的兩種主流實現方式:1.時間戳; 2.設定定時器。

時間戳

觸發事件時,取出當前的時間戳,然後減去之前的時間戳(最開始設定為0)。若大於設定的時間週期,則執行函式,同時更新時間戳為當前的時間戳。若小於,則不執行。

const throttle = function(func, delay) {
    let prev = 0; // 將初始的時間戳設為0,保證第一次觸發就一定執行函式
    return function(){
        const context = this;
        const args = arguments;
        const now = +new Date();
        if (now - prev > delay) {
            func.apply(context, args);
            prev = now;
        }
    }
}
複製程式碼

存在的問題

每過delay秒會執行一次函式,但是當最後一次觸發的時間少於delay,則now - prev < delay,導致最後一次觸發並沒有執行函式。

定時器

觸發事件時,設定一個定時器。當再次觸發事件時,若定時器存在就不執行;直到定時器內部方法執行完,然後清空定時器,設定下一個定時器。

const throttle = function(func, delay){
    let timer;
    return function(){
        const context = this;
        const args = arguments;
        if (!timer) {
            timer = setTimeout(() => {
                timer = null; // delay秒重置timer值為null,為了重新設定一個新的定時器。
                func.apply(context, args);
            }, delay);
        }
    }
}
複製程式碼

存在的問題

當首次觸發事件的時候不會執行函式。

雙劍合璧

這版要實現兩個需求:

  • 首次觸發事件立即執行
  • 停止觸發事件後依然再執行一次事件

這裡先貼下他的程式碼。

雙劍合璧

說實話剛看到這段程式碼的時候我自己也是懵的,後面仔細思考了一會兒才完全想通。這邊我將自己如何理解這段程式碼的思路寫下來,幫助大家層層實現這個需求。

先看第二個需求(停止觸發事件後依然再執行一次事件),其實說白了就是延遲執行事件,此時我就會先想到這塊要用上setTimeout。但是有一個問題在於setTimeout的第二個引數延遲多少秒後觸發呢?假設每3s執行一次函式,執行了3次,我在第9.5的時候停止觸發事件。那麼後續將要過多少秒才能執行這最後一次觸發對應的事件呢?(12 - 9.5 = 2.5s)

// 虛擬碼片段如下
const throttle1 = function(func, delay){
    let timer,
        prev = 0;
    return function(){
        const context = this;
        const args = arguments;
        const now = +new Date();
        const remaining = delay - (now - prev); // 關鍵點:剩餘時間
        // 設定!timer條件是為了防止在已有定時器的情況下,再次觸發事件又去生成一個新的定時器。
        if (remaining > 0 && !timer) {
            timer = setTimeout(() => {
                prev = +new Date();
                timer = null;
                func.apply(context, args);    
            }, remaining)
        }
    }
}
複製程式碼

再來看第一個需求(首次觸發事件立即執行),想要首次觸發只需要將prev設為0,這樣就能確保在第一次的時候delay - (now - prev)的值一定是小於0的。

// 虛擬碼片段如下
const throttle2 = function(func, delay){
    let timer,
        prev = 0;
    return function(){
        const context = this;
        const args = arguments;
        const now = +new Date();
        const remaining = delay - (now - prev); // 關鍵點:下次觸發 func 剩餘時間
        // 設定!timer條件是為了在已有定時器的情況下,再次觸發事件又去新生成了一個定時器。
        if (remaining <= 0) {
            // 這段程式碼的實際意義?
            if (timer) {
                clearTimeout(timer);
                timer = null;
            }
            prev = now;
            func.apply(context, args);    
        }
    }
} 
複製程式碼

完整版本

const throttle = function(func, delay) {
    let timer,
        prev = 0;
    return function(){
        const context = this;
        const args = arguments;
        const now = +new Date();
        const remaining = delay - (now - prev);
        if (remaining <= 0) {
            prev = now;
            func.apply(context, args);    
        } else if(!timer) {
            timer = setTimeout(() => {
                prev = +new Date();
                timer = null;
                func.apply(context, args);    
            }, remaining)    
        }
    }
}
複製程式碼

現在基於上面兩段程式碼來模擬操作下(假設delay值為3):

  • 首次觸發:remaining值小於0,直接執行func函式同時更新prev的值(prev = now)。
  • 過1s後觸發:remaining值為2且timer值為undefined。此時會設定一個定時器(2s後執行),定時器中的程式碼將會在2s後執行(更新prev值;執行func函式;重置timer的值)。
  • 過2s後觸發:remaining值為1且timer有值,此時不會走進任何分支,即不會發生任何事情。
  • 過3s後觸發:remaining值為0且timer值為null,此時更新prev的值,將timer設定為null且執行func函式。
  • 過4s後觸發:remaining值為1且timer值為null,這個時候又會重複上面 過1s後觸發 的步驟,生成一個新的定時器,定時器中的程式碼將在2s後執行。
  • 過9.2s後觸發(停止觸發後還能再執行一次):remaining值為2.8且timer值為null,生成一個新的定時器,並且定時器中的程式碼將在2.8s後執行。

不知道大家會不會有這樣的疑問,我9.2s時停止觸發了,然後我10s的時候又再次觸發那會不會多產生新的定時器呢? 其實這個操作和上面的第二步與第三步類似,當10s再次觸發的時候,雖然remaining的值為2,但是此時timer是有值的,所以並不會進入任何一條分支,即不會發生任何事。

不知道經過我這一拆分講解,各位觀眾老爺有沒有對上面截圖的程式碼更清晰了一點呢??

優化版本

有時候希望無頭有尾或者有尾無頭。通過設定options作為第三個引數,然後根據傳的值進行判斷想要的效果。leading:false 表示禁用第一次執行; trailing:false 表示禁用停止觸發的回撥。

優化

老規矩先看下他的程式碼,當初剛看這版程式碼的時候我產生了如下幾點疑問:。

  • 為什麼later函式中,不直接寫previous = new Date().getTime(),而寫成previous =options.leading === false ? 0 : new Date().getTime()呢?;
  • 為什麼要有if (!timeout) context = args = null這段程式碼呢?
  • 下面這段程式碼的意義?可能會走到這裡嗎?
if (timeout) {
    clearTimeout(timeout);
    timeout = null;
}
複製程式碼

先將需求拆分下,先來看看設定leading = false如何實現禁用第一次執行的。這裡可以想到導致首次觸發就執行的關鍵就在於remaining的值小於0,那麼其實只要想辦法在首次觸發的時候保證remaining的值大於0就好啦!(將prev的初始值設定等於now的值即可)

const throttle = function(func, delay, option = {}) {
    let timer,
        prev = 0;
    return function(){
        const context = this;
        const args = arguments;
        const now = +new Date();
        // 首次觸發時將prev值設定等於now值,禁止首次觸發執行函式
        if (!prev && option.leading === false) {
            prev = now; // 確保首次觸發時remaining的值大於0.    
        }
        const remaining = delay - (now - prev);
        if (remaining <= 0) {
            prev = now;
            func.apply(context, args);    
        } else if(!timer) {
            timer = setTimeout(() => {
                prev = option.leading === false ? 0 : +new Date(); // 這裡為什麼這樣做,下面會解釋到。
                timer = null;
                func.apply(context, args);    
            }, remaining)    
        }
    }
}
複製程式碼

再看trailing = false是如何禁用停止觸發的回撥。同樣思考下導致停止觸發後還會再一次執行的原因在哪?其實就在於remaining的值是大於0,當它大於0時,就會去產生一個計時器,從而導致就算停止了觸發仍然能在remaining秒後執行函式。所以只需要在產生計時器程式碼的條件判斷上加上option.trailing !== false就可以禁止停止觸發的回撥啦。

const throttle = function(func, delay, option = {}) {
    let timer,
        prev = 0;
    return function(){
        const context = this;
        const args = arguments;
        const now = +new Date();
        if (!prev && option.leading === false) {
            prev = now;
        }
        const remaining = delay - (now - prev);
        if (remaining <= 0) {
            prev = now;
            func.apply(context, args);    
        // 當option.trailing值被設定為false時,永遠走不進這條分支,也就不會產生計時器。    
        } else if(!timer && option.trailing !== false) {
            timer = setTimeout(() => {
                prev = option.leading === false ? 0 : +new Date();
                timer = null;
                func.apply(context, args);    
            }, remaining)    
        }
    }
}
複製程式碼

解釋疑問1

為什麼要將prev = option.leading === false ? 0 : +new Date(),而不是prev = +new Date()。其實關鍵點在於當prev = 0時,觸發事件時就一定會執行if(!pre && option.leading === false) prev = now這段程式碼,進而能夠確保remaining的值恆大於0,即使用者不管下一次是什麼時候再次觸發事件時,都能保證程式碼走到else if這條分支。舉個場景解釋下(delay為3s)~

  • 使用者首次觸發滑動事件,remaining值大於0,所以會產生一個定時器且3秒後執行定時器內部程式碼。
  • 此時假設使用者並沒有持續3s都在觸發事件,而是在第2s的時候就離開了可滑動的區域,再過1s後,計時器中的對應函式仍會照常執行。這時分水嶺就出來了,若直接將prev = +new Date(),同時假設使用者過了10s後再次去觸發事件,因為現在prev有值,且deay - (now - prev)少於0(因為這時now-prev的值為10,大於3),所以會走入if(remaining <= 0)分支,這個時候就會立即執行func函式。這樣就不符合需求所說的首次觸發(注意這裡的首次觸發並不只是指第一次觸發,如果後續離開了觸發區域,過段時間再去觸發,也還是被當作了首次觸發。這個點一定要明白)不執行函式啦。
  • 再來看看prev = option.leading === false ? 0 : +new Date(),過10s後prev的值早已經為0,這時使用者再次去觸發事件,會執行prev = now這段程式碼,所以此時能確保remaining的值大於0,這樣就能夠保證使用者再次首次觸發事件時不會執行函式啦。而是生成一個定時器,3s後執行定時器中的方法。

解釋疑問2

context = args = null主要是為了釋放記憶體,因為JavaScript有自動垃圾收集機制,會找出那些不再繼續使用的值,然後釋放掉其佔用的記憶體。垃圾收集器每隔固定的時間段就會執行一次釋放操作。

解釋疑問3

其實這一點我到現在也不是很確定。個人猜想這樣做是為了防止定時器中的程式碼timeout = null並沒有在指定時間內立刻執行(即timeout仍有值),感覺這段程式碼就是處理這種極端狀況下的,能夠確保timeout的值一定會被置為null。

結語

以上就是我對於防抖與節流的理解。接下來會出一篇 防抖與節流實戰篇。 希望大家能在評論區中一起討論起來,有任何好的idea也可以丟擲來哦?~

參考文章

相關文章