【進階 7-5 期】淺出篇 | 7 個角度吃透 Lodash 防抖節流原理

更新:謝謝你們的支持,最近折騰了一個博客官網出來,方便你們系統閱讀,後續會有更多內容和更多優化,猛戳這裏查看前端

------ 如下是正文 ------git

引言

上一節咱們學習了 Lodash 中防抖和節流函數是如何實現的,並對源碼淺析一二,今天這篇文章會經過七個小例子爲切入點,換種方式繼續解讀源碼。其中源碼解析上篇文章已經很是詳細介紹了,這裏就再也不重複,建議本文配合上文一塊兒服用,猛戳這裏學習github

有什麼想法或者意見均可以在評論區留言,歡迎你們拍磚。面試

節流函數 Throttle

咱們先來看一張圖,這張圖充分說明了 Throttle(節流)和 Debounce(防抖)的區別,以及在不一樣配置下產生的不一樣效果,其中 mousemove 事件每 50 ms 觸發一次,即下圖中的每一小隔是 50 ms。今天這篇文章就從下面這張圖開始介紹。segmentfault

4196897931-5a14d309d661c_articlex

角度 1

lodash.throttle(fn, 200, {leading: true, trailing: true})瀏覽器

mousemove 第一次觸發

先來看下 throttle 源碼閉包

function throttle(func, wait, options) {
  // 首尾調用默認爲 true
  let leading = true
  let trailing = true

  if (typeof func !== 'function') {
    throw new TypeError('Expected a function')
  }
  // options 是不是對象
  if (isObject(options)) {
    leading = 'leading' in options ? !!options.leading : leading
    trailing = 'trailing' in options ? !!options.trailing : trailing
  }
  // maxWait 爲 wait 的防抖函數
  return debounce(func, wait, {
    leading,
    trailing,
    'maxWait': wait,
  })
}
複製代碼

因此 throttle(fn, 200, {leading: true, trailing: true}) 返回內容是 debounce(fn, 200, {leading: true, trailing: true, maxWait: 200}),多了 maxWait: 200 這部分。app

先打個預防針,後面即將開始比較難的部分,看下 debounce 入口函數。函數

// 入口函數,返回此函數
function debounced(...args) {
  // 獲取當前時間
  const time = Date.now()
  // 判斷此時是否應該執行 func 函數
  const isInvoking = shouldInvoke(time)

  // 賦值給閉包,用於其餘函數調用
  lastArgs = args
  lastThis = this
  lastCallTime = time

  // 執行
  if (isInvoking) {
    // 無 timerId 的狀況有兩種:
    // 一、首次調用 
    // 二、trailingEdge 執行過函數
    if (timerId === undefined) {
      return leadingEdge(lastCallTime)
    }
    
    // 若是設置了最大等待時間,則當即執行 func
    // 一、開啓定時器,到時間後觸發 trailingEdge 這個函數。
    // 二、執行 func,並返回結果
    if (maxing) {
      // 循環定時器中處理調用
      timerId = startTimer(timerExpired, wait)
      return invokeFunc(lastCallTime)
    }
  }
  // 一種特殊狀況,trailing 設置爲 true 時,前一個 wait 的 trailingEdge 已經執行了函數
  // 此時函數被調用時 shouldInvoke 返回 false,因此要開啓定時器
  if (timerId === undefined) {
    timerId = startTimer(timerExpired, wait)
  }
  // 不須要執行時,返回結果
  return result
}
複製代碼

對於 debounce(fn, 200, {leading: true, trailing: true, maxWait: 200}) 來講,會經歷以下過程。學習

  • 一、shouldInvoke(time) 中,由於知足條件 lastCallTime === undefined,因此返回 true
  • 二、lastCallTime = time,因此 lastCallTime 等於當前時間,假設爲 0
  • 三、timerId === undefined 知足,執行 leadingEdge(lastCallTime) 方法
