聊聊lodash的debounce實現

本文同步自個人Bloggit

前段時間團隊內部搞了一個代碼訓練營,你們組織在一塊兒實現 lodashthrottledebounce,實現起來以爲並不麻煩,可是最後和官方的一對比,發現功能的實現上仍是有差距的,爲了尋找個人問題,把官方源碼閱讀了一遍,本文是我閱讀完成後的一篇總結。github

注:本文只會列出比較核心部分的代碼和註釋,若是對所有的源碼有興趣的歡迎直接看個人repobash

什麼是throttle和debounce

throttle(又稱節流)和debounce(又稱防抖)其實都是函數調用頻率的控制器,這裏只作簡單的介紹,若是想了解更多關於這兩個定義的細節能夠看下後文給出的一張圖片,或者閱讀一下lodash的文檔app

throttle:將一個函數的調用頻率限制在必定閾值內,例如 1s 內一個函數不能被調用兩次。函數

debounce:當調用函數n秒後,纔會執行該動做,若在這n秒內又調用該函數則將取消前一次並從新計算執行時間,舉個簡單的例子,咱們要根據用戶輸入作suggest,每當用戶按下鍵盤的時候均可以取消前一次,而且只關心最後一次輸入的時間就好了。oop

lodash 對這兩個函數又增長了一些參數,主要是如下三個:ui

  • leading,函數在每一個等待時延的開始被調用
  • trailing,函數在每一個等待時延的結束被調用
  • maxwait(debounce纔有的配置),最大的等待時間,由於若是 debounce 的函數調用時間不知足條件,可能永遠都沒法觸發,所以增長了這個配置,保證大於一段時間後必定能執行一次函數

這裏直接劇透一下,其實 throttle 就是設置了 maxwaitdebounce,因此我這裏也只會介紹 debounce 的代碼,聰明的讀者們能夠本身思考一下爲何。this

個人實現與lodash的區別

我本身的代碼實現放在個人repo裏,你們有興趣的能夠看下。以前說過個人實現和 lodash 有些區別,下面就用兩張圖來展現一下。編碼

這是個人實現
spa

這是lodash的實現

這裏看到,個人代碼主要有兩個問題:

  1. throttle 的最後一次函數會執行兩次,並且並不是穩定復現。
  2. throttle 裏函數執行的順序不對,雖然個人功能實現了,可是對於每一次 wait 來講,我都是執行的 leading 那一次

lodash 的實現解讀

下面,我就會帶着這幾個問題去看看 lodasah 的代碼。

官方代碼的實現也不是很複雜,這裏我貼出一些核心部分代碼和我閱讀後的註釋,後面會講一下 lodash 的大概流程:

function debounce(func, wait, options) {
    let lastArgs,
        lastThis,
        maxWait,
        result,
        timerId,
        lastCallTime

    // 參數初始化
    let lastInvokeTime = 0 // func 上一次執行的時間
    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)) {
        // 對配置的一些初始化
    }

    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
        // 爲 trailing edge 觸發函數調用設定定時器
        timerId = setTimeout(timerExpired, wait)
        // leading = true 執行函數
        return leading ? invokeFunc(time) : result
    }

   function remainingWait(time) {
        const timeSinceLastCall = time - lastCallTime // 距離上次debounced函數被調用的時間
        const timeSinceLastInvoke = time - lastInvokeTime // 距離上次函數被執行的時間
        const timeWaiting = wait - timeSinceLastCall // 用 wait 減去 timeSinceLastCall 計算出下一次trailing的位置

        // 兩種狀況
        // 有maxing:比較出下一次maxing和下一次trailing的最小值,做爲下一次函數要執行的時間
        // 無maxing:在下一次trailing時執行 timerExpired
        return maxing
            ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
            : timeWaiting
    }

    // 根據時間判斷 func 可否被執行
    function shouldInvoke(time) {
        const timeSinceLastCall = time - lastCallTime
        const timeSinceLastInvoke = time - lastInvokeTime

        // 幾種知足條件的狀況
        return (lastCallTime === undefined //首次
            || (timeSinceLastCall >= wait) // 距離上次被調用已經超過 wait
            || (timeSinceLastCall < 0) //系統時間倒退
            || (maxing && timeSinceLastInvoke >= maxWait)) //超過最大等待時間
    }

    function timerExpired() {
        const time = Date.now()
        // 在 trailing edge 且時間符合條件時,調用 trailingEdge函數,不然重啓定時器
        if (shouldInvoke(time)) {
            return trailingEdge(time)
        }
        // 重啓定時器,保證下一次時延的末尾觸發
        timerId = setTimeout(timerExpired, remainingWait(time))
    }

    function trailingEdge(time) {
        timerId = undefined

        // 有lastArgs才執行,意味着只有 func 已經被 debounced 過一次之後纔會在 trailing edge 執行
        if (trailing && lastArgs) {
            return invokeFunc(time)
        }
        // 每次 trailingEdge 都會清除 lastArgs 和 lastThis,目的是避免最後一次函數被執行了兩次
        // 舉個例子:最後一次函數執行的時候,可能恰巧是前一次的 trailing edge,函數被調用,而這個函數又須要在本身時延的 trailing edge 觸發,致使觸發屢次
        lastArgs = lastThis = undefined
        return result
    }

    function cancel() {}

    function flush() {}

    function pending() {}

    function debounced(...args) {
        const time = Date.now()
        const isInvoking = shouldInvoke(time) //是否知足時間條件

        lastArgs = args
        lastThis = this
        lastCallTime = time  //函數被調用的時間

        if (isInvoking) {
            if (timerId === undefined) { // 無timerId的狀況有兩種:1.首次調用 2.trailingEdge執行過函數
                return leadingEdge(lastCallTime)
            }
            if (maxing) {
                // Handle invocations in a tight loop.
                timerId = setTimeout(timerExpired, wait)
                return invokeFunc(lastCallTime)
            }
        }
        // 負責一種case:trailing 爲 true 的狀況下,在前一個 wait 的 trailingEdge 已經執行了函數;
        // 而此次函數被調用時 shouldInvoke 不知足條件,所以要設置定時器,在本次的 trailingEdge 保證函數被執行
        if (timerId === undefined) {
            timerId = setTimeout(timerExpired, wait)
        }
        return result
    }
    debounced.cancel = cancel
    debounced.flush = flush
    debounced.pending = pending
    return debounced
}複製代碼

這裏我用文字來簡單描述一下流程:

首次進入函數時由於 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。

個人問題出在哪?

那麼,回到上面的兩個問題,個人代碼到底是哪裏出了問題呢?

爲何順序圖不對

研究了一下,lodash是比較穩定的在trailing時觸發前一次函數調用的,而個人則是每次在 maxWait 時觸發的下一次調用。問題就出在對於定時器的控制上。

由於在編碼時考慮到定時器和 maxwait 會衝突的問題,在函數每次被調用的時候都會 clearTimeout(timer),所以個人 trailing 判斷其實只對整個執行流的最後一次有效,而非 lodash 所說的 trailing 控制的是函數在每一個 wait 的最後執行。

而 lodash 並不會清除定時器,只是每次生成新的定時器的時候都會根據 lastCallTime 來計算下一次該執行的時間,不只保證了定時器的準確性,也保證了對每次 trailing 的控制。

爲何最後會觸發兩次

經過打 log 我發現這種觸發兩次的狀況很是湊巧,最後一次函數執行的時候,正好知足前一個時延的 trailing,而後本身這個 wait 的定時器也觸發了,因此最後又觸發了一次本次時延的 trailing,因此觸發了兩次。

理論上 lodash 也會出現這種狀況,可是它在每次函數執行的時候都會刪除 lastArgs 和 lastThis,而下次函數執行的時候都會判斷這兩個參數是否存在,所以避免了這種狀況。

總結

其實以前就知道 debouncethrottle 的用途和含義,可是每次用起來都得去看一眼文檔,經過此次本身實現以及對源碼的閱讀,終於作到了了熟於心,也發現本身的代碼設計能力仍是有缺陷,一開始並無想的很到位。

寫代碼的,仍是要多寫,多看;慢慢作到會寫,會看;與你們共勉。

相關文章
相關標籤/搜索