函數防抖(debounce)和節流(throttle)以及lodash的debounce源碼賞析

函數節流和去抖的出現場景,通常都伴隨着客戶端 DOM 的事件監聽。好比scroll resize等事件,這些事件在某些場景觸發很是頻繁。
好比,實現一個原生的拖拽功能(不能用 H5 Drag&Drop API),須要一路監聽 mousemove 事件,在回調中獲取元素當前位置,而後重置 dom 的位置(樣式改變)。若是咱們不加以控制,每移動必定像素而觸發的回調數量是會很是驚人的,回調中又伴隨着 DOM 操做,繼而引起瀏覽器的重排與重繪,性能差的瀏覽器可能就會直接假死,這樣的用戶體驗是很是糟糕的。
咱們須要作的是下降觸發回調的頻率,好比讓它 500ms 觸發一次,或者 200ms,甚至 100ms,這個閾值不能太大,太大了拖拽就會失真,也不能過小,過小了低版本瀏覽器可能就會假死,這樣的解決方案就是函數節流,英文名字叫「throttle」。git

節流(throttle)

函數節流的核心是,讓一個函數不要執行得太頻繁,減小一些過快的調用來節流。也就是在一段固定的時間內只觸發一次回調函數,即使在這段時間內某個事件屢次被觸發也只觸發回調一次。github

防抖(debounce)

函數防抖(debounce)和節流是一對經常被放在一塊兒的場景。防抖的原理是在事件被觸發n秒後再執行回調,若是在這n秒內又被觸發,則從新計時。也就是說事件來了,先setTimeout定個時,n秒後再去觸發回調函數。它和節流的不一樣在於若是某段時間內事件以間隔小於n秒的頻率執行,那麼這段時間回調只會觸發一次。節流則是按照200ms或者300ms定時觸發,而不只僅是一次。ajax

二者應用場景

初看以爲兩個概念好像差很少啊,到底何時用節流何時用防抖呢?編程

防抖經常使用場景

防抖的應用場景是連續的事件響應咱們只觸發一次回調,好比下面的場景:segmentfault

  • resize/scroll 觸發統計事件
  • 文本輸入驗證,不用用戶輸一個文字調用一次ajax請求,隨着用戶的輸入驗證一次就能夠

節流經常使用場景

節流是個很公平的函數,隔一段時間就來觸發回調,好比下面的場景:瀏覽器

  • DOM 元素的拖拽功能實現(mousemove)
  • 計算鼠標移動的距離(mousemove)
  • 搜索聯想(keyup)

爲何這些適合節流而不是防抖呢?
咱們想一想哈,按照防抖的概念若是n秒內用戶接二連三觸發事件,則防抖會在用戶結束操做結束後觸發回調。 那對於拖動來講,我拖了半天沒啥反應,一鬆手等n秒,啪。元素蹦過來了,這仍是拖動嗎?這是跳動吧,2333;閉包

lodash源碼實現

基本節流實現

function throttle(func, gapTime){
    if(typeof func !== 'function') {
        throw new TypeError('need a function');
    }
    gapTime = +gapTime || 0;
    let lastTime = 0;
    
    return function() {
        let time = + new Date();
        if(time - lastTime > gapTime || !lastTime) {
            func();
            lastTime = time;
        }
    }
}

setInterval(throttle(() => {
    console.log('xxx')
}, 1000),10)

如上,沒10ms觸發一次,但事實上是每1s打印一次 'xxx';app

基本防抖實現

弄清防抖的原理後,咱們先來實現一個簡單的 debounce 函數。dom

// 個人debounce 實現
function my_debounce(func, wait) {

    if(typeof func !== 'function') {
        throw new TypeError('need a function');
    }
    wait = +wait || 0;

    let timeId = null;

    return function() {
        // console.log('滾動了滾動了');  // 測試時可放開
        const self = this;
        const args = arguments;

        if(timeId) {
            clearTimeout(timeId);   // 清除定時器,從新設定一個新的定時器
        }
        timeId =  setTimeout(() => {
            func.apply(self, args); // arguments 是傳給函數的參數,這裏是 event  對象

        }, wait);

    }

}

