每日原始碼分析 - lodash(debounce.js和throttle.js)

樑王發表於2017-12-18

本系列使用 lodash 4.17.4

前言

本檔案引用了isObject函式

import isObject from './isObject.js' 判斷變數是否是廣義的物件(物件、陣列、函式), 不包括null

正文

import isObject from './isObject.js'

/**
 * Creates a debounced function that delays invoking `func` until after `wait`
 * milliseconds have elapsed since the last time the debounced function was
 * invoked. The debounced function comes with a `cancel` method to cancel
 * delayed `func` invocations and a `flush` method to immediately invoke them.
 * Provide `options` to indicate whether `func` should be invoked on the
 * leading and/or trailing edge of the `wait` timeout. The `func` is invoked
 * with the last arguments provided to the debounced function. Subsequent
 * calls to the debounced function return the result of the last `func`
 * invocation.
 *
 * **Note:** If `leading` and `trailing` options are `true`, `func` is
 * invoked on the trailing edge of the timeout only if the debounced function
 * is invoked more than once during the `wait` timeout.
 *
 * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred
 * until the next tick, similar to `setTimeout` with a timeout of `0`.
 *
 * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
 * for details over the differences between `debounce` and `throttle`.
 *
 * @since 0.1.0
 * @category Function
 * @param {Function} func The function to debounce.
 * @param {number} [wait=0] The number of milliseconds to delay.
 * @param {Object} [options={}] The options object.
 * @param {boolean} [options.leading=false]
 *  Specify invoking on the leading edge of the timeout.
 * @param {number} [options.maxWait]
 *  The maximum time `func` is allowed to be delayed before it's invoked.
 * @param {boolean} [options.trailing=true]
 *  Specify invoking on the trailing edge of the timeout.
 * @returns {Function} Returns the new debounced function.
 * @example
 *
 * // Avoid costly calculations while the window size is in flux.
 * jQuery(window).on('resize', debounce(calculateLayout, 150))
 *
 * // Invoke `sendMail` when clicked, debouncing subsequent calls.
 * jQuery(element).on('click', debounce(sendMail, 300, {
 *   'leading': true,
 *   'trailing': false
 * }))
 *
 * // Ensure `batchLog` is invoked once after 1 second of debounced calls.
 * const debounced = debounce(batchLog, 250, { 'maxWait': 1000 })
 * const source = new EventSource('/stream')
 * jQuery(source).on('message', debounced)
 *
 * // Cancel the trailing debounced invocation.
 * jQuery(window).on('popstate', debounced.cancel)
 *
 * // Check for pending invocations.
 * const status = debounced.pending() ? "Pending..." : "Ready"
 */
