1. 程式人生 > >函式去抖(debounce)和函式節流(throttle)

函式去抖(debounce)和函式節流(throttle)

目的

以下場景往往由於事件頻繁被觸發,因而頻繁執行DOM操作、資源載入等重行為,導致UI停頓甚至瀏覽器崩潰。

  • window物件的resizescroll事件
  • 拖拽時的mousemove事件
  • 射擊遊戲中的mousedownkeydown事件
  • 文字輸入、自動完成的keyup事件

實際上對於window的resize事件,實際需求大多為停止改變大小n毫秒後執行後續處理;而其他事件大多的需求是以一定的頻率執行後續處理。針對這兩種需求就出現了debounce和throttle兩種解決辦法。

throttle(又稱節流)和debounce(又稱去抖)其實都是函式呼叫頻率的控制器,

debounce去抖

當呼叫函式n秒後,才會執行該動作,若在這n秒內又呼叫該函式則將取消前一次並重新計算執行時間,舉個簡單的例子,我們要根據使用者輸入做suggest,每當使用者按下鍵盤的時候都可以取消前一次,並且只關心最後一次輸入的時間就行了。

_.debounce(func, [wait=0], [options={}])

lodash在opitons引數中定義了一些選項,主要是以下三個:

  • leading,函式在每個等待時延的開始被呼叫,預設值為false
  • trailing,函式在每個等待時延的結束被呼叫,預設值是true
  • maxwait,最大的等待時間,因為如果debounce的函式呼叫時間不滿足條件,可能永遠都無法觸發,因此增加了這個配置,保證大於一段時間後一定能執行一次函式

根據leadingtrailing的組合,可以實現不同的呼叫效果:

  • leading-falsetrailing-true:預設情況,即在延時結束後才會呼叫函式
  • leading-truetrailing-true:在延時開始時就呼叫,延時結束後也會呼叫
  • leading-true, trailing-false:只在延時開始時呼叫

deboucne還有cancel方法,用於取消防抖動呼叫

下面是一些簡單的用例:

// 避免視窗在變動時出現昂貴的計算開銷。
jQuery(window).on('resize', _.debounce(calculateLayout, 150));

//
當點選時 `sendMail` 隨後就被呼叫。 jQuery(element).on('click', _.debounce(sendMail, 300, { 'leading': true, 'trailing': false })); // 確保 `batchLog` 呼叫1次之後,1秒內會被觸發。 var debounced = _.debounce(batchLog, 250, { 'maxWait': 1000 }); var source = new EventSource('/stream'); jQuery(source).on('message', debounced); // 取消一個 trailing 的防抖動呼叫 jQuery(window).on('popstate', debounced.cancel);

在學習Vue的時候,官網也用到了一個裡子,就是用於對使用者輸入的事件進行了去抖,因為使用者輸入後需要進行ajax請求,如果不進行去抖會頻繁的傳送ajax請求,所以通過debounce對ajax請求的頻率進行了限制

完整的demo在這裡。

methods: {
  // `_.debounce` 是一個通過 Lodash 限制操作頻率的函式。
  // 在這個例子中,我們希望限制訪問 yesno.wtf/api 的頻率
  // AJAX 請求直到使用者輸入完畢才會發出。想要了解更多關於
  getAnswer: _.debounce(function() {
    if (!reg.test(this.question)) {
      this.answer = 'Questions usually end with a question mark. ;-)';
      return;
    }
    this.answer = 'Thinking ... ';
    let self = this;
    axios.get('https://yesno.wtf/api')
    // then中的函式如果不是箭頭函式,則需要對this賦值self
    .then((response) = > {
      this.answer = _.capitalize(response.data.answer)
    }).
    catch ((error) = > {
      this.answer = 'Error! Could not reach the API. ' + error
    })
  }, 500) // 這是我們為判定使用者停止輸入等待的毫秒數
},

簡單的實現

一個簡單的手寫的去抖函式:

function test() {
  console.log(11)
}

function debounce(method, context) {
  clearTimeout(method.tId);
  method.tId = setTimeout(function() {
    method.call(context)
  }, 500)
}
window.onresize = function() {
  debounce(test, window);
}

lodash中debounce的原始碼學習

