理解 Debouncing 与 Throttling 的区别

3,010 阅读4分钟

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 层的事件处理。