function debounce(func, wait, options) {
  let lastArgs,
    lastThis,
    maxWait,
    result,
    timerId,
    lastCallTime

  let lastInvokeTime = 0
  let leading = false
  let maxing = false
  let trailing = true

  if (typeof func != 'function') {
    throw new TypeError('Expected a function')
  }
  wait = +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
  }

  function leadingEdge(time) {
    // Reset any `maxWait` timer.
    lastInvokeTime = time
    // Start the timer for the trailing edge.
    timerId = setTimeout(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

    // Either this is the first call, activity has stopped and we're at the
    // trailing edge, the system time has gone backwards and we're treating
    // it as the trailing edge, or we've hit the `maxWait` limit.
    return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
      (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
  }

  function timerExpired() {
    const time = Date.now()
    if (shouldInvoke(time)) {
      return trailingEdge(time)
    }
    // Restart the timer.
    timerId = setTimeout(timerExpired, remainingWait(time))
  }

  function trailingEdge(time) {
    timerId = undefined

    // Only invoke if we have `lastArgs` which means `func` has been
    // debounced at least once.
    if (trailing && lastArgs) {
      return invokeFunc(time)
    }
    lastArgs = lastThis = undefined
    return result
  }

  function cancel() {
    if (timerId !== undefined) {
      clearTimeout(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 = setTimeout(timerExpired, wait)
        return invokeFunc(lastCallTime)
      }
    }
    if (timerId === undefined) {
      timerId = setTimeout(timerExpired, wait)
    }
    return result
  }
  debounced.cancel = cancel
  debounced.flush = flush
  debounced.pending = pending
  return debounced
}

export default debounce

複製程式碼

使用方式

函式防抖(debounce)

函式防抖(debounce)和函式節流(throttle)相信有一定前端基礎的應該都知道,不過還是簡單說一下

防抖(debounce)就是把多個順序的呼叫合併到一起(只執行一次),這在某些情況下對效能會有極大的優化(後面使用場景會說幾個)。

圖片來自css-tricks

debounce

在lodash的options中提供了一個leading屬性,這個屬性讓其在開始的時候觸發。

圖片來自css-tricks

leading

// debounce函式的簡單使用
var log = function() {
    console.log("log after stop moving");
}
document.addEventListener('mousemove', debounce(log, 500))
複製程式碼

函式節流(throttle)

使用throttle時,只允許一個函式在 X 毫秒內執行一次。

比如你設定了400ms,那麼即使你在這400ms裡面呼叫了100次,也只有一次執行。跟 debounce 主要的不同在於,throttle 保證 X 毫秒內至少執行一次。

在lodash的實現中,throttle主要藉助了debounce來實現。

// throttle函式的簡單使用
var log = function() {
    console.log("log every 500ms");
}
document.addEventListener('mousemove', throttle(log, 500))
複製程式碼

使用場景

我儘量總結一下debounce和throttle函式實際的應用場景

防抖(debounce)

1. 自動補全(autocomplete)效能優化

每日原始碼分析 - lodash(debounce.js和throttle.js)
自動補全很多地方都有,基本無一例外都是通過發出非同步請求將當前內容作為引數傳給伺服器,然後伺服器回傳備選項。

那麼問題來了,如果我每輸入一個字元都要發出個非同步請求,那麼非同步請求的個數會不會太多了呢?因為實際上使用者可能只需要輸入完後給出的備選項

這時候就可以使用防抖,比如當輸入框input事件觸發隔了1000ms的時候我再發起非同步請求。

2. 原生事件效能優化

想象一下,我有個使用js進行自適應的元素,那麼很自然,我需要考慮我瀏覽器視窗發生resize事件的時候我要去重新計算它的位置。現在問題來了,我們看看resize一次觸發多少次。

window.addEventListener('resize', function() {
  console.log('resize')
})
複製程式碼

至少在我電腦上,稍微改變一下就會觸發幾次resize事件,而用js去自適應的話會有較多的DOM操作,我們都知道DOM操作很浪費時間,所以對於resize事件我們是不是可以用debounce讓它最後再計算位置?當然如果你覺得最後才去計算位置或者一些屬性會不太即時,你可以繼續往下看看函式節流(throttle)

每日原始碼分析 - lodash(debounce.js和throttle.js)

節流(throttle)

和防抖一樣,節流也可以用於原生事件的優化。我們看下面幾個例子

圖片懶載入

每日原始碼分析 - lodash(debounce.js和throttle.js)
圖片懶載入(lazyload)可能很多人都知道,如果我們瀏覽一個圖片很多的網站的話,我們不希望所有的圖片在一開始就載入了,一是浪費流量,可能使用者不關心下面的圖片呢。二是效能,那麼多圖片一起下載,效能爆炸。

那麼一般我們都會讓圖片懶載入,讓一個圖片一開始在頁面中的標籤為

<img src="#" data-src="我是真正的src">
複製程式碼

當我螢幕滾動到能顯示這個img標籤的位置時,我用data-src去替換src的內容,變為

<img src="我是真正的src" data-src="我是真正的src">
複製程式碼

大家都知道如果直接改變src的話瀏覽器也會直接發出一個請求,在紅寶書(JS高程)裡面的跨域部分還提了一下用img標籤的src做跨域。這時候圖片才會顯示出來。

關於怎麼判斷一個元素出現在螢幕中的,大家可以去看看這個函式getBoundingClientRect(),這裡就不擴充套件的講了

好的,那麼問題來了,我既然要檢測元素是否在瀏覽器內,那我肯定得在scroll事件上繫結檢測函式吧。scroll函式和resize函式一樣,滑動一下事件觸發幾十上百次,讀者可以自己試一下。

document.addEventListener('scroll', function() {
  console.log('scroll')
})
複製程式碼

好的,你的檢測元素是否在瀏覽器內的函式每次要檢查所有的img標籤(至少是所有沒有替換src的),而且滑一次要執行幾十次,你懂我的意思。

throttle正是你的救星,你可以讓檢測函式每300ms執行一次。

拖動和拉伸

你以為你只需要防備resizescroll麼,太天真了,看下面幾個例子。

每日原始碼分析 - lodash(debounce.js和throttle.js)
或者想做類似原生視窗調整大小的效果

每日原始碼分析 - lodash(debounce.js和throttle.js)
那麼你一定會需要mousedownmouseupmousemove事件,前兩個用於拖動的開始和結束時的狀態變化(比如你要加個標識標識開始拖動了)。mousemove則是用來調整元素的位置或者寬高。那麼同樣的我們來看看mousemove事件的觸發頻率。

document.addEventListener('mousemove', function() {
  console.log('mousemove')
})
複製程式碼

我相信你現在已經知道它比scroll還恐怖而且可以讓效能瞬間爆炸。那麼這時候我們就可以用函式節流讓它300ms觸發一次位置計算。

原始碼分析

debounce.js

這個檔案的核心和入口是debounced函式,我們先看看它

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

  lastArgs = args       // 記錄最後一次呼叫傳入的引數
  lastThis = this       // 記錄最後一次呼叫的this
  lastCallTime = time   // 記錄最後一次呼叫的時間

  if (isInvoking) {
    if (timerId === undefined) {
      return leadingEdge(lastCallTime)
    }
    if (maxing) {
      // Handle invocations in a tight loop.
      timerId = setTimeout(timerExpired, wait)
      return invokeFunc(lastCallTime)
    }
  }
  if (timerId === undefined) {
    timerId = setTimeout(timerExpired, wait)
  }
  return result
}
複製程式碼

