從 underscore 源碼看節流函數實現

throttle節流函數

Javascript 中的函數大多數狀況下都是用戶調用執行的,可是在某些場景下不是用戶直接控制的,在這些場景下,函數會被頻繁調用,容易形成性能問題。html

好比在 window.onresize 事件和 window.onScroll事件中,因爲用戶能夠不斷地觸發,這會致使函數短期內頻繁調用,若是函數中有複雜的計算,很容易就形成性能的問題。前端

這些場景下最主要的問題是觸發頻率過高,1s內能夠觸發數次,可是大多數狀況下咱們並不須要那麼高的觸發頻率,可能只要在500ms內觸發一次,這樣其實咱們能夠用 setTimeout 來解決,在這期間的觸發都忽略掉。git

咱們能夠先嚐試着本身實現一個節流函數:github

// 本身實現的簡單節流函數
function throttle (func, time) {
	var timeout = null,
		context = null,
		args = null
	return function() {
	    context = this
		args = arguments
		// 只要timeout函數存在,全部調用都無視
		if(timeout) return;
		timeout = setTimeout(function() {
			func.apply(context, args)
			clearTimeout(timeout)
			timeout = null
		}, time||500)
	}
}
複製代碼

咱們實現了一個簡單的節流函數,可是還不夠完整,若是我想在第一次觸發的時候當即執行怎麼辦?若是我想禁用掉最後一次執行怎麼辦?underscore 中實現了一個比較完整的節流函數。segmentfault

// options是一個對象,若是options.leading爲false,就是禁用第一次觸發當即調用
// 若是options.trailing爲false,則是禁用第一次執行
_.throttle = function (func, wait, options) {
		// 一些初始化操做
		var context, args, result;
		var timeout = null;
		var previous = 0;
		if (!options) options = {};
		var later = function () {
			// 若是禁用第一次首先執行,返回0不然就用previous保存當前時間戳
			previous = options.leading === false ? 0 : _.now();
			// 解除引用
			timeout = null;
			result = func.apply(context, args);
			// 看到一種說法是在func函數裏面從新給timeout賦值,會致使timeout依然存在,因此這裏會判斷!timeout
			if (!timeout) context = args = null;
		};
		return function () {
		    // 獲取當前調用時的時間(ms)
			var now = _.now();
			// 若是previous爲0而且禁用了第一次執行,那麼將previous設置爲當前時間
			// 這裏用全等來避免undefined的狀況
			if (!previous && options.leading === false) previous = now;
			// 還要wait時間纔會觸發下一次func
			var remaining = wait - (now - previous);
			context = this;
			args = arguments;
			// remaining小於0有兩種狀況,一種是上次調用後到如今已經到了wait時間
			// 一種狀況是第一次觸發的時候而且options.leading不爲false,previous爲0,由於now記錄的是unix時間戳,因此會遠遠大於wait
			// remaining大於wait的狀況我本身不清楚,但看到一種說法是客戶端系統時間被調整過,可能會出現now小於previous的狀況
			// 這兩種情形下會當即執行func函數,並把previous設置爲now
			if (remaining <= 0 || remaining > wait) {
				if (timeout) {
				    // 清除定時器
					clearTimeout(timeout);
					timeout = null;
				}
				// previous保存當前觸發的時間戳
				previous = now;
				result = func.apply(context, args);
				if (!timeout) context = args = null;
			// 若是timeout不存在(當前定時器還存在)
			// 而且options.trailing不爲false,這個時候會從新設置定時器,remaining時間後執行later函數
			} else if (!timeout && options.trailing !== false) {
				timeout = setTimeout(later, remaining);
			}
			return result;
		};
	};

複製代碼

這段代碼看着很少,可是讓我糾結了好久,運行的時候主要會有如下幾種狀況。bash

沒有傳 leading 和 trailing

  1. 第一次觸發函數的時候,因爲 previous 爲0,而 now 又很是大,因此會致使 remaining 爲負值,知足下面第一個 if 判斷,因此會當即執行 func 函數(第一次觸發時當即調用)而且用 previous 記錄當前時間戳
  2. 第二次觸發的時候因爲 previous 記錄了前一次的時間戳,因此 now - previous 幾乎爲0,這個時候知足 else if 裏面的判斷,會設置一個定時器,這個定時器在 remaining 時間後執行,因此只要在 remaining 時間內無論咱們再怎麼頻繁觸發,因爲不會知足兩個 if 裏面的條件,因此都不會執行 func,一直到 remaining 後纔會執行func
  3. 以後每次觸發都會重複走2的流程

options.leading: false

這種狀況和上面狀況相似,不過區別在於第一次觸發的時候。微信

因爲知足 !previous && options.leading === false這個條件,因此 previous 會被設置爲 now,這個時候 remaining 等於 wait,因此會走 else if 的分支,這樣就會重複前一種狀況下步驟2的流程app

options.trailing: false

  1. 因爲沒有設置 leading 爲 false,因此第一次觸發就會當即執行一次 func
  2. 第二次觸發的時候,因爲 previous 保存了上次時間戳,因此 remaining <= wait ,可是又由於 options.trailing 爲 false,這樣就不會走 if 的任何一個分支,一直到 now-previous 大於 wait 的時候(也就是過了 wait 時間後),這樣會知足 if 第一個分支的條件,func 會當即被執行一次
  3. 以後重複步驟2

trailing 和 leading 都爲f alse

