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

本系列使用 lodash 4.17.4css

前言

本文件引用了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 毫秒內執行一次。app

好比你設置了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)性能優化

自動補全不少地方都有,基本無一例外都是經過發出異步請求將當前內容做爲參數傳給服務器,而後服務器回傳備選項。

那麼問題來了,若是我每輸入一個字符都要發出個異步請求,那麼異步請求的個數會不會太多了呢?由於實際上用戶可能只須要輸入完後給出的備選項

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

2. 原生事件性能優化

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

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

至少在我電腦上,稍微改變一下就會觸發幾回resize事件,而用js去自適應的話會有較多的DOM操做,咱們都知道DOM操做很浪費時間,因此對於resize事件咱們是否是能夠用debounce讓它最後再計算位置?固然若是你以爲最後纔去計算位置或者一些屬性會不太即時,你能夠繼續往下看看函數節流(throttle)

節流(throttle)

和防抖同樣,節流也能夠用於原生事件的優化。咱們看下面幾個例子

圖片懶加載

圖片懶加載(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麼,太天真了,看下面幾個例子。

或者想作相似原生窗口調整大小的效果

那麼你必定會須要 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
}
複製代碼

這裏面不少變量,用閉包存下的一些值

其實就是保存最後一次調用的上下文(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進行了判斷

總結一下其實就是下面這樣

// 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組 - 梁王

相關文章
相關標籤/搜索