// 執行連續事件剛開始的那次回調
function leadingEdge(time) {
  // 一、設置上一次執行 func 的時間
  lastInvokeTime = time
  // 二、開啓定時器,爲了事件結束後的那次回調
  timerId = startTimer(timerExpired, wait)
  // 三、若是配置了 leading 執行傳入函數 func
  // leading 來源自 !!options.leading
  return leading ? invokeFunc(time) : result
}
複製代碼
  • 四、在 leadingEdge(time) 中,設置 lastInvokeTime 爲當前時間即 0,開啓 200 毫秒定時器,執行 invokeFunc(time) 並返回
// 執行 Func 函數
function invokeFunc(time) {
  // 獲取上一次執行 debounced 的參數
  const args = lastArgs
  // 獲取上一次的 this
  const thisArg = lastThis

  // 重置
  lastArgs = lastThis = undefined
  lastInvokeTime = time
  result = func.apply(thisArg, args)
  return result
}
複製代碼
  • 五、在 invokeFunc(time) 中,執行 func.apply(thisArg, args),即 fn 函數第一次執行,並把結果賦值給 result,便於後續觸發時直接返回。同時重置 lastInvokeTime 爲當前時間即 0,清空 lastArgslastThis
  • 六、第一次觸發已經完成,注意此時 lastCallTimelastInvokeTime 都爲 0,200 毫秒的定時器還在運行中。

mousemove 第二次觸發

50 毫秒後第二次觸發到來,此時當前時間 time 爲 50,wait 爲 200, maxWait 爲 200,maxing 爲 true,lastCallTimelastInvokeTime 都爲 0,timerId 定時器存在,咱們來看下執行步驟。

function shouldInvoke(time) {
  // 當前時間距離上一次調用 debounce 的時間差
  const timeSinceLastCall = time - lastCallTime
  // 當前時間距離上一次執行 func 的時間差
  const timeSinceLastInvoke = time - lastInvokeTime

  // 下述 4 種狀況返回 true
  return ( lastCallTime === undefined || 
          (timeSinceLastCall >= wait) ||
          (timeSinceLastCall < 0) || 
          (maxing && timeSinceLastInvoke >= maxWait) )
}
複製代碼
  • 一、shouldInvoke(time) 中,timeSinceLastCall 爲 50,timeSinceLastInvoke 爲 50,4 種條件都不知足,返回 false。
  • 二、此時 isInvoking 爲 false,同時 timerId === undefined 不知足,直接返回第一次觸發時的 result
  • 三、第二次觸發完成,並不會執行 fn,只會返回上次執行的結果 result
  • 四、第三次和第四次觸發時,效果同樣,就再也不重複了。

mousemove 第五次觸發

距第一次觸發 200 毫秒後第五次觸發到來,此時當前時間 time 爲 200,wait 爲 200, maxWait 爲 200,maxing 爲 true,lastCallTime 爲 150, lastInvokeTime 爲 0,timerId 定時器存在,咱們來看下執行步驟。

  • 一、shouldInvoke(time) 中,timeSinceLastInvoke 爲 200,知足(maxing && timeSinceLastInvoke >= maxWait),因此返回 true
// debounced 方法中執行到這部分
if (maxing) {
  // 循環定時器中處理調用
  timerId = startTimer(timerExpired, wait)
  return invokeFunc(lastCallTime)
}
複製代碼
  • 二、知足 maxing 條件,從新開啓 200 毫秒的定時器,並執行 invokeFunc(lastCallTime) 函數
  • 三、invokeFunc(time) 中,重置 lastInvokeTime 爲當前時間即 200,清空 lastArgslastThis
  • 四、第6、7、八次觸發時,同第二次觸發效果一致,就再也不重複了。

mousemove 中止觸發

