1. 程式人生 > >“淺入淺出”函式防抖(debounce)與節流(throttle)

“淺入淺出”函式防抖(debounce)與節流(throttle)

函式防抖與節流是日常開發中經常用到的技巧,也是前端面試中的常客,但是發現自己工作一年多了,要麼直接複用已有的程式碼或工具,要麼抄襲《JS高階程式設計》書中所述“函式節流”,(實際上紅寶書上的實現類似是函式防抖而不是函式節流),還沒有認真的總結和親自實現這兩個方法,實在是一件蠻丟臉的事。網上關於這方面的資料簡直就像是中國知網上的“水論文”,又多又雜,難覓精品,當然,本文也是一篇很水的文章,只當是個人理解順便備忘,畢竟年紀大了,記憶力下降嚴重。CSS-Tricks上這篇文章Debouncing and Throttling Explained Through Examples算是非常通識的博文,值得一讀。

函式防抖與節流的區別及應用場合

關於函式常規、防抖、節流三種執行方式的區別可以通過下面的例子直觀的看出來

函式防抖和節流都能控制一段時間內函式執行的次數,簡單的說,它們之間的區別及應用:

  • 函式防抖: 將本來短時間內爆發的一組事件組合成單個事件來觸發。等電梯就是一個非常形象的比喻,電梯不會立即上行,而是等待一段時間內沒有人再上電梯了才上行,換句話說此時函式執行時一陣一陣的,如果一直有人上電梯,電梯就永遠不會上行。

使用場合:使用者輸入關鍵詞實時搜尋,如果使用者每輸入一個字元就發請求搜尋一次,就太浪費網路,頁面效能也差;再比如縮放瀏覽器視窗事件;再再比如頁面滾動埋點

  • 函式節流: 控制持續快速觸發的一系列事件每隔'X'毫秒執行一次,就像Magic把瓢潑大雨程式設計了綿綿細雨。

使用場合:頁面滾動過程中不斷統計離底部距離以便懶載入。

函式防抖與節流的簡易實現

如果應用場合比較常規,根據上述函式防抖和節流的概念,程式碼實現還是比較簡單的:
簡易防抖工具函式實現如下:

function debounce(func, wait) {
  let timerId
  return function(...args) {
    timerId && clearTimeout(timerId)
    timerId = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}

防抖高階函式實現很簡單,瞄一眼就懂,但是仍要注意:程式碼第三行返回的函式並沒有使用箭頭函式,目的是在事件執行時確定上下文,節流的高階函式實現起來相對複雜一點。

function throttle(func, wait = 100) {
  let timerId
  let start = Date.now()
  return function(...args) {
    const curr = Date.now()
    clearTimeout(timerId)
    if (curr - start >= wait) {// 可以保證func一定會被執行
      func.apply(this, args)
      start = curr
    } else {
      timerId = setTimeout(() => {
        func.apply(this, args)
      }, wait)
    }
  }
}

Lodash函式防抖(debounce)與節流(throttle)原始碼精讀

上面的基本實現大致滿足絕大多數場景的需求,但是Lodash庫中的實現則更加完備,下面我們一起看看其原始碼實現。

import isObject from "./isObject.js"
import root from "./.internal/root.js"

function debounce(func, wait, options) {
  /**
   * maxWait 最長等待執行時間
   * lastCallTime 事件上次觸發的時間,由於函式防抖,真正的事件處理程式並不一定會執行
   */
  let lastArgs, lastThis, maxWait, result, timerId, lastCallTime 


  let lastInvokeTime = 0 // 上一次函式真正呼叫的時間戳
  let leading = false // 是否在等待時間的起始端觸發函式呼叫
  let maxing = false //
  let trailing = true // 是否在等待時間的結束端觸發函式呼叫

  // 如果沒有傳入wait引數,檢測requestAnimationFrame方法是否可以,以便後面代替setTimeout,預設等待時間約16ms
  const useRAF =
    !wait && wait !== 0 && typeof root.requestAnimationFrame === "function"

  if (typeof func != "function") {
    // 必須傳入函式
    throw new TypeError("Expected a function")
  }
  wait = +wait || 0 // wait引數轉換成數字,或設定預設值0
  if (isObject(options)) {
    // 規範化引數
    leading = !!options.leading
    maxing = "maxWait" in options
    maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait
    trailing = "trailing" in options ? !!options.trailing : trailing
  }
  // 呼叫真正的函式,入參是呼叫函式時間戳
  function invokeFunc(time) {
    const args = lastArgs
    const thisArg = lastThis

    lastArgs = lastThis = undefined
    lastInvokeTime = time
    result = func.apply(thisArg, args)
    return result
  }
  // 開啟計時器方法,返回定時器id
  function startTimer(pendingFunc, wait) {
    if (useRAF) {
      // 如果沒有傳入wait引數,約16ms後執行
      return root.requestAnimationFrame(pendingFunc)
    }
    return setTimeout(pendingFunc, wait)
  }
  // 取消定時器
  function cancelTimer(id) {
    if (useRAF) {
      return root.cancelAnimationFrame(id)
    }
    clearTimeout(id)
  }
  //等待時間起始端呼叫事件處理程式
  function leadingEdge(time) {
    // Reset any `maxWait` timer.
    lastInvokeTime = time
    // Start the timer for the trailing edge.
    timerId = startTimer(timerExpired, wait)
    // Invoke the leading edge.
    return leading ? invokeFunc(time) : result
  }

  function remainingWait(time) {
    // 事件上次觸發到現在的經歷的時間
    const timeSinceLastCall = time - lastCallTime
    // 事件處理函式上次真正執行到現在經歷的時間
    const timeSinceLastInvoke = time - lastInvokeTime
    // 等待觸發的時間
    const timeWaiting = wait - timeSinceLastCall
    // 如果使用者設定了最長等待時間,則需要取最小值
    return maxing
      ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
      : timeWaiting
  }
  // 判斷某個時刻是否允許呼叫真正的事件處理程式
  function shouldInvoke(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeSinceLastInvoke = time - lastInvokeTime
    return (
      lastCallTime === undefined || // 如果是第一次呼叫,則一定允許
      timeSinceLastCall >= wait || // 等待時間超過設定的時間
      timeSinceLastCall < 0 ||   // 當前時刻早於上次事件觸發時間,比如說調整了系統時間
      (maxing && timeSinceLastInvoke >= maxWait) // 等待時間超過最大等待時間
    )
  }
  // 計時器時間到期執行的回撥
  function timerExpired() {
    const time = Date.now()
    if (shouldInvoke(time)) {
      return trailingEdge(time)
    }
    // 重新啟動計時器
    timerId = startTimer(timerExpired, remainingWait(time))
  }

  function trailingEdge(time) {
    timerId = undefined

      // 只有當事件至少發生過一次且配置了末端觸發才呼叫真正的事件處理程式,
    // 意思是如果程式設定了末端觸發,且沒有設定最大等待時間,但是事件自始至終只觸發了一次,則真正的事件處理程式永遠不會執行
    if (trailing && lastArgs) {
      return invokeFunc(time)
    }
    lastArgs = lastThis = undefined
    return result
  }
  // 取消執行
  function cancel() {
    if (timerId !== undefined) {
      cancelTimer(timerId)
    }
    lastInvokeTime = 0
    lastArgs = lastCallTime = lastThis = timerId = undefined
  }
  // 立即觸發一次事件處理程式呼叫
  function flush() {
    return timerId === undefined ? result : trailingEdge(Date.now())
  }
  // 查詢是否處於等待執行中
  function pending() {
    return timerId !== undefined
  }

  function debounced(...args) {
    const time = Date.now()
    const isInvoking = shouldInvoke(time)

    lastArgs = args
    lastThis = this
    lastCallTime = time

    if (isInvoking) {
      if (timerId === undefined) {
        return leadingEdge(lastCallTime)
      }
      if (maxing) {
        // Handle invocations in a tight loop.
        timerId = startTimer(timerExpired, wait)
        return invokeFunc(lastCallTime)
      }
    }
    if (timerId === undefined) {
      timerId = startTimer(timerExpired, wait)
    }
    return result
  }
  debounced.cancel = cancel
  debounced.flush = flush
  debounced.pending = pending
  return debounced
}
export default debounce

Lodash中throttle直接使用debounce實現,說明節流可以當作防抖的一種特殊情況。

function throttle(func, wait, options) {
  var leading = true,
      trailing = true;

  if (typeof func != 'function') {
    throw new TypeError(FUNC_ERROR_TEXT);
  }
  if (isObject(options)) {
    leading = 'leading' in options ? !!options.leading : leading;
    trailing = 'trailing' in options ? !!options.trailing : trailing;
  }
  return debounce(func, wait, {
    'leading': leading,
    'maxWait': wait,
    'trailing': trailing
  });
}