咱們來分析一下這個函數, 首先它是一個閉包。它的核心是 定時器的設置,若是第一次進來, timeId 不存在直接設置一個延遲 wait 毫秒的定時器; 若是timeId 已經存在則先清除定時器再 從新設置延遲。
如上所說,若是在延遲時間內 來了一個事件,則從這個事件到來的時候開始定時。
用該防抖函數試一下對scroll去抖效果。我在 防抖函數中放開日誌 console.log('滾動了滾動了');, 而後
對滾動添加事件響應.函數式編程

function onScroll_1() {
   console.log('執行滾動處理函數啦');  
}
window.addEventListener('scroll', my_debounce(onScroll_1, 1000));

打開頁面,不斷滾動能夠在控制檯看到以下圖的console.

從圖中能夠看出,我觸發了90次滾動響應,但實際上 滾動處理函數執行了一次。
嗯,對上面簡單的例子咱們分析下不一樣狀況下,4秒時間內防抖調用的時機和次數.

  1. 每隔1.5秒滾動一次,4秒內等待1秒觸發的狀況下,會調用響應函數 2次
  2. 每隔 0.5 秒滾動一次,4秒內等待1秒觸發的狀況下,一次也不會調用。

下圖展現了這兩種狀況下定時器設置和函數調用狀況(費死個猴勁畫的,湊合看不清楚的能夠留言)

從上面的分析來看,這個單純的 防抖函數仍是有個硬傷的,是什麼呢?
那就是每次觸發定時器就從新來,每次都從新來,若是某段時間用戶一直一直觸發,防抖函數一直從新設置定時器,就是不執行,頻繁的延遲會致使用戶遲遲得不到響應,用戶一樣會產生「這個頁面卡死了」的觀感。
既然如此,那咱們是否是能夠設置一個最常等待時間,超過這個事件無論還有沒有事件在觸發,就去執行函數呢?或者我可不能夠設置第一次觸發的時候當即執行函數,再次觸發的時候再去防抖,也就是說無論如何先 響應一次,告訴那些 心急的 用戶我響應你啦,我是正常的,接下來慢慢來哦~
答案是,都是能夠的。這些屬於更自由的配置,加上這些, debounce 就是一個成熟的防抖函數了。嗯,是噠~成熟的

既然說到成熟,我們仍是來看下大名鼎鼎的==lodash==庫是怎麼將 debounce 成熟的吧!

loadsh中debounce源碼解讀

爲了方便,咱們忽略lodash 開始對function的註釋完裏整版在這 。成熟的 debounce 也才 100多行而已,小場面~~

先來看下完整函數,裏面加上了我本身的理解,而後再詳細分析

function debounce(func, wait, options) {
  let lastArgs,     // debounced 被調用後被賦值,表示至少調用 debounced一次
    lastThis,   // 保存 this
    maxWait,     // 最大等待時間
    result,      // return 的結果,可能一直爲 undefined,沒看到特別的做用
    timerId,    // 定時器句柄
    lastCallTime    // 上一次調用 debounced 的時間,按上面例子能夠理解爲 上一次觸發 scroll 的時間

  let lastInvokeTime = 0  // 上一次執行 func 的時間,按上面例子能夠理解爲 上次 執行 時的時間
  let leading = false     // 是否第一次觸發時當即執行
  let maxing = false     // 是否有最長等待時間
  let trailing = true    // 是否在等待週期結束後執行用戶傳入的函數

  // window.requestAnimationFrame() 方法告訴瀏覽器您但願執行動畫並請求瀏覽器在下一次重繪以前調用指定的函數來更新動畫。該方法使用一個回調函數做爲參數,這個回調函數會在瀏覽器重繪以前調用。
  // 下面的代碼我先註釋,能夠先不關注~意思是沒傳 wait 時 會在某個時候 調用 window.requestAnimationFrame()
  <!--const useRAF = (!wait && wait !== 0 && typeof root.requestAnimationFrame === 'function')-->
  //  以上代碼被我註釋,能夠先不關注

 // 這個很好理解,若是傳入的 func 不是函數,拋出錯誤,老子幹不了這樣的活
  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
  }

