節流與防抖【從0到0.1】

Debounce 和 throttle 是咱們在 JavaScript 中使用的兩個概念,用於加強對函數執行的控制,這在事件處理程序中特別有用。這兩種技術都回答了同一個問題「一段時間內某個函數的調用頻率是多少?」前端

📚 相關連接

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

📚 Debounce

1. 概念

  • 本是機械開關的「去彈跳」概念,彈簧開關按下後,因爲簧片的做用,接觸點會連續接觸斷開好屢次,若是每次接觸都通電對用電器很差,因此就要控制按下到穩定的這段時間不通電github

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

    • 鼠標(mousemove...)鍵盤(keydown...)事件等
    • 表單的實時校驗(頻繁發送驗證請求)
  • 在 debounce 函數沒有再被調用的狀況下通過 delay 毫秒後才執行回調函數,例如閉包

    • mousemove事件中,確保屢次觸發只調用一次監聽函數
    • 在表單校驗的時候,不加防抖,依次輸入user,就會分紅uususe,user四次發出請求;而添加防抖,設置好時間,能夠實現完整輸入user才發出校驗請求

2. 思路

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

    • 回調函數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 秒超時都沒有人再進來,則開始運送
相關文章
相關標籤/搜索