理解 Debouncing 與 Throttling 的區別

debouncethrottle 是前端開發中經常使用到的高階函數,都是用來處理 Timing Issues 的,兩者作用看似相同,都是為了防止函數被高頻調用,但實際內部還是有很大差異的。

為什么要引入這兩個高階函數呢?我們可以設想一下的情景:
很多 app 中都有搜索框,而一般搜索框都會配備智能聯想的功能,例如輸入一個關鍵詞的拼音可以聯想出相關的完整關鍵詞,但為了減輕服務器壓力,減少用戶不必要的流量開銷,我們需要一種機制來限制 API 請求的頻率。這就是引入這兩種高階函數的原因。

兩種節流函數的區別

Throttle (節流閥)

首先我們來看看 throttle 函數的工作方式,從字面意思上看可以理解為事件在一個管道中傳輸,加上這個節流閥以后,事件的流速就會減慢。實際上這個函數的作用就是如此,它可以將一個函數的調用頻率限制在一定閾值內,例如 1s,那么 1s 內這個函數一定不會被調用兩次,這里我畫了一個形象的示意圖,如下:

上方的時間軸代表上游事件,可能是用戶的輸入事件或設備傳感器發出的回調事件,如果沒有經過 throttle 函數處理,那么每次事件就會對應一次響應,假設一個用戶某次輸入了 10 個字符的搜索關鍵字,那么服務器就需要處理 10 次檢索請求,而如果加上節流閥,并且用戶輸入文字的手速很快,那么可能服務器就會收到兩次請求。

下面我用 Swift 做了一個簡易實現:

func throttle(threshold: TimeInterval, action: @escaping Action) -> Action {
    var last: CFAbsoluteTime = 0
    var timer: DispatchSourceTimer?
    return {
        let current = CFAbsoluteTimeGetCurrent();
        if current >= last + threshold {
            action()
            last = current
        } else {
            if timer != nil {
                timer!.cancel()
            }
            
            timer = DispatchSource.makeTimerSource()
            timer!.setEventHandler {
                action()
            }
            
            timer!.scheduleOneshot(deadline: .now() + .milliseconds(Int(threshold * 1000)))
            timer!.activate()
        }
    }
}

實際上就是記錄每次函數被實際調用時的絕對時間,如果下次調用時沒有到達指定時間,就推遲這次調用。這里要注意的是不能直接忽略掉這次調用,因為有可能會忽略掉用戶最后一次輸入操作而導致最終結果不完整,因此我們設置了一個 **Timer **來延遲觸發,并且如果有新 timer 要啟動,首先取消掉舊 timer,因為那次調用的結果已經沒有意義了。

然后我們看一下實際的效果(不動請在新窗口打開):


可以看到,不管我點擊按鈕有多快,在指定時間內只能觸發這么多次事件,而設置多長時間的閾值就視服務器性能和帶寬等因素而定了。

Debounce (防抖動)

在說明這個函數之前,我想舉一個大家都肯定遇見過的例子,那就是鼠標連擊。鼠標的微動開關都是有壽命的,而壽命長短與質量都參差不齊,很多廉價鼠標經常會出現連擊的問題,就是把單擊當成雙擊(或者三擊甚至更多擊...)。如果系統的鼠標事件被 debounce 函數處理過,那么這個問題就不可能發生了。事實上不管是 Windows 還是 macOS 都有相應的鉤子 API 來做到這件事,有興趣大家可以自己寫一個小程序來應對鼠標連擊。

那么這個函數到底做了什么事呢?我們先看下面這張示意圖:


我們可以看到,不管上游事件觸發了多少次,下游就產生了一次事件。也就是說當一次事件發生后,事件處理器要等一定閾值的時間,如果這段時間過去后 再也沒有 事件發生,就處理最后一次發生的事件。假設還差 0.01 秒就到達指定時間,這時又來了一個事件,那么之前的等待作廢,需要重新再等待指定時間。

實現如下:

func debounce(threshold: TimeInterval, action: @escaping Action) -> Action {
    var timer: DispatchSourceTimer?
    return {
        if timer != nil {
            timer!.cancel()
        }
        
        timer = DispatchSource.makeTimerSource()
        timer!.setEventHandler {
            action()
        }
        
        timer!.scheduleOneshot(deadline: .now() + .milliseconds(Int(threshold * 1000)))
        timer!.activate()
    }
}

同樣是使用了 timer,每次函數被調用時都開啟一個 timer 來推遲內部函數的執行,同時取消舊的 timer。這個函數實現起來比較簡單。

下面是實際效果(不動請在新窗口打開):


如果這個函數被應用到搜索中去,最終的效果就是,每次用戶輸入完他想搜索的關鍵字后,API 才會被調用,不管中途他輸入了多少字,輸入了多長時間。

總結

兩種函數在實際應用中選擇哪一個歸根到底還要看使用場景,對于其實現,我們需要講究一個原則就是:最后一次事件一定要得到處理。文中的代碼實現可以運用到生產環境,但并不是 thread-safe 的,只適合 UI 層的事件處理。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 1 概念 首先講講關注函數節流這個概念的原因~~,其實還是因為業務驅動,在實現無限加載組件(就是滾動到底部,就可以...
    Coder_不易閱讀 2,502評論 1 3
  • 最近在研究頁面渲染及web動畫的性能問題,以及拜讀《CSS SECRET》(CSS揭秘)這本大作。 本文主要想談談...
    諾奕閱讀 1,138評論 0 11
  • 一場雨,一場夢,一世的繁華如星落,徒留伊人,輕嘆息,空惆悵 億往昔,雨無香,一柄紙傘隨風立,濡濕了唇角的羞澀,落入...
    陌上草薰1228閱讀 228評論 0 0
  • 對這個世界了解越深 你就會越明白自己的 淺薄和無知 空著的木桶滾得最響 靜心修煉 不負時光
    小史努比閱讀 328評論 0 0