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

前言

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

防抖與節流的定義

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

防抖

持續觸發事件不執行,等到事件中止觸發後n秒纔去執行函數。github

// 初版
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秒後,才從新觸發執行。bash

先來拆分需求:app

  • 當即執行函數
  • 中止觸發n秒後,才從新觸發

當即執行函數很容易實現func.apply(context, args)便可。可是不可能當用戶持續觸發的時候一直去調用func這個函數,因此這裏想到須要一個字段來判斷什麼時候可以去執行func函數。ide

// 第二版
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的時候返回函數的執行結果。ui

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)後,取消防抖,當用戶再次去觸發時,就能夠又馬上執行了。this

需求思考:取消防抖,其實說白了就是清除掉以前存在的計時器。這樣當用戶再次觸發的時候就能馬上執行函數啦。嘿嘿😝是否是很簡單啊!

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也能夠拋出來哦😬~

參考文章

相關文章
相關標籤/搜索