節流與防抖【從0到0.1】

慎玄發表於2019-01-04

Debounce 和 throttle 是我們在 JavaScript 中使用的兩個概念,用於增強對函式執行的控制,這在事件處理程式中特別有用。這兩種技術都回答了同一個問題“一段時間內某個函式的呼叫頻率是多少?”

? 相關連結

文中內容多數來自以下文章,侵刪!

? Debounce

1. 概念

  • 本是機械開關的“去彈跳”概念,彈簧開關按下後,由於簧片的作用,接觸點會連續接觸斷開好多次,如果每次接觸都通電對用電器不好,所以就要控制按下到穩定的這段時間不通電

  • 前端開發中則是一些頻繁的事件觸發

    • 滑鼠(mousemove...)鍵盤(keydown...)事件等
    • 表單的實時校驗(頻繁傳送驗證請求)
  • 在 debounce 函式沒有再被呼叫的情況下經過 delay 毫秒後才執行回撥函式,例如

    • mousemove事件中,確保多次觸發只呼叫一次監聽函式
    • 在表單校驗的時候,不加防抖,依次輸入user,就會分成uususe,user四次發出請求;而新增防抖,設定好時間,可以實現完整輸入user才發出校驗請求

2. 思路

  • 由 debounce 的功能可知防抖函式至少接收兩個引數(流行類庫中都是 3 個引數)

    • 回撥函式fn
    • 延時時間delay
  • debounce 函式返回一個閉包,閉包被頻繁的呼叫

    • debounce 函式只呼叫一次,之後呼叫的都是它返回的閉包函式
    • 在閉包內部限制了回撥函式fn的執行,強制只有連續操作停止後執行一次
  • 使用閉包是為了使指向定時器的變數不被gc回收

    • 實現在延時時間delay內的連續觸發都不執行回撥函式fn,使用的是在閉包內設定定時器setTimeOut
    • 頻繁呼叫這個閉包,在每次呼叫時都要將上次呼叫的定時器清除
    • 被閉包儲存的變數就是指向上一次設定的定時器

3. 實現

  • 符合原理的簡單實現

    function debounce(fn, delay) {
      var timer;
      return function() {
        // 清除上一次呼叫時設定的定時器
        // 計時器清零
        clearTimeout(timer);
        // 重新設定計時器
        timer = setTimeout(fn, delay);
      };
    }
    複製程式碼
  • 簡單實現的程式碼,可能會造成兩個問題

    • this指向問題。debounce 函式在定時器中呼叫回撥函式fn,所以fn執行的時候this指向全域性物件(瀏覽器中window),需要在外層用變數將this儲存下來,使用apply進行顯式繫結

      function debounce(fn, delay) {
        var timer;
        return function() {
          // 儲存呼叫時的this
          var context = this;
          clearTimeout(timer);
          timer = setTimeout(function() {
            // 修正 this 的指向
            fn.apply(this);
          }, delay);
        };
      }
      複製程式碼
    • event物件。JavaScript 的事件處理函式中會提供事件物件event,在閉包中呼叫時需要將這個事件物件傳入

      function debounce(fn, delay) {
        var timer;
        return function() {
          // 儲存呼叫時的this
          var context = this;
          // 儲存引數
          var args = arguments;
          clearTimeout(timer);
          timer = setTimeout(function() {
            console.log(context);
            // 修正this,並傳入引數
            fn.apply(context, args);
          }, delay);
        };
      }
      複製程式碼

