手撕源碼系列 —— lodash 的 debounce 與 throttle

前言

debouncethrottle 相信你們並不陌生,我猜測過去,FEer 對它們的瞭解大概分爲如下幾個階段:javascript

  • 沒據說過的
  • 據說過的
  • 瞭解原理可是徒手寫不出來的
  • 能寫出最基本的實現的
  • 能理解並寫出 lodash 這種稍微複雜一點實現的

固然,在第三個階段的人應該佔絕大多數,當我還在第三階段的時候,就但願有一篇技術文章,能讓我一下就能達到最後一個階段。結果就是我 naive 了,google 了許多資料,50%在反覆地聊基本實現,20%在基礎上聊了二者的區別,20%在聊 underscore 的實現,剩下10%很粗暴地把源碼和註釋貼了上來。這就讓我很難受了,沒辦法,萬事開頭難,我只能將這些資料和源碼結合起來,事半功倍地進行探索。事實也證實,一口是吃不成胖子的,因此這篇文章旨在拆分 lodash 的實現,一步一步地理解並縮短 第四階段第五階段 的時間,至於以前處於前三階段的同窗,能夠去找些其餘的文章來進行學習。java

一些必須知道的

什麼是 debouncegit

debounce: Grouping a sudden burst of events (like keystrokes) into a single one.
防抖:將一組例如按下按鍵這種密集的事件歸併成一個單獨事件。github

什麼是 throttle緩存

throttle: Guaranteeing a constant flow of executions every X milliseconds.
節流:保證每 X 毫秒恆定地執行一些操做。app

爲何要重提一下二者的概念呢?由於我在第三階段的時候,一直是把這二者分開理解的,等到理解了 lodash 的源碼以後,才發現 throttledebounce的一種特殊狀況。若是從上面的看不出來的話,能夠通俗地這麼理解:
debounce 將密集觸發的事件合併成一個單獨事件(不限時間,你能夠一直密集地觸發,它最終只會觸發一次)而 throttledebounce 的基礎上增長了時間限制(maxWait),也就是你一直密集地觸發時間,可是到了限定時間,它必定要觸發一次,也就是上文中提到的 a constant flow of executions函數

能夠照着這個 可視化分析界面 理解一下。工具

若是還沒用過 lodash 的同窗,建議先看下 lodashdebouncethrottle 的用法:學習

分步實現 debounce

上圖是一個最基本的 debounce 實現,下面咱們來按照 lodash 的實現思路,進行 第一步 拆解。

第一步 —— 基礎的拆解

爲了後續的擴展實現,第一步咱們將一個基本的 debounce 拆分爲五個部分優化

  • formatArgs()
    沒有什麼好說的,一個健壯的工具函數是少不了入參校驗的,固然,在第一步只是實現了最基本的校驗和格式化。
  • debounced()
    和基礎實現同樣,最後的結果是返回一個包裝了全部操做的函數,能夠看到,裏面的實現和基礎實現相似,不一樣的是這裏多了一步記錄上一次調用的 thisargs
  • startTimer(wait)
    setTimeout 設置定時器操做語義化爲一個函數,入參是 wait
  • timeExpired()
    將回調函數抽成一個函數,目前的操做只有 invoke 須要防抖的函數,後續會慢慢添加功能。
  • invokeFunc() 調用須要防抖的函數,這裏作了一個參數的傳遞,獲取 thisargs

通過上面的拆分,其實一個基本可用的 debounce 函數已經實現好了,可是咱們會發現一個問題,他的調用嚴重依賴於 setTimeout,那麼延遲時間是否必定爲 wait 呢?實際上是不必定的。

舉個例子,好比說 wait5,此時在某一個定時器的回調函數 timeExpired 檢測到上一次觸發時間的 lastCallTime100,而 Date.now()103,此時雖熱 103 - 100 = 3 < 5,要開啓下一次定時,但這個時候定時的時間爲 5 - 3 = 2 就能夠了。

接下來,就要進行定時時間的優化。

對應完整源碼以及 Demo:debounce-1

第二步 —— 對定時時間的優化

爲了達到對定時時間的優化,咱們須要加入時間參數進行詳細計算,分爲如下幾步:

  • 緩存上一個執行 debounced 函數的時間 lastCallTime
var lastCallTime // 緩存的上一個執行 debounced 的時間
複製代碼
  • 緩存獲取當前時間的函數
/**輔助函數的緩存 */
    now = Date.now
複製代碼
  • 加入判斷某一時刻是否要調用 func 的工具函數 shouldInvoke

  • 加入計算真正延遲時間的工具函數 remainingWait

  • 運用上訴的兩個新增的工具函數,修改回調的執行函數 timeExpired

修改後的回調函數再也不是單純的調用 invokeFunc,而是先判斷執行回調的時刻是否可以調用 func,若是能夠,直接調用;若是不行,計算出真正的延遲時間並重置定時器。

對應完整源碼以及 Demo:debounce-2

第三步 —— 加入maxWait ,實現基本的 throttle

爲了以後 lodash 的功能擴展以及 throttle 的實現,這一步加入參數 最大限制時間 maxWait。分爲如下幾步:

  • 緩存上一個執行 invokeFunc 函數的時間 lastInvokeTime
