聊聊lodash的debounce實現

zhe.zhang發表於2019-03-04

本文同步自我的Blog

前段時間團隊內部搞了一個程式碼訓練營,大家組織在一起實現 lodashthrottledebounce,實現起來覺得並不麻煩,但是最後和官方的一對比,發現功能的實現上還是有差距的,為了尋找我的問題,把官方原始碼閱讀了一遍,本文是我閱讀完成後的一篇總結。

注:本文只會列出比較核心部分的程式碼和註釋,如果對全部的原始碼有興趣的歡迎直接看我的repo

什麼是throttle和debounce

throttle(又稱節流)和debounce(又稱防抖)其實都是函式呼叫頻率的控制器,這裡只做簡單的介紹,如果想了解更多關於這兩個定義的細節可以看下後文給出的一張圖片,或者閱讀一下lodash的文件

throttle:將一個函式的呼叫頻率限制在一定閾值內,例如 1s 內一個函式不能被呼叫兩次。

debounce:當呼叫函式n秒後,才會執行該動作,若在這n秒內又呼叫該函式則將取消前一次並重新計算執行時間,舉個簡單的例子,我們要根據使用者輸入做suggest,每當使用者按下鍵盤的時候都可以取消前一次,並且只關心最後一次輸入的時間就行了。

lodash 對這兩個函式又增加了一些引數,主要是以下三個:

  • leading,函式在每個等待時延的開始被呼叫
  • trailing,函式在每個等待時延的結束被呼叫
  • maxwait(debounce才有的配置),最大的等待時間,因為如果 debounce 的函式呼叫時間不滿足條件,可能永遠都無法觸發,因此增加了這個配置,保證大於一段時間後一定能執行一次函式

這裡直接劇透一下,其實 throttle 就是設定了 maxwaitdebounce,所以我這裡也只會介紹 debounce 的程式碼,聰明的讀者們可以自己思考一下為什麼。

我的實現與lodash的區別

我自己的程式碼實現放在我的repo裡,大家有興趣的可以看下。之前說過我的實現和 lodash 有些區別,下面就用兩張圖來展示一下。

這是我的實現

這是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 的用途和含義,但是每次用起來都得去看一眼文件,通過這次自己實現以及對原始碼的閱讀,終於做到了了熟於心,也發現自己的程式碼設計能力還是有缺陷,一開始並沒有想的很到位。

寫程式碼的,還是要多寫,多看;慢慢做到會寫,會看;與大家共勉。

相關文章