最好不要這麼寫,由於會致使一個 bug 的出現,若是咱們在一段時間內頻繁觸發,這個是沒什麼問題,但若是咱們最後一次觸發後中止等待 wait 時間後再從新開始觸發,這時候的第一次觸發就會當即執行 func,leading 爲 false 並無生效。函數

不知道有沒有人和我同樣有這兩個疑問,leading 爲 false 的時候,真的只是在第一次調用的時候有區別嗎?trailing 是怎麼作到禁用最後一次執行的?源碼分析

這兩個問題讓我昨晚睡覺前都還在糾結,還好今天在 segmentfault 上面有熱心的用戶幫我解答了。

請直接看第一個回答以及下面的評論區:關於 underscore 源碼 中throttle 函數的疑惑?

leading 帶來的不一樣表現

GDUTxxZ 大神給了一段代碼,執行後不一樣的表現讓我印象深入。

var _now = new Date().getTime()
var throttle = function(func, wait, options) {
  var context, args, result;
  var timeout = null;
  var previous = 0;
  if (!options) options = {};
  var later = function() {
    previous = options.leading === false ? 0 : new Date().getTime();
    timeout = null;
    result = func.apply(context, args);
    if (!timeout) context = args = null;
  };
  return function() {
    console.log(`函數${++i}${new Date().getTime() - _now}調用`)
    var now = new Date().getTime();
    if (!previous && options.leading === false) previous = now;
    var remaining = wait - (now - previous);
    context = this;
    args = arguments;
    // 若是超過了wait時間,那麼就當即執行
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      previous = now;
      result = func.apply(context, args);
      if (!timeout) context = args = null;
    } else if (!timeout && options.trailing !== false) {
      timeout = setTimeout(later, remaining);
    }
    return result;
  };
};
var i = 0
var test = throttle(() => {
  console.log(`函數${i}${new Date().getTime() - _now}執行`)
}, 1000, {leading: false})

setInterval(test, 3000)
複製代碼

我將傳入 leading 和沒傳入 leading 的狀況做了如下比較。 leading 爲 false 時:

leading爲false
沒有傳入 leading 時:
leading爲true
當兩次觸發間隔時間大於 wait 時間的時候,很明顯 leading 爲 false 的時候總會在調用後延遲 wait 後執行 func ,而不傳 leading 的時候二者是同時的,調用 test 的時候就直接運行了 func。本來應該是 callback => wait => callback

通常狀況下固然不會有這種極端狀況存在,可是可能出現這種狀況。若是在 scroll 事件中,咱們滾動一段距離後中止了,等 wait ms 後再開始滾動,這個時候若是 leading 爲 false,依然會延遲 wait 時間後執行,而不是當即執行,這也是爲何同時設置 leading 和 trailing 爲 false 的時候會出現問題。

爲何是禁用最後一次調用

trailing 爲 false 時究竟是怎麼禁用了最後一次調用?這個也一直讓我很糾結。一樣的,我也寫了一段代碼,比較了一下兩次運行後的不一樣結果。

var _now = new Date().getTime()
var throttle = function(func, wait, options) {
var context, args, result;
var timeout = null;
var previous = 0;
if (!options) options = {};
var later = function() {
previous = options.leading === false ? 0 : new Date().getTime();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function() {
console.log(`函數${++i}${new Date().getTime() - _now}調用`)
var now = new Date().getTime();
if (!previous && options.leading === false) previous = now;
var remaining = wait - (now - previous);
context = this;
args = arguments;
// 若是超過了wait時間,那麼就當即執行
if (remaining <= 0 || remaining > wait) {
  if (timeout) {
    clearTimeout(timeout);
    timeout = null;
  }
  previous = now;
  result = func.apply(context, args);
  if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
  timeout = setTimeout(later, remaining);
}
return result;
};
};
var i = 0
var test = throttle(() => {
console.log(函數${i}${new Date().getTime() - _now}執行)
}, 1000, {trailing: false})
window.addEventListener("scroll", test)
複製代碼

trailing 爲 false 時:

trailing爲false

沒有設置 trailing 時:

沒有設置trailing

這兩張圖很明顯的不一樣就是設置了 trailing 的時候,最後一次老是"執行",而未設置 trailing 最後一次老是"調用",少了一次執行。

咱們能夠假設在一種臨界的場景下,好比在倒數第二次執行 func 後的 (wait-1) 的時間內。

若是設置了 trailing,由於沒法走 setTimeout,因此只能等待 wait 時間後才能當即調用 func,因此在(wait-1)的時間內不管咱們觸發了多少次都不會執行 func 函數。

若是沒有設置 trailing,那麼確定會走 setTimeout,在這個期間觸發的第一次就會設置一個定時器,等到 wait 時間後自動執行 func 函數,到(wait-1)的這段時間內無論咱們觸發了多少次,反正第一次觸發的時候就已經設置了定時器,因此到最後必定會執行一次 func 函數。

總結

好久之前就使用過 throttle 函數,本身也實現過簡單的,可是看到 underscore 源碼後才發現原來還會有這麼多使人充滿想象的場景,本身所學的這點知識真的是皮毛。

❤️ 看完三件事

若是你以爲這篇內容對你挺有啓發,我想邀請你幫我三個小忙:

  1. 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)
  2. 關注公衆號「前端小館」,或者加我微信號「testygy」,不按期分享原創知識。
  3. 也看看其它文章

參考連接:

  1. 關於underscore源碼中throttle函數的疑惑?
  2. underscore 函數節流的實現
  3. Underscore之throttle函數源碼分析以及使用注意事項
  4. 淺談 Underscore.js 中 _.throttle 和 _.debounce 的差別
相關文章
相關標籤/搜索