假設第八次觸發以後就中止了滾動,在第八次觸發時 time 爲 350,因此若是有第九次觸發,那麼此時是應該執行fn 的,可是此時 mousemove 已經中止了觸發,那麼還會執行 fn 嗎?答案是依舊執行,由於最開始設置了 {trailing: true}

// 開啓定時器
function startTimer(pendingFunc, wait) {
  // 沒傳 wait 時調用 window.requestAnimationFrame()
  if (useRAF) {
    // 若想在瀏覽器下次重繪以前繼續更新下一幀動畫
    // 那麼回調函數自身必須再次調用 window.requestAnimationFrame()
    root.cancelAnimationFrame(timerId);
    return root.requestAnimationFrame(pendingFunc)
  }
  // 不使用 RAF 時開啓定時器
  return setTimeout(pendingFunc, wait)
}
複製代碼

在第五次觸發時開啓了 200 毫秒的定時器,因此在時間 time 到 400 時會執行 pendingFunc,此時的 pendingFunc 就是 timerExpired 函數,來看下具體的代碼。

// 定時器回調函數,表示定時結束後的操做
function timerExpired() {
  const time = Date.now()
  // 一、是否須要執行
  // 執行事件結束後的那次回調,不然重啓定時器
  if (shouldInvoke(time)) {
    return trailingEdge(time)
  }
  // 二、不然 計算剩餘等待時間,重啓定時器,保證下一次時延的末尾觸發
  timerId = startTimer(timerExpired, remainingWait(time))
}
複製代碼

此時在 shouldInvoke(time) 中,time 爲 400,lastInvokeTime 爲 200,timeSinceLastInvoke 爲 200,知足 (maxing && timeSinceLastInvoke >= maxWait),因此返回 true。

// 執行連續事件結束後的那次回調
function trailingEdge(time) {
  // 清空定時器
  timerId = undefined

  // trailing 和 lastArgs 二者同時存在時執行
  // trailing 來源自 'trailing' in options ? !!options.trailing : trailing
  // lastArgs 標記位的做用,意味着 debounce 至少執行過一次
  if (trailing && lastArgs) {
    return invokeFunc(time)
  }
  // 清空參數
  lastArgs = lastThis = undefined
  return result
}
複製代碼

以後執行 trailingEdge(time),在這個函數中判斷 trailinglastArgs ,此時這兩個條件都是 true,因此會執行 invokeFunc(time),最終執行函數 fn。

這裏須要說明如下兩點

  • 若是設置了 {trailing: false},那麼最後一次是不會執行的。對於 throttledebounce 來講,默認值是 true,因此若是沒有特地指定 trailing,那麼最後一次是必定會執行的。
  • 對於 lastArgs 來講,執行 debounced 時會賦值,即每次觸發都會從新賦值一次,那何時清空呢,在 invokeFunc(time) 中執行 fn 函數時重置爲 undefined,因此若是 debounced 只觸發了一次,即便設置了 {trailing: true} 那也不會再執行 fn 函數,這個就解答了上篇文章留下的第一道思考題。

角度 2

lodash.throttle(fn, 200, {leading: true, trailing: false})

在「角度 1 之 mousemove 中止觸發」這部分中說到,若是不設置 trailing 和設置 {trailing: true} 效果是同樣的,事件回調結束後都會再執行一次傳入函數 fn,可是若是設置了{trailing: false},那麼事件回調結束後是不會再執行 fn 的。

此時的配置對比角度 1 來講,區別在於設置了{trailing: false},因此實際效果對比 1 來講,就是最後不會額外再執行一次,效果見第一張圖。

角度 3

lodash.throttle(fn, 200, {leading: false, trailing: true})

此時的配置和角度 1 相比,區別在於設置了 {leading: false},因此直接看 leadingEdge(time) 方法就能夠了。