var lastInvokeTime = 0, // 緩存的上一個 執行 invokeFunc 的時間
複製代碼
  • 緩存計算最大值、最小值的函數 maxmin
nativeMax = Math.max,
nativeMin = Math.min
複製代碼
  • 增長對新入參 options 的校驗
if (isObject(options)) {
  maxing = 'maxWait' in options
  maxWait = maxing ? nativeMax(+options.maxWait || 0, wait) : maxWait
}
複製代碼
  • 優化計算真正延遲時間的工具函數 remainingWait

  • 增長工具函數 shouldInvoke 的判斷條件
(maxing && timeSinceLastInvoke >= maxWait) // 等待時間超過最大等待時間
複製代碼
  • 優化包裝函數 debounced 的執行過程

還記得開頭說的 throttle 只是一個 debounce 的特殊狀況嗎?準確的說這一步就增長了這個特殊狀況(maxWait),那麼咱們就能夠實現一個基本的 throttle了。

function debounce(func, wait, options) {
// ......
}

function throttle(func, wait) {
  return debounce(func, wait, {
    maxWait: wait
  })
}
複製代碼

對應完整源碼以及 Demo:

第四步 —— 增長入參選項 trailing 以及 trailingEdge 工具函數

通常一些基礎實現的 debounce ,在解決完 this 的指向event 對象 時,緊接就要處理 前置執行後置執行 的問題。在 lodash 裏,將這兩個操做分爲 leadingtrailing 兩個參數,分別對應控制 leadingEdgetrailingEdge 兩個工具函數的執行,這裏咱們先實現 trailing 。分爲如下幾步:

  • 初始化並給 trailing 設置默認值
var trailing = true
複製代碼
  • 增長對 trailing 的校驗和格式化
trailing = 'trailing' in options ? !!options.trailing : trailing
複製代碼
  • 增長工具函數 trailingEdge
  • 修改回調函數,不直接調用 invokeFunc,而是經過 trailingEdge 來間接調用
// setTimeout 定時器的回調函數
function timeExpired() {
  // ......
  if (canInvoke) {
    return trailingEdge(time)
  }
  // ......
}
複製代碼

對應完整源碼以及 Demo:

第五步 —— 增長入參選項 leading 以及 leadingEdge 工具函數

這一步基本和上一步相似,分爲以幾步:

  • 初始化並給 leading 設置默認值
var leading = false
複製代碼
  • 增長對 leading 的校驗和格式化
leading = !!options.leading
複製代碼
  • 增長工具函數 leadingEdge
  • 修改包裝函數 debounced 的執行過程
// 要返回的包裝 debounce 操做的函數
function debounced() {
  // ......
  if (isInvoking) {
    if (timerId === undefined) {
      return leadingEdge(lastCallTime)
    }
    // ......
  }
  // ......
}
複製代碼

至此,一個基本完整的 debouncethrottle 已經實現了,下一步只是錦上添花,加一些額外的 feature

對應完整源碼以及 Demo:

第六步 —— 增長 cancelflush 功能

lodash 的實現裏,還增長了兩個貼心的小功能,這裏也一併貼上來:

  • 取消 debounce 效果的 cancel
// 取消 debounce 函數
function cancel() {
  if (timerId !== undefined) {
    clearTimeout(timerId)
  }
  lastInvokeTime = 0
  lastArgs = lastCallTime = lastThis = timerId = undefined
}
複製代碼
  • 取消並當即執行一次 debounce 函數的 flush
// 取消並當即執行一次 debounce 函數
function flush() {
  return timerId === undefined ? result : trailingEdge(now())
}
複製代碼

對應完整源碼以及 Demo:

總結

雖然一開始直接撕源碼,以爲有點小複雜,可是隻要將其主幹剝離以後再理邏輯,就會將難度減小不少。從上述分步過程來看 lodash 的整體實現,整體能夠分爲

  • 返回的包裝函數 debounced()
  • 校驗並格式化入參的函數 fomrtArgs()
  • 設置 Timer 的工具函數 startTimer(time)
  • 定時器的回調函數 timeExpired()
  • 判斷是否要調用 func 的函數shouldInvoke(time)
  • 觸發 func 的函數 invokeFunc(time)
  • 前置觸發 func 的邊界函數 leadingEdge(time)
  • 後置觸發 func 的邊界函數 trailingEdge(time)
  • 內部的兩個小工具函數(判斷是不是 object 的 isObject(value)計算真正延遲時間的函數 remainingWait(time)
  • 兩個小功能(取消 debounce 效果的 cancel()取消並當即執行一次 debounce 函數的 flush()

如下是我整理的一個執行流程圖(完整大圖在 repo 裏),能夠照着參考一下

篇幅有限,不免一些錯誤,歡迎探討和指教~
附一個 GitHub 完整的 repo 地址: github.com/LazyDuke/de…

後記

接下來這個系列想繼續寫下去,目前想寫的有

  • 用 TypeScript 實現一個 符合 Promise A+ 規範的Promise
  • 淺拷貝和深拷貝的徹底實現
  • 老生常談的call、apply、bind和new
相關文章
相關標籤/搜索