//  執行 用戶傳入的 func
//  重置 lastArgs,lastThis
//  lastInvokeTime 在此時被賦值,記錄上一次調用 func的時間
  function invokeFunc(time) {
    const args = lastArgs
    const thisArg = lastThis

    lastArgs = lastThis = undefined
    lastInvokeTime = time
    result = func.apply(thisArg, args)
    return result
  }

//  setTimeout 一個定時器
  function startTimer(pendingFunc, wait) {
  // 先不關注這個
    //if (useRAF) {
      //return root.requestAnimationFrame(pendingFunc)
    //}
    return setTimeout(pendingFunc, wait)
  }

//  清除定時器
  function cancelTimer(id) {
    // 先不關注
    //if (useRAF) {
      //return root.cancelAnimationFrame(id)
    //}
    clearTimeout(id)
  }

//  防抖開始時執行的操做
//  lastInvokeTime 在此時被賦值,記錄上一次調用 func的時間
//  設置了當即執行func,則執行func, 不然設置定時器
  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
  }

//  計算還須要等待多久
//  沒設置最大等待時間,結果爲 wait - (當前時間 - 上一次觸發(scroll) )  時間,也就是  wait - 已經等候時間
//  設置了最長等待時間,結果爲 最長等待時間 和 按照wait 計算還須要等待時間 的最小值
  function remainingWait(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeSinceLastInvoke = time - lastInvokeTime
    const timeWaiting = wait - timeSinceLastCall

    return maxing
      ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
      : timeWaiting
  }

// 此時是否應該設置定時器/執行用戶傳入的函數,有四種狀況應該執行
// 1, 第一次觸發(scroll)
// 2. 距離上次觸發超過 wait, 參考上面例子中 1.5 秒觸發一次,在3s觸發的狀況
// 3.當前時間小於 上次觸發時間,大概是系統時間被人爲日後撥了,原本2018年,系統時間變爲 2017年了,嘎嘎嘎
// 4. 設置了最長等待時間,而且等待時長不小於 最長等待時間了~ 參考上面例子,若是maxWait 爲2s, 則在 2s scroll 時會執行
  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)
    }
    // Restart the timer.
    timerId = startTimer(timerExpired, remainingWait(time))
  }

// 執行用戶傳入的 func 以前的最後一道屏障  func os: 執行我一次能咋地,這麼多屏障?
// 重置 定時器
// 執行 func
// 重置 lastArgs = lastThis 爲 undefined
  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) {
      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
  //  下面這句話證實 debounced 我是入口函數,是正宮娘娘!
  return debounced
}

export default debounce

第一看是否是有點暈?不要緊,咱們結合例子理一遍 這個成熟的 debounce 是如何運做的。

用demo 理解 loadsh debounce

調用以下:

function onScroll_1() {
   console.log('執行滾動處理函數啦');  
}
window.addEventListener('scroll', debounce(onScroll_1, 1000));
  1. 每 1500 ms 觸發(scroll)一次
  2. 每 600 ms 觸發(scroll)一次

再來看一下入口函數 debounced。

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

    lastArgs = args     //  args 是 event 對象,是點擊、scroll等事件傳過來的
    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
  }

1500ms時scroll,開始執行 debounced:

  1. 首先判斷shouldInvoke(time),由於第一次 lastCallTime === undefined 因此返回true;
  2. 而且此時 timerId === undefined, 因此執行 leadingEdge(lastCallTime);
  3. 在 leadingEdge(lastCallTime) 函數中,設置 lastInvokeTime = time,這個挺關鍵的,而且設定一個 1000ms的定時器,若是leading 爲true,則invokefunc,咱們沒有設置leading這種狀況不表~
  4. 1500ms~2500ms 之間沒什麼事,定時器到點,執行 invokeFunc(time);
  5. invokeFunc 中再次設置 lastInvokeTime, 並重置 lastThis,lastArgs;
  6. 第一次 scroll 完畢,接下來是 3000ms,這種間隔很大的調用與單純的 debounce 沒有太大差異,4s結束會執行 2次。