// 執行連續事件剛開始的那次回調
function leadingEdge(time) {
  // 一、設置上一次執行 func 的時間
  lastInvokeTime = time
  // 二、開啓定時器,爲了事件結束後的那次回調
  timerId = startTimer(timerExpired, wait)
  // 三、若是配置了 leading 執行傳入函數 func
  // leading 來源自 !!options.leading
  return leading ? invokeFunc(time) : result
}
複製代碼

在這裏,會開啓 200 毫秒的定時器,同時由於 leading 爲 false,因此並不會執行 invokeFunc(time) ,只會返回 result,此時的 result 值是 undefined

這裏開啓一個定時器的目的是爲了事件結束後的那次回調,即若是設置了 {trailing: true} 那麼最後一次回調將執行傳入函數 fn,哪怕 debounced 函數只觸發一次。

這裏指定了 {leading: false},那麼 leading 的初始值是什麼呢?在 debounce 中是 false,在 throttle 中是 true。因此在 throttle 中不須要剛開始就觸發時,必須指定 {leading: false},在 debounce 中就不須要了,默認不觸發。

防抖函數 Debounce

角度 4

lodash.debounce(fn, 200, {leading: false, trailing: true})

此時相比較 throttle 來講,缺乏了 maxWait 值,因此具體觸發過程當中的判斷就不同了,來詳細看一遍。

  • 一、在入口函數 debounced 中,執行 shouldInvoke(time),前面討論過由於第一次觸發因此會返回 true,以後執行 leadingEdge(lastCallTime)
// 執行連續事件剛開始的那次回調
function leadingEdge(time) {
  // 一、設置上一次執行 func 的時間
  lastInvokeTime = time
  // 二、開啓定時器,爲了事件結束後的那次回調
  timerId = startTimer(timerExpired, wait)
  // 三、若是配置了 leading 執行傳入函數 func
  // leading 來源自 !!options.leading
  return leading ? invokeFunc(time) : result
}
複製代碼
  • 二、在 leadingEdge 中,由於 leading 爲 false,因此並不執行 fn,只開啓 200 毫秒的定時器,並返回 undefined。此時 lastInvokeTime 爲當前時間,假設爲 0。
// 判斷此時是否應該執行 func 函數
function shouldInvoke(time) {
  // 當前時間距離上一次調用 debounce 的時間差
  const timeSinceLastCall = time - lastCallTime
  // 當前時間距離上一次執行 func 的時間差
  const timeSinceLastInvoke = time - lastInvokeTime

  // 下述 4 種狀況返回 true
  return ( lastCallTime === undefined || 
          (timeSinceLastCall >= wait) ||
          (timeSinceLastCall < 0) || 
          (maxing && timeSinceLastInvoke >= maxWait) )
}
複製代碼
  • 三、以後每次觸發時,timeSinceLastCall 老是爲 50 毫秒,maxing 爲 false,因此 shouldInvoke(time) 老是返回 false,並不會執行傳入函數 fn,只返回 result,即爲 undefined
  • 四、到如今爲止,fn 一次尚未執行,200 毫秒後,定時器回調函數觸發,執行 timerExpired 函數
// 定時器回調函數,表示定時結束後的操做
function timerExpired() {
  const time = Date.now()
  // 一、是否須要執行
  // 執行事件結束後的那次回調,不然重啓定時器
  if (shouldInvoke(time)) {
    return trailingEdge(time)
  }
  // 二、不然 計算剩餘等待時間,重啓定時器,保證下一次時延的末尾觸發
  timerId = startTimer(timerExpired, remainingWait(time))
}
複製代碼
  • 五、此時存在兩種狀況,第一種是 mousemove 事件一直在觸發,根據前面介紹 shouldInvoke(time) 會返回 false,以後就將計算剩餘等待時間,重啓定時器。時間計算公式爲 wait - (time - lastCallTime),即 200 - 50,因此只要 shouldInvoke(time) 返回 false,就每隔 150 毫秒後執行一次 timerExpired()
  • 六、第二種狀況是 mousemove 事件再也不觸發,由於 timerExpired() 在循環執行,因此確定會存在一種狀況知足 timeSinceLastCall >= wait,即 shouldInvoke(time) 返回 true,終結 timerExpired() 的循環,並執行 trailingEdge(time)