4. 完善(underscore的實現)

  • 立刻執行。增加第三個引數,兩種情況

    • 先執行回撥函式fn,等到停止觸發後的delay毫秒,才可以再次觸發(先執行
    • 連續的呼叫 debounce 函式不觸發回撥函式,停止呼叫經過delay毫秒後才執行回撥函式(後執行
    • clearTimeout(timer)後,timer並不會變成null,而是依然指向定時器物件
    function debounce(fn, delay, immediate) {
      var timer;
      return function() {
        var context = this;
        var args = arguments;
        // 停止定時器
        if (timer) clearTimeout(timer);
        // 回撥函式執行的時機
        if (immediate) {
          // 是否已經執行過
          // 執行過,則timer指向定時器物件,callNow 為 false
          // 未執行,則timer 為 null,callNow 為 true
          var callNow = !timer;
          // 設定延時
          timer = setTimeout(function() {
            timer = null;
          }, delay);
          if (callNow) fn.apply(context, args);
        } else {
          // 停止呼叫後delay時間才執行回撥函式
          timer = setTimeout(function() {
            fn.apply(context, args);
          }, delay);
        }
      };
    }
    複製程式碼
  • 返回值與取消 debounce 函式

    • 回撥函式可能有返回值。
      • 後執行情況可以不考慮返回值,因為在執行回撥函式前的這段時間裡,返回值一直是undefined
      • 先執行情況,會先得到返回值
    • 能取消 debounce 函式。一般當immediatetrue的時候,觸發一次後要等待delay時間後才能再次觸發,但是想要在這個時間段內想要再次觸發,可以先取消掉之前的 debounce 函式
    function debounce(fn, delay, immediate) {
      var timer, result;
      var debounced = function() {
        var context = this;
        var args = arguments;
        // 停止定時器
        if (timer) clearTimeout(timer);
        // 回撥函式執行的時機
        if (immediate) {
          // 是否已經執行過
          // 執行過,則timer指向定時器物件,callNow 為 false
          // 未執行,則timer 為 null,callNow 為 true
          var callNow = !timer;
          // 設定延時
          timer = setTimeout(function() {
            timer = null;
          }, delay);
          if (callNow) result = fn.apply(context, args);
        } else {
          // 停止呼叫後delay時間才執行回撥函式
          timer = setTimeout(function() {
            fn.apply(context, args);
          }, delay);
        }
        // 返回回撥函式的返回值
        return result;
      };
    
      // 取消操作
      debounced.cancel = function() {
        clearTimeout(timer);
        timer = null;
      };
    
      return debounced;
    }
    複製程式碼
  • ES6 寫法

    function debounce(fn, delay, immediate) {
      let timer, result;
      // 這裡不能使用箭頭函式,不然 this 依然會指向 Windows物件
      // 使用rest引數,獲取函式的多餘引數
      const debounced = function(...args) {
        if (timer) clearTimeout(timer);
        if (immediate) {
          const callNow = !timer;
          timer = setTimeout(() => {
            timer = null;
          }, delay);
          if (callNow) result = fn.apply(this, args);
        } else {
          timer = setTimeout(() => {
            fn.apply(this, args);
          }, delay);
        }
        return result;
      };
    
      debounced.cancel = () => {
        clearTimeout(timer);
        timer = null;
      };
    
      return debounced;
    }
    複製程式碼

? throttle

1. 概念

  • 固定函式執行的速率

  • 如果持續觸發事件,每隔一段時間,執行一次事件

    • 例如監聽mousemove事件時,不管滑鼠移動的速度,【節流】後的監聽函式會在 wait 秒內最多執行一次,並以此【勻速】觸發執行
  • windowresizescroll事件的優化等

2. 思路

  • 有兩種主流實現方式

    • 使用時間戳
    • 設定定時器
  • 節流函式 throttle 呼叫後返回一個閉包

    • 閉包用來儲存之前的時間戳或者定時器變數(因為變數被返回的函式引用,所以無法被垃圾回收機制回收
  • 時間戳方式

    • 當觸發事件的時候,取出當前的時間戳,然後減去之前的時間戳(初始設定為 0)
    • 結果大於設定的時間週期,則執行函式,然後更新時間戳為當前時間戳
    • 結果小於設定的時間週期,則不執行函式
  • 定時器方式

    • 當觸發事件的時候,設定一個定時器
    • 再次觸發事件的時候,如果定時器存在,就不執行,知道定時器執行,然後執行函式,清空定時器
    • 設定下個定時器
  • 將兩種方式結合,可以實現兼併立刻執行和停止觸發後依然執行一次的效果

3. 實現

  • 時間戳實現

    function throttle(fn, wait) {
      var args;
      // 前一次執行的時間戳
      var previous = 0;
      return function() {
        // 將時間轉為時間戳
        var now = +new Date();
        args = arguments;
        // 時間間隔大於延遲時間才執行
        if (now - previous > wait) {
          fn.apply(this, args);
          previous = now;
        }
      };
    }
    複製程式碼
    • 觸發監聽事件,回撥函式會立刻執行(初始的previous為 0,除非設定的時間間隔大於當前時間的時間戳,否則差值肯定大於時間間隔)
    • 停止觸發後,無論停止時間在哪,都不會再執行。例如,1 秒執行 1 次,在 4.2 秒停止,則第 5 秒不會再執行 1 次
  • 定時器實現

    function throttle(fn, wait) {
      var timer, context, args;
      return function() {
        context = this;
        args = arguments;
        // 如果定時器存在,則不執行
        if (!timer) {
          timer = setTimeout(function() {
            // 執行後釋放定時器變數
            timer = null;
            fn.apply(context, args);
          }, wait);
        }
      };
    }
    複製程式碼
    • 回撥函式不會立刻執行,要在 wait 秒後第一次執行,停止觸發閉包後,如果停止時間在兩次執行之間,則還會執行一次
  • 結合時間戳和定時器實現

    function throttle(fn, wait) {
      var timer, context, args;
      var previous = 0;
      // 延時執行函式
      var later = function() {
        previous = +new Date();
        // 執行後釋放定時器變數
        timer = null;
        fn.apply(context, args);
        if (!timeout) context = args = null;
      };
      var throttled = function() {
        var now = +new Date();
        // 距離下次執行 fn 的時間
        // 如果人為修改系統時間,可能出現 now 小於 previous 情況
        // 則剩餘時間可能超過時間週期 wait
        var remaining = wait - (now - previous);
        context = this;
        args = arguments;
        // 沒有剩餘時間 || 修改系統時間導致時間異常,則會立即執行回撥函式fn
        // 初次呼叫時,previous為0,除非wait大於當前時間的時間戳,否則剩餘時間一定小於0
        if (remaining <= 0 || remaining > wait) {
          // 如果存在延時執行定時器,將其取消掉
          if (timer) {
            clearTimeout(timer);
            timer = null;
          }
          previous = now;
          fn.apply(context, args);
          if (!timeout) context = args = null;
        } else if (!timer) {
          // 設定延時執行
          timer = setTimeout(later, remaining);
        }
      };
      return throttled;
    }
    複製程式碼
    • 過程中的節流功能是由時間戳的原理實現,同時實現了立刻執行
    • 定時器只是用來設定在最後退出時增加一個延時執行
    • 定時器在每次觸發時都會重新計時,但是隻要不停止觸發,就不會去執行回撥函式 fn

4. 優化完善

  • 增加第三個引數,讓使用者可以自己選擇模式

    • 忽略開始邊界上的呼叫,傳入{ leading: false }
    • 忽略結尾邊界上的呼叫,傳入{ trailing: false }
  • 增加返回值功能

  • 增加取消功能

    function throttle(func, wait, options) {
      var context, args, result;
      var timeout = null;
      // 上次執行時間點
      var previous = 0;
      if (!options) options = {};
      // 延遲執行函式
      var later = function() {
        // 若設定了開始邊界不執行選項,上次執行時間始終為0
        previous = options.leading === false ? 0 : new Date().getTime();
        timeout = null;
        // func 可能會修改 timeout 變數
        result = func.apply(context, args);
        // 定時器變數引用為空,表示最後一次執行,則要清除閉包引用的變數
        if (!timeout) context = args = null;
      };
      var throttled = function() {
        var now = new Date().getTime();
        // 首次執行時,如果設定了開始邊界不執行選項,將上次執行時間設定為當前時間。
        if (!previous && options.leading === false) previous = now;
        // 延遲執行時間間隔
        var remaining = wait - (now - previous);
        context = this;
        args = arguments;
        // 延遲時間間隔remaining小於等於0,表示上次執行至此所間隔時間已經超過一個時間視窗
        // remaining 大於時間視窗 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;
      };
      throttled.cancel = function() {
        clearTimeout(timeout);
        previous = 0;
        timeout = context = args = null;
      };
      return throttled;
    }
    複製程式碼
    • 有個問題,leading: falsetrailing: false 不能同時設定
      • 第一次開始邊界不執行,但是,第一次觸發時,previous為 0,則remaining值和wait相等。所以,if (!previous && options.leading === false)為真,改變了previous的值,而if (remaining <= 0 || remaining > wait)為假
      • 以後再觸發就會導致if (!previous && options.leading === false)為假,而if (remaining <= 0 || remaining > wait)為真。就變成了開始邊界執行。這樣就和leading: false衝突了

? 總結

  • 至此,完整實現了一個underscore中的 debounce 函式和 throttle 函式
  • lodash中 debounce 函式和 throttle 函式的實現更加複雜,封裝更加徹底
  • 推薦兩個視覺化執行過程的工具
  • 自己實現是為了學習其中的思想,實際開發中儘量使用 lodash 或 underscore 這樣的類庫。

對比

  • throttle 和 debounce 是解決請求和響應速度不匹配問題的兩個方案。二者的差異在於選擇不同的策略

  • 電梯超時現象解釋兩者區別。假設電梯設定為 15 秒,不考慮容量限制

    • throttle策略:保證如果電梯第 1 個人進來後,15 秒後準時送一次,不等待。如果沒有人,則待機、
    • debounce策略:如果電梯有人進來,等待 15 秒,如果又有人進來,重新計時 15 秒,直到 15 秒超時都沒有人再進來,則開始運送

相關文章