這裡面很多變數,用閉包存下的一些值

每日原始碼分析 - lodash(debounce.js和throttle.js)
其實就是儲存最後一次呼叫的上下文(lastThis, lastAargs, lastCallTime)還有定時器的Id之類的。

然後下面是執行部分, 由於maxing是和throttle有關的,為了理解方便這裡暫時不看它。

  // isInvoking可以暫時理解為第一次或者當上一次觸發時間超過設定wait的時候為真
  if (isInvoking) {
    // 第一次觸發的時候沒有加timer
    if (timerId === undefined) {
      // 和上文說的leading有關
      return leadingEdge(lastCallTime)
    }
    //if (maxing) {
    //  // Handle invocations in a tight loop.
    //  timerId = setTimeout(timerExpired, wait)
    //  return invokeFunc(lastCallTime)
    //}
  }
  // 第一次觸發的時候新增定時器
  if (timerId === undefined) {
    timerId = setTimeout(timerExpired, wait)
  }
複製程式碼

接下來我們看看這個timerExpired的內容

  function timerExpired() {
    const time = Date.now()
    // 這裡的這個判斷基本只用作判斷timeSinceLastCall是否超過設定的wait
    if (shouldInvoke(time)) {
      // 實際呼叫函式部分
      return trailingEdge(time)
    }
    // 如果timeSinceLastCall還沒超過設定的wait,重置定時器之後再進一遍timerExpired
    timerId = setTimeout(timerExpired, remainingWait(time))
  }
複製程式碼

trailingEdge函式其實就是執行一下invokeFunc然後清空一下定時器還有一些上下文,這樣下次再執行debounce過的函式的時候就能夠繼續下一輪了,沒什麼值得說的

  function trailingEdge(time) {
    timerId = undefined

    // Only invoke if we have `lastArgs` which means `func` has been
    // debounced at least once.
    if (trailing && lastArgs) {
      return invokeFunc(time)
    }
    lastArgs = lastThis = undefined
    return result
  }
複製程式碼

總結一下其實就是下面這些東西,不過提供了一些配置和可複用性(throttle部分)所以程式碼就複雜了些。

// debounce簡單實現
var debounce = function(wait, func){
  var timerId
  return function(){
    var thisArg = this, args = arguments
    clearTimeout(last)
    timerId = setTimeout(function(){
        func.apply(thisArg, args)
    }, wait)
  }
}
複製程式碼

throttle.js

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

  if (typeof func != 'function') {
    throw new TypeError('Expected a function')
  }
  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
  })
}
複製程式碼

其實基本用的都是debounce.js裡面的內容,只是多了個maxWait引數,還記得之前分析debounce的時候被我們註釋的部分麼。

  if (isInvoking) {
    if (timerId === undefined) {
      return leadingEdge(lastCallTime)
    }
    // **看這裡**,如果有maxWait那麼maxing就為真
    if (maxing) {
      // Handle invocations in a tight loop.
      timerId = setTimeout(timerExpired, wait)
      return invokeFunc(lastCallTime)
    }
  }
  if (timerId === undefined) {
    timerId = setTimeout(timerExpired, wait)
  }
複製程式碼

可以看到remainingWait和shouldInvoke中也都對maxing進行了判斷

每日原始碼分析 - lodash(debounce.js和throttle.js)
總結一下其實就是下面這樣

// throttle的簡單實現,定時器都沒用
var throttle = function(wait, func){
  var last = 0
  return function(){
    var time = +new Date()
    if (time - last > wait){
      func.apply(this, arguments)
      last = curr 
    }
  }
}
複製程式碼

本文章來源於午安煎餅計劃Web組 - 樑王

相關文章