理解Underscore中的節流函式

Russ_Zhong發表於2019-03-04

上一篇中講解了Underscore中的去抖函式(_.debounced),這一篇就來介紹節流函式(_.throttled)。

經過上一篇文章,我相信很多人都已經瞭解了去抖和節流的概念。去抖,在一段連續的觸發中只能得到觸發一次的結果,在觸發之後經過一段時間才可以得到執行的結果,並且必須在經過這段時間之後,才可以進入下一個觸發週期。節流不同於去抖,節流是一段連續的觸發至少可以得到一次觸發結果,上限取決於設定的時間間隔。

1 理解函式節流

通過這張我手畫的圖,我相信可以更容易理解函式節流這個概念。

理解Underscore中的節流函式

在這張粗製濫造的手繪圖中,從左往右的軸線表示時間軸,下方的粗藍色線條表示不斷的呼叫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);
複製程式碼

結語

由於水平有限,所以文章可能會存在紕漏,恭請各位斧正!
有想看其他文章的同學可以去我的GitHub,我的私人部落格

相關文章