// 執行連續事件結束後的那次回調
function trailingEdge(time) {
  // 清空定時器
  timerId = undefined

  // trailing 和 lastArgs 二者同時存在時執行
  // trailing 來源自 'trailing' in options ? !!options.trailing : trailing
  // lastArgs 標記位的做用,意味着 debounce 至少執行過一次
  if (trailing && lastArgs) {
    return invokeFunc(time)
  }
  // 清空參數
  lastArgs = lastThis = undefined
  return result
}
複製代碼
  • 七、在 trailingEdgetrailinglastArgs 都是 true,因此會執行 invokeFunc(time),即執行傳入函數 fn。
  • 八、因此整個過程當中只在最後執行一次傳入函數 fn,效果同上面第一張圖所示。

角度 5

lodash.debounce(fn, 200, {leading: true, trailing: false})

此時相比角度 4 來講,差別在於 {leading: true, trailing: false},可是 waitmaxWait 都和角度 4 一致,因此只存在下面 2 種區別,效果同上面第一張圖所示。

  • 區別 1:leadingEdge 中會執行傳入函數 fn
  • 區別 2:trailingEdge 中再也不執行傳入函數 fn

角度 6

lodash.debounce(fn, 200, {leading: true, trailing: true})

此時相比角度 4 來講,差別僅僅在於設置了 {leading: true},因此只存在一個區別,那就是在 leadingEdge 中會執行傳入函數 fn,固然在 trailingEdge 中依舊執行傳入函數 fn,因此會出如今 mousemove 事件觸發過程當中首尾都會執行的狀況,效果同上面第一張圖所示。

固然一種狀況除外,那就是 mousemove 事件永遠只觸發一次的狀況,關鍵在於 lastArgs 變量。

對於 lastArgs 變量來講,在入口函數 debounced 中賦值,即每次觸發都會從新賦值一次,那何時清空呢,在 invokeFunc(time) 中重置爲 undefined,因此若是 debounced 只觸發了一次,並且在 {leading: true} 時執行過一次 fn,那麼即便設置了 {trailing: true} 也不會再執行傳入函數 fn。

角度 7

lodash.debounce(fn, 200, {leading: false, trailing: true, maxWait: 400})

此時 wait 爲 200,maxWait 爲 400,maxing 爲 true,咱們來看下執行過程。

  • 一、第一次觸發時,由於 {leading: false},因此確定不會執行 fn,此時開啓了一個 200 毫秒的定時器。
// 判斷此時是否應該執行 func 函數
function shouldInvoke(time) {
  // 當前時間距離上一次調用 debounce 的時間差
  const timeSinceLastCall = time - lastCallTime
  // 當前時間距離上一次執行 func 的時間差
  const timeSinceLastInvoke = time - lastInvokeTime

  // 下述 4 種狀況返回 true
  return ( lastCallTime === undefined || 
          (timeSinceLastCall >= wait) ||
          (timeSinceLastCall < 0) || 
          (maxing && timeSinceLastInvoke >= maxWait) )
}
複製代碼
  • 二、以後每隔 50 毫秒觸發一次,每次都會執行 shouldInvoke(time) 函數,只有在第 400 毫秒時,纔會知足 maxing && timeSinceLastInvoke >= maxWait,返回 true。
