每天閱讀一個 npm 模組(4)- throttle-debounce

elvinnn發表於2019-03-03

系列文章:

  1. 每天閱讀一個 npm 模組(1)- username
  2. 每天閱讀一個 npm 模組(2)- mem
  3. 每天閱讀一個 npm 模組(3)- mimic-fn

上一篇文章中介紹的屬性描述符的知識太偏於理論,今天閱讀的 throttle-debounce 模組會實用許多,在工作常常可以用到。

一句話介紹

今天閱讀的 npm 模組是 throttle-debounce,它提供了 throttledebounce 兩個函式:throttle 的含義是節流,debounce 的含義是防抖動,通過它們可以限制函式的執行頻率,避免短時間內函式多次執行造成效能問題,當前包版本為 2.0.1,周下載量為 6.3 萬。

用法

首選需要介紹一下 throttledebounce ,它們都可以用於 函式節流 從而提升效能,但它們還是存在一些不同:

  • debounce:將短時間內多次觸發的事件合併成一次事件響應函式執行(往往是在第一次事件或者在最後一次事件觸發時執行),即該段時間內僅一次真正執行事件響應函式。
  • throttle:假如在短時間內同一事件多次觸發,那麼每隔一段更小的時間間隔就會執行事件響應函式,即該段時間內可能多次執行事件響應函式。

雖然每天最煩等電梯要花上十幾分鍾,但還是可以用坐電梯來舉例子:

  • debounce:假如我在電梯裡面正準備關門,這時 A 想要坐電梯,那麼出於禮貌我會按下開門鍵,然後等他走進電梯後再嘗試關門;等 A 進電梯後,又發現 B 也想要坐電梯,那麼同樣出於禮貌我會按下開門鍵,然後等他走進電梯。那麼假如一直有人想要坐電梯的話,我就會不斷地延後按下關門鍵的時機,直至沒有人想要坐電梯(現實生活中我這樣做的話,估計每天除了坐電梯就可以什麼都不做了)。
  • throttle:實際上我每天都有工作要完成,不可能在電梯裡無限地等別人。那麼這回我任性一點,規定我只等 30 秒,不管到時候有沒有人想要坐電梯,我都會按下關門鍵走掉。

從上面兩個例子中可以看出兩者最大的區別在於只要有事件發生(有人想坐電梯),若使用了 throttle 方法,那麼在一段時間內事件響應函式一定會執行(30秒內我按下關門鍵);若使用了 debounce 方法,那麼只有事件停止發生後(我發現沒有人想坐電梯)才會執行。

大家可以嘗試在下面的 Demo 中滾動滑鼠直觀地感受到這兩者的不同:

See the Pen The Difference Between Throttling, Debouncing, and Neither by Elvin Peng (@elvinn) on CodePen.

對於 throttle-debounce,它的簡單用法如下:

import { throttle, debounce } from `throttle-debounce`;

function foo() { console.log(`foo..`); }
function bar() { console.log(`bar..`); }

const fooWrapper = throttle(200, foo);

for (let i = 1; i < 10; i++) {
  setTimeout(fooWrapper, i * 30);
}

// => foo 執行了三次
// => foo..
// => foo..
// => foo..

const barWrapper = debounce(200, bar);

for (let i = 1; i < 10; i++) {
  setTimeout(barWrapper, i * 30);
}

// => bar 執行了一次 
// => bar..

複製程式碼

原始碼學習

throttle 實現

將原始碼簡化後適當修改如下:

// 原始碼 4-1
function throttle(delay, callback) {
  let timeoutID;
  let lastExec = 0;

  function wrapper() {
    const self = this;
    const elapsed = Number(new Date()) - lastExec;
    const args = arguments;

    function exec() {
      lastExec = Number(new Date());
      callback.apply(self, args);
    }

    clearTimeout(timeoutID);

    if (elapsed > delay) {
      exec();
    } else {
      timeoutID = setTimeout(exec, delay - elapsed);
    }
  }

  return wrapper;
}
複製程式碼

整個程式碼的邏輯十分清晰,一共只有三步:

  1. 計算距離最近一次函式執行後經過的時間 elapsed,並清除之前設定的計時器。
  2. 如果經過的時間大於設定的時間間隔 delay,那麼立即執行函式,並更新最近一次函式的執行時間。
  3. 如果經過的時間小於設定的時間間隔 delay,那麼通過 setTimeout 設定一個計數器,讓函式在 delay - elapsed 時間後執行。

原始碼 4-1 並不難理解,不過需要關注一下 this 的使用:

function throttle(delay, callback) {
    // ...
    function wrapper() {
    	const self = this;
        const args = arguments;
        // ...
        
        function exec() {
            // ...
	      	callback.apply(self, args);
    	}
        
    }
}
複製程式碼

在上面的程式碼中,通過 self 變數臨時儲存 this 的值,從而在 exec 函式中通過 callback.apply(self, args) 傳入正確的 this 值,這種做法在閉包相關的函式呼叫中十分常用。正因為這裡對 this 的處理,所以可以實現下面的能力:

function foo() { console.log(this.name);  }

const fooWithName = throttle(200, foo);

const obj = {name: `elvin`};

fooWithName.call(obj, `elvin`);

// => `elvin`
複製程式碼

debounce 實現

由於 debouncen 只是往後推延函式的執行時間,並不具有 throttle 每隔一段時間一定會執行的能力,所以其實現起來更加簡單:

function debounce(delay, callback) {
  let timeoutID;

  function wrapper() {
    const self = this;
    const args = arguments;

    function exec() {
      callback.apply(self, args);
    }

    clearTimeout(timeoutID);

    timeoutID = setTimeout(exec, delay);
  }

  return wrapper;
}
複製程式碼

將上述程式碼與 throttle 實現的程式碼相比,可以發現其就是去除了 elapsed 相關邏輯後的程式碼,其餘大部分程式碼一模一樣,所以 debounce 函式可以藉助 throttle 函式實現(throttle-debounce 原始碼中也是這樣做的),throttle 函式也可以藉助 debounce 函式實現。

throttle 和 debounce 使用場景舉例

throttledebounce 適用於使用者短時間內頻繁執行某一相同操作的場景,例如:

  • 使用者拖動瀏覽器視窗改變視窗大小,觸發 resize 事件。
  • 使用者移動滑鼠,觸發 mousemove 等事件。
  • 使用者在輸入框內進入輸入,觸發 keydown | keypress | keyinput | keyup 等事件。
  • 使用者滾動螢幕,觸發 scroll 事件。
  • 使用者在點選按鈕後,由於 API 請求耗時未立即看到響應,可能會不斷點選按鈕觸發 click 事件。

在網上搜尋了不少資料,發現對兩個函式的使用場景有時彼此之間都互相矛盾,例如有的說在搜尋框進行輸入,應該使用 debounce 進行限流,從而減輕伺服器壓力;有的說使用 throttle 進行限流即可,可以更快地返回使用者的搜尋結果。

在我看來,並不存在一個場景,就一定是使用 throttledebounce 中的一種方法並另外一種方法好,往往需要結合自身的情況進行考慮和選擇:

  • 當事件響應函式對 CPU、GPU、流量、伺服器等資源的佔用在接受範圍內時,可以使用 throttle 進行限流帶來更好的使用者體驗。
  • 當事件響應函式對 CPU、GPU、流量、伺服器等資源的佔用較大時,可以使用 debounce 進行更強力的限流,從而減輕壓力。

寫在最後

throttle-debounce 原始碼和我前幾天所看的 Sindre 所寫的模組程式碼風格完全不同,它的程式碼中註釋的行數約為程式碼行數的三倍,而且函式的引數均有詳細的註釋,這本應是一件好事,但是對於我閱讀原始碼而言,並沒有覺得更加輕鬆,而求由於對可選引數進行的如下處理,讓我閱讀起來更加費力:

// 原始碼 4-2

/**
 *
 * @param  {Number}    delay
 * @param  {Boolean}   [noTrailing]
 * @param  {Function}  callback
 * @param  {Boolean}   [debounceMode]
 *
 * @return {Function}  A new, throttled, function.
 */
export default function ( delay, noTrailing, callback, debounceMode ) {
    // `noTrailing` defaults to falsy.
	if ( typeof noTrailing !== `boolean` ) {
		debounceMode = callback;
		callback = noTrailing;
		noTrailing = undefined;
	}
    
    // ...
}
複製程式碼

在原始碼 4-2 中,從註釋可以看出 noTrailingdebounceMode 是可選引數,delay 和 callback 為必選引數,然後它將可選引數 noTrailing 放在了必選引數 callback 之前,再在函式中的程式碼進行判斷:假如 noTrailing 為函式的話,則此值應作為 callback,然後再將 noTrailing 設為預設值 undefined

不禁感嘆這真是一番騷操作,哪怕是為了相容 ES5,也有更好的寫法,這裡說說我個人認為可用 ES6 語法時更好的寫法:

export default function (dalay, noTrailing, options = {
    callback = false,
    debounceMode = false,
} = {}) {
    // ...
}
複製程式碼

關於我:畢業於華科,工作在騰訊,elvin 的部落格 歡迎來訪 ^_^

相關文章