function debounce(func, wait, options) {
  var nativeMax = Math.max,
    toNumber,
    nativeMin

  var lastArgs,
    lastThis,
    maxWait,
    result,
    timerId,
    lastCallTime,
    // func 上一次執行的時間
    lastInvokeTime = 0,
    leading = false,
    maxing = false,
    trailing = true;

  // func必須是函式
  if (typeof func != 'function') {
    throw new TypeError(FUNC_ERROR_TEXT);
  }

  // 對間隔時間的處理
  wait = toNumber(wait) || 0;

  // 對options中傳入引數的處理
  if (isObject(options)) {
    leading = !!options.leading;
    maxing = 'maxWait' in options;
    maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait;
    trailing = 'trailing' in options ? !!options.trailing : trailing;
  }

  // 執行要被觸發的函式
  function invokeFunc(time) {
    var args = lastArgs,
      thisArg = lastThis;
    lastArgs = lastThis = undefined;
    lastInvokeTime = time;
    result = func.apply(thisArg, args);
    return result;
  }

  // 在leading edge階段執行函式
  function leadingEdge(time) {
    // Reset any `maxWait` timer.
    lastInvokeTime = time;
    // 為 trailing edge 觸發函式呼叫設定定時器
    timerId = setTimeout(timerExpired, wait);
    // leading = true 執行函式
    return leading ? invokeFunc(time) : result;
  }

  // 剩餘時間
  function remainingWait(time) {
    // 距離上次debounced函式被呼叫的時間
    var timeSinceLastCall = time - lastCallTime,
      // 距離上次函式被執行的時間
      timeSinceLastInvoke = time - lastInvokeTime,
      // 用 wait 減去 timeSinceLastCall 計算出下一次trailing的位置
      result = wait - timeSinceLastCall;
    // 兩種情況
    // 有maxing: 比較出下一次maxing和下一次trailing的最小值,作為下一次函式要執行的時間
    // 無maxing: 在下一次trailing時執行timerExpired
    return maxing ? nativeMin(result, maxWait - timeSinceLastInvoke) : result;
  }

  // 根據時間判斷 func 能否被執行
  function shouldInvoke(time) {
    var timeSinceLastCall = time - lastCallTime,
      timeSinceLastInvoke = time - lastInvokeTime;
    // 幾種滿足條件的情況
    return (lastCallTime === undefined // 首次執行
      || (timeSinceLastCall >= wait) // 距離上次被呼叫已經超過 wait
      || (timeSinceLastCall < 0)// 系統時間倒退
      || (maxing && timeSinceLastInvoke >= maxWait)); //超過最大等待時間
  }

  // 在 trailing edge 且時間符合條件時,呼叫 trailingEdge函式,否則重啟定時器
  function timerExpired() {
    var time = now();
    if (shouldInvoke(time)) {
      return trailingEdge(time);
    }
    // 重啟定時器
    timerId = setTimeout(timerExpired, remainingWait(time));
  }

  // 在trailing edge階段執行函式
  function trailingEdge(time) {
    timerId = undefined;
    // 有lastArgs才執行,
    // 意味著只有 func 已經被 debounced 過一次以後才會在 trailing edge 執行
    if (trailing && lastArgs) {
      return invokeFunc(time);
    }
    // 每次 trailingEdge 都會清除 lastArgs 和 lastThis,目的是避免最後一次函式被執行了兩次
    // 舉個例子:最後一次函式執行的時候,可能恰巧是前一次的 trailing edge,函式被呼叫,而這個函式又需要在自己時延的 trailing edge 觸發,導致觸發多次
    lastArgs = lastThis = undefined;
    return result;
  }

  // cancel方法
  function cancel() {
    if (timerId !== undefined) {
      clearTimeout(timerId);
    }
    lastInvokeTime = 0;
    lastArgs = lastCallTime = lastThis = timerId = undefined;
  }

  // flush方法--立即呼叫
  function flush() {
    return timerId === undefined ? result : trailingEdge(now());
  }

  function debounced() {
    var time = now(),
      //是否滿足時間條件
      isInvoking = shouldInvoke(time);
    lastArgs = arguments;
    lastThis = this;
    lastCallTime = time; //函式被呼叫的時間
    // 無timerId的情況有兩種:
    // 1.首次呼叫
    // 2.trailingEdge執行過函式
    if (isInvoking) {
      if (timerId === undefined) {
        return leadingEdge(lastCallTime);
      }
      if (maxing) {
        // Handle invocations in a tight loop.
        timerId = setTimeout(timerExpired, wait);
        return invokeFunc(lastCallTime);
      }
    }
    // 負責一種case:trailing 為 true 的情況下,在前一個 wait 的 trailingEdge 已經執行了函式;
    // 而這次函式被呼叫時 shouldInvoke 不滿足條件,因此要設定定時器,在本次的 trailingEdge 保證函式被執行
    if (timerId === undefined) {
      timerId = setTimeout(timerExpired, wait);
    }
    return result;
  }

  debounced.cancel = cancel;
  debounced.flush = flush;
  return debounced;
}

throttle節流

throttle將一個函式的呼叫頻率限制在一定閾值內,例如1s內一個函式不能被呼叫兩次。

同樣,lodash提供了這個方法

_.throttle(func, [wait=0], [options={}])

具體使用的例子:

// 避免在滾動時過分的更新定位
jQuery(window).on('scroll', _.throttle(updatePosition, 100));

// 點選後就呼叫 `renewToken`,但5分鐘內超過1次。
var throttled = _.throttle(renewToken, 300000, { 'trailing': false });
jQuery(element).on('click', throttled);

// 取消一個 trailing 的節流呼叫。
jQuery(window).on('popstate', throttled.cancel);

throttle同樣提供了leadingtrailing引數,與debounce含義相同

其實throttle就是設定了maxwait的debounce

注意,debounce返回的是一個經過包裝的函式,被包裝的函式必須是要立刻執行的函式。例如:

function test() {
  console.log(123)
}
setInterval(function () {
  _.debounce(test, 1500)
}, 500)

上面的效果不會是我們想要的效果,因為每次setInterval執行之後,都返回了一個沒有執行的、經過debounce包裝後的函式,所以debounce是無效的

點選事件也是同樣:

btn.addEventListener('click', function () {
  _.debounce(test, 1500)
})

上面的程式碼同樣不會生效,正確的做法是:

btn.addEventListener('click', test)
setInterval(_.debounce(test, 1500), 500)

參考