// 計算仍需等待的時間
function remainingWait(time) {
  // 當前時間距離上一次調用 debounce 的時間差
  const timeSinceLastCall = time - lastCallTime
  // 當前時間距離上一次執行 func 的時間差
  const timeSinceLastInvoke = time - lastInvokeTime
  // 剩餘等待時間
  const timeWaiting = wait - timeSinceLastCall

  // 是否設置了最大等待時間
	// 是(節流):返回「剩餘等待時間」和「距上次執行 func 的剩餘等待時間」中的最小值
	// 否:返回剩餘等待時間
  return maxing
    ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
  	: timeWaiting
}
複製代碼
  • 三、可是在這以前的第 200 毫秒,定時器觸發回調函數,執行 timerExpired,由於此時 shouldInvoke(time) 返回 false,因此會從新計算剩餘等待時間並重啓計時器,其中 timeWaiting 是 150 毫秒,maxWait - timeSinceLastInvoke 是 200 毫秒,因此計算結果是150 毫秒。
  • 四、150 毫秒以後,即自開始以後的第 350 毫秒時,會從新計算時間,其中 timeWaiting 依舊是 150 毫秒,maxWait - timeSinceLastInvoke 是 50 毫秒,因此從新開啓 50 毫秒的定時器,即在第 400 毫秒時觸發。
  • 五、此時會發現定時器觸發的時間是第 400 毫秒,shouldInvoke(time) 中返回 true 的時間也是在第 400 毫秒,爲何要這樣呢?這樣會衝突嗎?首先定時器剩餘時間判斷和 shouldInvoke(time) 判斷中,只要有一處知足執行 fn 條件,就會立馬執行,同時 lastInvokeTime 值也會發生改變,因此另外一處判斷就不會生效了。另外自己定時器是不精準的,因此經過 Math.min(timeWaiting, maxWait - timeSinceLastInvoke) 取最小值的方式來減小偏差。
  • 六、於此同時,須要在 debounced 入口函數添加這麼一句 if (timerId === undefined) {timerId = startTimer(timerExpired, wait)},避免 trailingEdge 執行後定時器被清空。
  • 七、最終效果和節流是同樣的,只是時間間隔變大了而已,具體效果同第一張圖所示。

上期答疑

第一題

問:若是 leadingtrailing 選項都是 true,在 wait 期間只調用了一次 debounced 函數時,總共會調用幾回 func,1 次仍是 2 次,爲何?

答案是 1 次,爲何?文中已給出詳細解答,詳情請看角度 1 和角度 6。

第二題

問:如何給 debounce(func, time, options) 中的 func 傳參數?

第一種方案,由於 debounced 函數能夠接受參數,因此能夠用高階函數的方式傳參,以下

const params = 'muyiy';
const debounced = lodash.debounce(func, 200)(params)
window.addEventListener('mousemove', debounced);
複製代碼

不過這種方式不太友好,params 會將原來的 event 覆蓋掉,此時就拿不到 scroll 或者 mousemove 等事件對象 event 了。

第二種方案,在監聽函數上處理,使用閉包保存傳入參數並返回須要執行的函數便可。

function onMove(param) {
    console.log('param:', param);  // muyiy
  
    function func(event) {
      console.log('param:', param);  // muyiy
      console.log('event:', event);  // event
    }
    return func;
}
複製代碼

使用時以下

const params = 'muyiy';
const debounced = lodash.debounce(onMove(params), 200)
window.addEventListener('mousemove', debounced);
複製代碼

參考

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

推薦閱讀

【進階 6-3 期】深刻淺出節流函數 throttle

【進階 6-4 期】深刻淺出防抖函數 debounce

【進階 6-5 期】[譯] Throttle 和 Debounce 在 React 中的應用

【進階 6-6 期】深刻篇 | 阿里 P6 必會 Lodash 防抖節流函數實現原理

❤️ 看完三件事

若是你以爲這篇內容對你挺有啓發,我想邀請你幫我三個小忙:

  1. 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-
  2. 關注個人 GitHub,讓咱們成爲長期關係
  3. 關注公衆號「高級前端進階」,每週重點攻克一個前端面試重難點,公衆號後臺回覆「資料」 送你精選前端優質資料。

相關文章
相關標籤/搜索