每 600ms 執行一次:
先用文字描述吧:
首次進入函數時由於 lastCallTime === undefined 而且 timerId === undefined,因此會執行 leadingEdge,若是此時 leading 爲 true 的話,就會執行 func。同時,這裏會設置一個定時器,在等待 wait(s) 後會執行 timerExpired,timerExpired 的主要做用就是觸發 trailing。

若是在還未到 wait 的時候就再次調用了函數的話,會更新 lastCallTime,而且由於此時 isInvoking 不知足條件,因此此次什麼也不會執行。

時間到達 wait 時,就會執行咱們一開始設定的定時器timerExpired,此時由於time-lastCallTime < wait,因此不會執行 trailingEdge。

這時又會新增一個定時器,下一次執行的時間是 remainingWait,這裏會根據是否有 maxwait 來做區分:

若是沒有 maxwait,定時器的時間是 wait - timeSinceLastCall,保證下一次 trailing 的執行。

若是有 maxing,會比較出下一次 maxing 和下一次 trailing 的最小值,做爲下一次函數要執行的時間。

最後,若是再也不有函數調用,就會在定時器結束時執行 trailingEdge。
簡單畫了個以時間爲軸,函數執行的狀況:
看不懂的多看兩遍吧~~~

在沒配置其餘參數的狀況下,連續觸發也是不執行,那咱們增長一下 maxWait試一下:

function onScroll_1() {
   console.log('執行滾動處理函數啦');  
}
window.addEventListener('scroll', debounce(onScroll_1, 1000, {
    maxWait: 1000
}));

文字描述過程:
首次進入函數時由於 lastCallTime === undefined 而且 timerId === undefined,因此會執行 leadingEdge,這裏會設置一個定時器,在等待 wait(s) 後會執行 timerExpired,timerExpired 的主要做用就是觸發 trailing。

若是在還未到 wait 的時候就再次調用了函數的話,會更新 lastCallTime,而且由於此時 isInvoking 不知足條件,因此此次什麼也不會執行。

時間到達 wait 時,就會執行咱們一開始設定的定時器timerExpired,此時由於time-lastCallTime < wait,若是因此不會執行 trailingEdge。可是若是設置了maxWait,這裏還會判斷 time-lastInvokeTime > maxWait,(參考上圖中1600ms處,會執行) 若是是則 trailingEdge。

這時又會新增一個定時器,下一次執行的時間是 remainingWait,這裏會根據是否有 maxwait 來做區分:

若是沒有 maxwait,定時器的時間是 wait - timeSinceLastCall,保證下一次 trailing 的執行。

若是有 maxing,會比較出下一次 maxing 和下一次 trailing 的最小值,做爲下一次函數要執行的時間。

最後,若是再也不有函數調用,就會在定時器結束時執行 trailingEdge

常見問題,防抖函數如何傳參

其實糾結這個問題的同窗,看看函數式編程會理解一些~
其實很簡單,my_debounce會返回一個函數,那在函數調用時加上參數就OK了~~

window.addEventListener('scroll', my_debounce(onScroll_1, 1000)('test'));

咱們的 onScroll_1 這樣寫,就把'test' 傳給 params了。。

function onScroll_1(params) {
    console.log('onScroll_1', params);   // test
    console.log('執行滾動處理函數啦');  
}

不過通常咱們不會這樣寫吧,由於新傳的值會將 原來的 event 給覆蓋掉,也就拿不到 scroll 或者 mouseclick等事件對象 event 了~~
那你說,我既想獲取到 event對象,又想傳參,怎麼辦?
個人辦法是,在本身的監聽函數上動手腳,好比個人onScroll 函數這樣寫:

function onScroll(param) {
    console.log('param:', param);  // test
       return function(event) {
           console.log('event:', event);  // event
   }
}

以下這樣使用 debounce

window.addEventListener('scroll', my_debounce(onScroll('test'), 1000));

控制檯的日誌確實如此~~

loadsh中throttle

有了 debounce的基礎loadsh對throttle的實現就很是簡單了,就是一個傳了 maxWait的debounce.

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
  })
}

上面已經分析了這種狀況,它的結果是若是接二連三觸發則每隔 wait 秒執行一次func。

參考資料

相關文章
相關標籤/搜索