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

木易楊說發表於2019-07-10

更新:謝謝大家的支援,最近折騰了一個部落格官網出來,方便大家系統閱讀,後續會有更多內容和更多優化,猛戳這裡檢視

------ 以下是正文 ------

引言

上一節我們學習了 Lodash 中防抖和節流函式是如何實現的,並對原始碼淺析一二,今天這篇文章會通過七個小例子為切入點,換種方式繼續解讀原始碼。其中原始碼解析上篇文章已經非常詳細介紹了,這裡就不再重複,建議本文配合上文一起服用,猛戳這裡學習

有什麼想法或者意見都可以在評論區留言,歡迎大家拍磚。

節流函式 Throttle

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

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 這部分。

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

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

  // 賦值給閉包,用於其他函式呼叫
  lastArgs = args
  lastThis = this
  lastCallTime = time

  // 執行
  if (isInvoking) {
    // 無 timerId 的情況有兩種:
    // 1、首次呼叫 
    // 2、trailingEdge 執行過函式
    if (timerId === undefined) {
      return leadingEdge(lastCallTime)
    }
    
    // 如果設定了最大等待時間,則立即執行 func
    // 1、開啟定時器,到時間後觸發 trailingEdge 這個函式。
    // 2、執行 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}) 來說,會經歷如下過程。

  • 1、shouldInvoke(time) 中,因為滿足條件 lastCallTime === undefined,所以返回 true
  • 2、lastCallTime = time,所以 lastCallTime 等於當前時間,假設為 0
  • 3、timerId === undefined 滿足,執行 leadingEdge(lastCallTime) 方法
// 執行連續事件剛開始的那次回撥
function leadingEdge(time) {
  // 1、設定上一次執行 func 的時間
  lastInvokeTime = time
  // 2、開啟定時器,為了事件結束後的那次回撥
  timerId = startTimer(timerExpired, wait)
  // 3、如果配置了 leading 執行傳入函式 func
  // leading 來源自 !!options.leading
  return leading ? invokeFunc(time) : result
}
複製程式碼
  • 4、在 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
}
複製程式碼
  • 5、在 invokeFunc(time) 中,執行 func.apply(thisArg, args),即 fn 函式第一次執行,並把結果賦值給 result,便於後續觸發時直接返回。同時重置 lastInvokeTime 為當前時間即 0,清空 lastArgslastThis
  • 6、第一次觸發已經完成,注意此時 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) )
}
複製程式碼
  • 1、shouldInvoke(time) 中,timeSinceLastCall 為 50,timeSinceLastInvoke 為 50,4 種條件都不滿足,返回 false。
  • 2、此時 isInvoking 為 false,同時 timerId === undefined 不滿足,直接返回第一次觸發時的 result
  • 3、第二次觸發完成,並不會執行 fn,只會返回上次執行的結果 result
  • 4、第三次和第四次觸發時,效果一樣,就不再重複了。

mousemove 第五次觸發

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

  • 1、shouldInvoke(time) 中,timeSinceLastInvoke 為 200,滿足(maxing && timeSinceLastInvoke >= maxWait),所以返回 true
