上一篇中講解了Underscore中的去抖函式(_.debounced
),這一篇就來介紹節流函式(_.throttled
)。
經過上一篇文章,我相信很多人都已經瞭解了去抖和節流的概念。去抖,在一段連續的觸發中只能得到觸發一次的結果,在觸發之後經過一段時間才可以得到執行的結果,並且必須在經過這段時間之後,才可以進入下一個觸發週期。節流不同於去抖,節流是一段連續的觸發至少可以得到一次觸發結果,上限取決於設定的時間間隔。
1 理解函式節流
通過這張我手畫的圖,我相信可以更容易理解函式節流這個概念。
在這張粗製濫造的手繪圖中,從左往右的軸線表示時間軸,下方的粗藍色線條表示不斷的呼叫throttled函式(看做連續發生的),而上方的一個一個節點表示我們得到的執行func函式的結果。
從圖上可以看出來,我們通過函式節流,成功的限制了func函式在一段時間內的呼叫頻率,在實際中能夠提高我們應用的效能表現。
接下來我們探究一下Underscore中_.throttle函式的實現。
2 Underscore的實現
我們在探究原始碼之前,先了解一下Underscore API手冊中關於_.throttle函式的使用說明:
throttle_.throttle(function, wait, [options])
建立並返回一個像節流閥一樣的函式,當重複呼叫函式的時候,最多每隔 wait毫秒呼叫一次該函式。對於想控制一些觸發頻率較高的事件有幫助。(注:詳見:javascript函式的throttle和debounce)
預設情況下,throttle將在你呼叫的第一時間儘快執行這個function,並且,如果你在wait週期內呼叫任意次數的函式,都將盡快的被覆蓋。如果你想禁用第一次首先執行的話,傳遞{leading: false},還有如果你想禁用最後一次執行的話,傳遞{trailing: false}。
var throttled = _.throttle(updatePosition, 100);
$(window).scroll(throttled);
結合我畫的那張示意圖,應該比較好理解了。
如果傳遞的options引數中,leading為false,那麼不會在throttled函式被執行時立即執行func函式;trailing為false,則不會在結束時呼叫最後一次func。
Underscore原始碼(附註釋):
// Returns a function, that, when invoked, will only be triggered at most once
// during a given window of time. Normally, the throttled function will run
// as much as it can, without ever going more than once per `wait` duration;
// but if you`d like to disable the execution on the leading edge, pass
// `{leading: false}`. To disable execution on the trailing edge, ditto.
_.throttle = function (func, wait, options) {
var timeout, context, args, result;
var previous = 0;
if (!options) options = {};
var later = function () {
//previous===0時,下一次會立即觸發。
//previous===_.now()時,下一次不會立即觸發。
previous = options.leading === false ? 0 : _.now();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
var throttled = function () {
//獲取當前時間戳(13位milliseconds表示)。
//每一次呼叫throttled函式,都會重新獲取now,計算時間差。
//而previous只有在func函式被執行過後才回重新賦值。
//也就是說,每次計算的remaining時間間隔都是每次呼叫throttled函式與上一次執行func之間的時間差。
var now = _.now();
//!previous確保了在第一次呼叫時才會滿足條件。
//leading為false表示不立即執行。
//注意是全等號,只有在傳遞了options引數時,比較才有意義。
if (!previous && options.leading === false) previous = now;
//計算剩餘時間,now-previous為已消耗時間。
var remaining = wait - (now - previous);
context = this;
args = arguments;
//remaining <= 0代表當前時間超過了wait時長。
//remaining > wait代表now < previous,這種情況是不存在的,因為now >= previous是永遠成立的(除非主機時間已經被修改過)。
//此處就相當於只判斷了remaining <= 0是否成立。
if (remaining <= 0 || remaining > wait) {
//防止出現remaining <= 0但是設定的timeout仍然未觸發的情況。
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
//將要執行func函式,重新設定previous的值,開始下一輪計時。
previous = now;
//時間達到間隔為wait的要求,立即傳入引數執行func函式。
result = func.apply(context, args);
if (!timeout) context = args = null;
//remaining>0&&remaining<=wait、不忽略最後一個輸出、
//timeout未被設定時,延時呼叫later並設定timeout。
//如果設定trailing===false,那麼直接跳過延時呼叫later的部分。
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};
throttled.cancel = function () {
clearTimeout(timeout);
previous = 0;
timeout = context = args = null;
};
return throttled;
};
複製程式碼
接下來,我們分三種情況分析Underscore原始碼:
- 沒有配置options選項時
- options.leading === false時
- options.trailing === false時
2.1 預設情況(options === undefined)
在預設情況下呼叫throttled函式時,options是一個空的物件{}
,此時options.leading!==false
並且options.trailing!==false
,那麼throttled函式中的第一個if會被忽略掉,因為options.leading === false永遠不會滿足。
此時,不斷地呼叫throttled函式,會按照以下方式執行:
-
用now變數儲存當前呼叫時的時間戳,previous預設為0,計算remaining剩餘時間,此時應該會小於0,滿足了
if (remaining <= 0 || remaining > wait)
。 -
清空timeout並清除其事件,為previous重新賦值以記錄當前呼叫throttled函式的值。
-
能夠進入當前的if語句表示剩餘時間不足或者是第一次呼叫throttled函式(且options.leading !== false),那麼將會立即執行func函式,使用result記錄執行後的返回值。
-
下一次呼叫throttled函式時,重新計算當前時間和剩餘時間,如果剩餘時間不足那麼仍然立即執行func,如此不斷地迴圈。如果remaining時間足夠(大於0),那麼會進入else if語句,設定一個timeout非同步事件,此時注意到timeout會被賦值,直到later被呼叫才回被賦值為null。這樣做的目的就是為了防止不斷進入else if條件語句重複設定timeout非同步事件,影響效能,消耗資源。
-
之後呼叫throttled函式,都會按照這樣的方式執行。
通過上面的分析,我們可以發現,除非設定options.leading===false,否則第一次執行throttled函式時,條件語句if (!previous && options.leading === false) previous = now;
不會被執行。間接導致remaining<0,然後進入if語句立即執行func函式。
接下來我們看看設定options.leading === false時的情況。
2.2 options.leading === false
設定options.leading為false時,執行情況與之前並沒有太大差異,僅在於if(!previous && options.leading === false)
語句。當options.leading為false時,第一次執行會滿足這個條件,所以賦值previous=== now,間接使得remaining>0。
由於timeout此時為undefined,所以!timeout為true。設定later為非同步任務,在remaining時間之後執行。
此後再不斷的呼叫throttled方法,思路同2.1無異,因為!previous為false,所以if(!previous && options.leading === false)
該語句不再判斷,會被完全忽略。可以理解為設定判斷!previous的目的就是在第一次呼叫throttled函式時,判斷options.leading是否為false,之後便不再進行判斷。
2.3 options.trailing === false
此時的區別在於else if中的執行語句。如果options.trailing === false
成立,那麼當remaining>0時間足夠時,不會設定timeout非同步任務。那麼如何實現時間到就立即執行func呢?是通過不斷的判斷remaining,一旦remaining <= 0
成立,那麼就立即執行func。
接下來,我們手動實現一個簡單的throttle函式。
實現一個簡單的throttle函式
首先,我們需要多個throttled函式共享一些變數,比如previous、result、timeout,所以最好的方案仍然是使用閉包,將這些共享的變數作為throttle函式的私有變數。
其次,我們需要在返回的函式中不斷地獲取呼叫該函式時的時間戳now,不斷地計算remaining剩餘時間,為了實現trailing不等於false時的效果,我們還需要設定timeout。
最終程式碼如下:
var throttle = function(func, wait) {
var timeout, result, now;
var previous = 0;
return function() {
now = +(new Date());
if(now - previous >= wait) {
if(timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(this, arguments);
}
else if(!timeout) {
timeout = setTimeout(function() {
previous = now;
result = func.apply(this, arguments);
timeout = null;
}, wait - now + previous);
}
return result;
}
}
複製程式碼
可能大家發現了一個問題就是我的now變數也是共享的變數,而underscore中是throttled函式的私有變數,為什麼呢?
我們可以注意到:underscore設定timeout時,呼叫的是另外一個throttle函式的私有函式,叫做later。later在更新previous的時候,使用的是previous = options.leading === false ? 0 : _.now();
也就是通過_.now
函式直接獲取later被呼叫時的時間戳。而我使用的是previous = now
,如果now做成throttled的私有變數,那麼timeout的非同步任務執行時,設定的previous仍然是過去的時間,而非非同步任務被執行時的當前時間。這樣做直接導致的結果就是previous相比實際值更小,remaining會更大,下一次func觸發會來的更早!
下面這段程式碼是對上面程式碼的應用,大家可以直接拷貝到瀏覽器的控制檯,回車然後在頁面上滾動滑鼠滾輪,看看這個函式實現了怎樣的功能,更有利於你對這篇文章的理解!
var throttle = function(func, wait) {
var timeout, result, now;
var previous = 0;
return function() {
now = +(new Date());
if(now - previous >= wait) {
if(timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(this, arguments);
}
else if(!timeout) {
timeout = setTimeout(function() {
previous = now;
result = func.apply(this, arguments);
timeout = null;
}, wait - now + previous);
}
return result;
}
}
window.onscroll = throttle(()=>{console.log(`yes`)}, 2000);
複製程式碼