前言
在日常開發或者面試中,防抖與節流應該都是屬於高頻出現的點。這篇文章主要是基於冴羽(後續用他代稱)大神的兩篇文章 防抖 與 節流來寫的。因為自己在看他文章的時候也對其中的程式碼產生了一些困惑,有一些卡住的地方,所以想把自己遇到的問題都丟擲來,一步步的去理解。 文中具體的場景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
函式可能是有返回值的,所以這裡也需要返回函式的結果。但當immediate
為false
的時候,因為setTimeout
的緣故,在最後return
的時候值會一直是undefined
。所以只在immediate
為true
的時候返回函式的執行結果。
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也可以丟擲來哦?~