// debounced 方法中執行到這部分
if (maxing) {
  // 迴圈定時器中處理呼叫
  timerId = startTimer(timerExpired, wait)
  return invokeFunc(lastCallTime)
}
複製程式碼
  • 2、滿足 maxing 條件,重新開啟 200 毫秒的定時器,並執行 invokeFunc(lastCallTime) 函式
  • 3、invokeFunc(time) 中,重置 lastInvokeTime 為當前時間即 200,清空 lastArgslastThis
  • 4、第六、七、八次觸發時,同第二次觸發效果一致,就不再重複了。

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()
  // 1、是否需要執行
  // 執行事件結束後的那次回撥,否則重啟定時器
  if (shouldInvoke(time)) {
    return trailingEdge(time)
  }
  // 2、否則 計算剩餘等待時間,重啟定時器,保證下一次時延的末尾觸發
  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) {
  // 1、設定上一次執行 func 的時間
  lastInvokeTime = time
  // 2、開啟定時器,為了事件結束後的那次回撥
  timerId = startTimer(timerExpired, wait)
  // 3、如果配置了 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 值,所以具體觸發過程中的判斷就不一樣了,來詳細看一遍。

  • 1、在入口函式 debounced 中,執行 shouldInvoke(time),前面討論過因為第一次觸發所以會返回 true,之後執行 leadingEdge(lastCallTime)
// 執行連續事件剛開始的那次回撥
function leadingEdge(time) {
  // 1、設定上一次執行 func 的時間
  lastInvokeTime = time
  // 2、開啟定時器,為了事件結束後的那次回撥
  timerId = startTimer(timerExpired, wait)
  // 3、如果配置了 leading 執行傳入函式 func
  // leading 來源自 !!options.leading
  return leading ? invokeFunc(time) : result
}
複製程式碼
  • 2、在 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) )
}
複製程式碼
  • 3、之後每次觸發時,timeSinceLastCall 總是為 50 毫秒,maxing 為 false,所以 shouldInvoke(time) 總是返回 false,並不會執行傳入函式 fn,只返回 result,即為 undefined
  • 4、到現在為止,fn 一次還沒有執行,200 毫秒後,定時器回撥函式觸發,執行 timerExpired 函式
// 定時器回撥函式,表示定時結束後的操作
function timerExpired() {
  const time = Date.now()
  // 1、是否需要執行
  // 執行事件結束後的那次回撥,否則重啟定時器
  if (shouldInvoke(time)) {
    return trailingEdge(time)
  }
  // 2、否則 計算剩餘等待時間,重啟定時器,保證下一次時延的末尾觸發
  timerId = startTimer(timerExpired, remainingWait(time))
}
複製程式碼
  • 5、此時存在兩種情況,第一種是 mousemove 事件一直在觸發,根據前面介紹 shouldInvoke(time) 會返回 false,之後就將計算剩餘等待時間,重啟定時器。時間計算公式為 wait - (time - lastCallTime),即 200 - 50,所以只要 shouldInvoke(time) 返回 false,就每隔 150 毫秒後執行一次 timerExpired()
  • 6、第二種情況是 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
}
複製程式碼
  • 7、在 trailingEdgetrailinglastArgs 都是 true,所以會執行 invokeFunc(time),即執行傳入函式 fn。
  • 8、所以整個過程中只在最後執行一次傳入函式 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,我們來看下執行過程。

  • 1、第一次觸發時,因為 {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) )
}
複製程式碼
  • 2、之後每隔 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
}
複製程式碼
  • 3、但是在這之前的第 200 毫秒,定時器觸發回撥函式,執行 timerExpired,因為此時 shouldInvoke(time) 返回 false,所以會重新計算剩餘等待時間並重啟計時器,其中 timeWaiting 是 150 毫秒,maxWait - timeSinceLastInvoke 是 200 毫秒,所以計算結果是150 毫秒。
  • 4、150 毫秒之後,即自開始之後的第 350 毫秒時,會重新計算時間,其中 timeWaiting 依舊是 150 毫秒,maxWait - timeSinceLastInvoke 是 50 毫秒,所以重新開啟 50 毫秒的定時器,即在第 400 毫秒時觸發。
  • 5、此時會發現定時器觸發的時間是第 400 毫秒,shouldInvoke(time) 中返回 true 的時間也是在第 400 毫秒,為什麼要這樣呢?這樣會衝突嗎?首先定時器剩餘時間判斷和 shouldInvoke(time) 判斷中,只要有一處滿足執行 fn 條件,就會立馬執行,同時 lastInvokeTime 值也會發生改變,所以另一處判斷就不會生效了。另外本身定時器是不精準的,所以通過 Math.min(timeWaiting, maxWait - timeSinceLastInvoke) 取最小值的方式來減少誤差。
  • 6、於此同時,需要在 debounced 入口函式新增這麼一句 if (timerId === undefined) {timerId = startTimer(timerExpired, wait)},避免 trailingEdge 執行後定時器被清空。
  • 7、最終效果和節流是一樣的,只是時間間隔變大了而已,具體效果同第一張圖所示。

上期答疑

第一題

問:如果 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. 關注公眾號「高階前端進階」,每週重點攻克一個前端面試重難點,公眾號後臺回覆「資料」 送你精選前端優質資料。

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

相關文章