Throttle 和 Debounce 的本质及一个简单的实现

2019-11-25 17:04:10

参考地址 Throttle 和 Debounce 的本质及一个简单的实现

就不把这两个词翻译成中文了,直接解释他们的概念。实际上这两个东西本质上是一样的,作用都是「为了避免某个『事件』在『一个较短的时间段内』内连续被触发从而引起的其对应的『事件处理函数』不必要的连续执行」。那么区别在哪呢?

先来举个例子:

Debounce

比如一个页面的 "resize" 事件,我们对这个事件的处理可能是重新对页面进行布局或者至少是改变某个 dom 元素的布局,可以想象一般这个事件一旦触发就会短时间(比如是 500ms)内连续触发多次,然后对应的事件处理函数(比如叫 handler)会也会被执行对应的次数,但实际上我们关注的只是 500ms 内最后一次 "resize" 事件处理的结果,于是最开始的一次到倒数第二次中间的所有 "resize" 都是不需要去处理的,那么我们会怎么做呢?我们可能会对 handler 做个 500ms 的延时,同时在每次 "resize" 触发的时候记录它的「触发时间」,在下次 "resize" 的时候比较当前时间和上次触发时间,如果时间差小于 500ms 那么我们就把上一次的处理的剩下延时重置为 500ms,同时将当次的的触发时间作为下次触发时的参照时间,这样会造成什么结果呢?这样造成的结果是:在一个时间段内,如果任意相邻两次事件触发的间隔小于 500ms,那么不管这整个时间段的长度是多少,也就是说不管事件触发了多少次,最终 handler 都只会被执行一次,就是最后的那一次;极端情况下,如果这个时间段趋于无穷,那么 handler 一次也得不到执行。这种短时间间隔内处理多次事件触发的机制就是 Debounce。

Throttle

某些情况下对于 Debounce 的处理方式我们可能不满意,比如对每个 500ms 的间隔的事件的连续触发,我们想要 handler 至少执行一次,可能是在 500ms 的开头,也可能是在结尾,比如是开头,此时我们会怎么做?我们可能会想到每次触发事件时,把当次的触发时间和上次的「handler 的执行时间」(而 debounce 是上次事件的触发时间)对比,那么每次事件的触发时间和上次 handler 的执行时间会有个差值,如果这个差值大于 500ms,那么理所应当地,执行 handler 并记录此时的执行时间作为下一次触发时的参考时间;如果小于 500ms ,就什么也不做。这个延时到期了之后执行 handler,执行 handler 之后的再次触发事件时就创建一个新的时长为 500ms 的延迟。这样我们就保证了每个 500ms 内的多次事件触发的第一次总会得到处理。这种短时间间隔内处理多次事件触发的机制就是 Throttle。相同情形下,10s 中连续触发事件,任意相邻两次触发时间间隔小于 500ms,debounce 只会执行一次 handler 而 throttle 会执行 10(或者 11)次。

二者根本差别

有了上面的例子,再来总结下二者的概念,debounce 和 throttle 本质上都是「为了避免某个『事件』在『一个较短的时间段内』(称为 delta T)内连续触发从而引起的其对应的『事件处理函数』不必要的连续执行」的一种事件处理机制。二者的根本的区别在于 throttle 保证了在每个 delta T 内至少执行一次,而 debounce 没有这样的保证。体现在实现层面上的区别就是,每次事件触发时参考的「时间点」对于 debounce 来是「上一次事件触发的时间」并且在延时没有结束时会重置这个延时为 500ms,而对于 throttle 来讲是「上一次 handler 执行的时间」并且在延时尚未结束时不会重置延时。

实现

下面来尝试着实现一个简单的能提供这两种功能的函数。我们可以把 handler 的执行时机放在每个时间段的开头或者结尾,于是每种处理机制都有两种模式,「debounce + 开头执行」和「debounce + 结尾执行」以及「throttle + 开头执行」和「throttle + 结尾执行」。常用的模式是「debounce + 结尾执行」和「throttle + 开头执行」,「throttle + 结尾执行」也有意义,但是「debounce + 开头执行」个人感觉没什么意义基本可以被「throttle + 开头执行」替代吧?我们打算按照上面分析的先写一个通用的函数,然后 Throttle 和 Debounce 只用配置对应的参数再生成一个函数就行。函数名就叫 debounceOrThrottle,我们需要传入的参数有 fn(实际的 handler)、wait(时间间隔)、immediate(为 true 是表示在时间段的开头执行,否则在末尾执行)、executeOncePerWait(为 true 时表示 throttle 否则为 debounce),函数的返回值是一个新的经过包装的函数,于是得到我们的函数声明:

function debounceOrThrottle({ fn, wait = 300, immediate = false, executeOncePerWait = false }) {
  // 函数体
  return function() {
    // 返回函数的函数体
  }}

我们给 waitimmediateexecuteOncePerWait 各自一个默认值,这样的参数配置表明生成的是一个「debounce + 结尾执行」的模式。下面我们就先按照这种模式的实现逻辑来补充函数体,首先确定的是肯定会有 lastTriggerTimelastExecutedTime 这两个变量来作为下一次事件触发时的参考时间,然后还会有 context(用于作为某个对象的方法时提供 this 绑定)、args(用于保存 fn 的参数)、tId(用于保存 setTimeout 的返回值,作为判断延时是否到期的依据,当延时到期即 fn 执行后将之再设为 null)和 result(用于保存 fn 的返回值),都初始化为 null。先写 debounceOrThrottle 的返回值

function() {
  context = this
  args = arguments
  lastTriggerTime = Date.now()

  if(!tId) {
    tId = setTimeout(anotherFn, wait) // 此处 被 setTimeout 延时的可能除了要执行 fn 以外还有其他操作,
    // 故先用 anotherFn 占位
  }

  return result}

下面来是 anotherFn

const anotherFn = function() {
  const last = Date.now() - lastTriggerTime  if(last < wait && last > 0) {
    setTimeout(anotherFn, wait - last)
  } else {
    result = fn.apply(context, args)
    context = args = null
    tId = null
  }}

综合起来就得到一个「debounce + 结尾执行」模式的debounceOrThrottle

function debounceOrThrottle ({ fn, wait = 300, immediate = false, executeOncePerWait = false }) {
  let tId = null
  let context = null
  let args = null
  let lastTriggerTime = null
  let result = null
  let lastExecutedTime = null

  const later = function() {
    const last = Date.now() - lastTriggerTime    if(last < wait && last > 0) {
      setTimeout(later, wait - last)
    } else {
      result = fn.apply(context, args)
      context = args = null
      tId = null
    }
  }

  return function() {
    context = this
    args = arguments
    lastTriggerTime = Date.now()

    if(!tId) {
      tId = setTimeout(later, wait)
    }

    return result  }}

下面我们来过一遍程序执行的流程,比如现在 300ms 内三次触发了事件,节点分别是 0ms, 100ms, 250ms 各节点函数执行情况:

  • 0ms:lastTriggerTime = 0(为方便记日此时的 Date.now() 为 0 ) -> tId = setTimeout(anotherFn, wait) -> return null

  • 100ms: lastTriggerTime = 100 -> return null

  • 250ms: lastTriggerTime = 250 -> return null

fn 的执行就是这些情况,然后 later 的首次的执行时间点为 300ms,第二次执行点为 550ms

  • 300ms:last = 300 - 250 -> setTimeout(later, 300 - 50)

  • 550ms:last = 550 - 250 -> result = fn.apply(context, args); tId = null -> return result

可以知道上面的关键点在于每次 tId = null 时会创建一个新的延时,在这个延时到期之前所有的事件触发造成的结果是更新 lastTriggerTime 然后通过 setTimeout(later, wait - last) 更新了延时,(只要保持两次事件触发的时间间隔小于 300ms 那么 last < wait && last > 0 就会永远成立,也即 fn永远得不到执行)然后当延时到期时 tId = null

上面只是一种模式,因为 executeOncePerWaitimmediate 这两个参数还没有用到,下面是应用这两个参数之后的完整版:

function debounceOrThrottle ({ fn, wait = 300, immediate = false, executeOncePerWait = false }) {
  if (typeof fn !== 'function') {
    throw new Error('fn is expected to be a function')
  }

  let tId = null
  let context = null
  let args = null
  let lastTriggerTime = null
  let result = null
  let lastExecutedTime = null

  const later = function() {
    const last = Date.now() - (executeOncePerWait ? lastExecutedTime : lastTriggerTime)

    if(last < wait && last > 0) {
      setTimeout(later, wait - last)
    } else {
      if (!immediate) {
        executeOncePerWait && (lastExecutedTime = Date.now())
        result = fn.apply(context, args)
        context = args = null
      }

      tId = null
    }
  }

  return function() {
    context = this
    args = arguments    !executeOncePerWait && (lastTriggerTime = Date.now())
    const callNow = immediate && !tId    if(!tId) {
      executeOncePerWait && (lastExecutedTime = Date.now())
      tId = setTimeout(later, wait)
    }

    if (callNow) {
      executeOncePerWait && (lastExecutedTime = Date.now())
      result = fn.apply(context, args)
      context = args = null
    }

    return result  }}const debounce = ({ fn, wait, immediate }) =>
  debounceOrThrottle({
    fn,
    wait,
    immediate  })const throttle = ({ fn, wait, immediate = true }) =>
  debounceOrThrottle({
    fn,
    wait,
    immediate,
    executeOncePerWait: true
  })



  • 2020-12-06 19:05:13

    visual studio 配置 intellij idea快捷键

    我原本从intellij idea转换到visual studio是因为webstorm没办法远程开发,而visual studio有remote wsl,和remote ssh,看着挺不错的样子。

  • 2020-12-06 20:38:30

    intellij idea远程开发remote

    开发时一般的平台都是windows,但windows对开发极其不友好,一般都会在本地开启虚拟机,安装上linux环境进行项目的部署测试。下面介绍一种windows主机与linux虚拟机代码同步的方法。这个工具适用于jerbrains公司旗下的很多产品,比如idea、webstrom、phpstrom等。但是要注意你安装的IDE必须是专业版的,社区版的IDE是没有这个代码同步功能的哦!

  • 2020-12-07 05:46:56

    npm设置和取消代理的方法

    有时候是设置了全局代理对npm并不生效,不如直接给npm设置代理,至少在mac电脑我是有这种感觉的。

  • 2020-12-07 15:04:03

    node开发邮件系统总结

    因为multipart这种形式比较复杂,因此要利用boundary分割符,将邮件体分割成不同段来进行解析,boundary分为父段和子段,父段一般出现0次或1次,出现在末尾,每个子段中也有content-type和boundary,需要在进行解析,如果遇到i,iii里面的情况可直接解析,如果遇到ii中的情况,再按ii中的步骤进行解析

  • 2020-12-07 15:17:45

    email-templates + mjml 发送邮件

    mjml 是一个很不错的响应式邮件html 内容标签库,email-templates 是一个灵活强大的邮件发送框架,两者集成起来我们 可以设计灵活强大的邮件发送系统,以下是一个简单的集成使用,实际使用还有好多地方需要完善

  • 2020-12-07 15:19:00

    响应式邮件的编写插件介绍mjml

    以前做项目碰到发邮件的需求,邮件模板的编辑就是一件头疼的事。因为虽说邮件是支持 HTML 的,但是确是 HTML 子集程度的支持,所以存在必须通过 <table> 排版的恶心之处,还有很多兼容性的坑。本质上是各家邮件商的标准有差异吧。

  • 2020-12-07 16:14:22

    nodejs队列实现amqplib,rabbitmq

    其中StartConsumer 会在项目启动时启动,在整个生命周期中一直保持监听状态,在程序结束时mq的链接关闭。需要注意的是 noAck 这个参数,当为false是表示消息出队后不会自动删除,如果设置成true,则无论消息处理成功与否此消息会被删除。注意到在消息不成功是,调用了ch.nack(msg)),此方法是将消息重新入队。

  • 2020-12-07 16:15:46

    RabbitMQ详解

    当前市面上mq的产品很多,比如RabbitMQ、Kafka、ActiveMQ、ZeroMQ和阿里巴巴捐献给Apache的RocketMQ。甚至连redis这种NoSQL都支持MQ的功能。 ActiveMQ ActiveMQ是apache出品,最流行的,能力强劲的开源消息总线,并且它一个完全支持JMS规范的消息中间件。其丰富的API、多种集群构建模式使得它成为业界老牌消息中间件,在中小型企业中应用广泛。