lishengzxc / bblog

My Blog
https://github.com/lishengzxc/bblog/issues
178 stars 8 forks source link

Javascript 的 Debounce 和 Throttle 的原理及实现 #7

Open lishengzxc opened 8 years ago

lishengzxc commented 8 years ago

分析_.debounce.throttle

DOM 上有些事件是会频繁触发的,比如mouseoverscrollresize...。以前有个需求,是做一个图表,是用canvas画的,最初,如果图表画完,用户拖拽浏览器窗口,改变浏览器大小的话,图表并不会自适应的变化,所以就需要监听resize事件,每当窗口大小变化后,再重新绘制。但是resize是频繁触发的,这就导致了页面的明显的卡顿,因为每次resize后的回调要执行大量的计算。

当时比较急,遇到这个问题以后,直接就查了.debounce.throttle,就直接用了lodash。现在回过头了,看下源码,看看它的实现。

Debounce

英文释义:

n. 防反跳
按键防反跳(Debounce)为什么要去抖动呢?机械按键在按下时,并非按下就接触的很好,尤其是有簧片的机械开关,会在接触的瞬间反复的开合多次,直到开关状态完全改变。

我们希望开关只捕获到那次最后的精准的状态切换。在 Javascript 中,那些 DOM 频繁触发的事件,我们想在某个时间点上去执行我们的回调,而不是每次事件每次触发,我们就执行该回调。有点啰嗦,再简单一点的说,我们希望多次触发的相同事件的触发合并为一次触发(其实还是触发了好多次,只是我们只关注那一次)。

所以,在 Javascript 中,我们就希望频繁事件的回调函数在某段连续时间内,在事件触发后只执行一次

resize事件为例,在 2s 内的该事件会被触发多次(具体几次未知,不同浏览器并不一样)。我们需要对resize的回调函数做 Debounce 100ms 化,这样resize的回调会在 2.1s 后触发,之前 2s 以内的resize我就无视了。

我们先自己实现一个

/**
 *
 * @param fn {Function}   实际要执行的函数
 * @param delay {Number}  延迟时间,单位是毫秒(ms)
 *
 * @return {Function}     返回一个“防反跳”了的函数
 */

function debounce(fn, delay) {

  // 定时器,用来 setTimeout
  var timer

  // 返回一个函数,这个函数会在一个时间区间结束后的 delay 毫秒时执行 fn 函数
  return function () {

    // 保存函数调用时的上下文和参数,传递给 fn
    var context = this
    var args = arguments

    // 每次这个返回的函数被调用,就清除定时器,以保证不执行 fn
    clearTimeout(timer)

    // 当返回的函数被最后一次调用后(也就是用户停止了某个连续的操作),
    // 再过 delay 毫秒就执行 fn
    timer = setTimeout(function () {
      fn.apply(context, args)
    }, delay)
  }
}

将 Debounce 化后的闭包函数作为频繁触发的事件的回调,其实还是频繁执行的,只不过,返回的闭包函数内部通过clearTimeout(),让真正需要执行的回调函数不执行了,只有在连续时间内,不在触发频繁事件后的delay秒后,执行真正的回调。

Demo

http://lishengzxc.github.io/bblog/Debounce.html

document.addEventListener('mousemove', debounce(() => console.log(new Date().getTime()), 1000), false);

再鼠标不移动后的 1000ms 后,执行了真正的回调,打印了当前时间戳。

比较合理的应用场景还有,在一个表单的输入框中(包括多行文本输入框),想当用户停止输入后,再ajax

input.addEventListener('keyup', debounce(() => ajax(...), 1000), false);

_.debounce()分析

先看看underscore的。

  _.debounce = function(func, wait, immediate) {
    var timeout, result;

    var later = function(context, args) {
      timeout = null;
      if (args) result = func.apply(context, args);
    };

    var debounced = restArgs(function(args) {
      if (timeout) clearTimeout(timeout);
      if (immediate) {
        var callNow = !timeout;
        timeout = setTimeout(later, wait);
        if (callNow) result = func.apply(this, args);
      } else {
        timeout = _.delay(later, wait, this, args);
      }

      return result;
    });

    debounced.cancel = function() {
      clearTimeout(timeout);
      timeout = null;
    };

    return debounced;
  };

它的debounce还接受第三个参数immediate,这个参数是用来配置回调函数是在一个时间区间的最开始执行(immediatetrue),还是最后执行(immediatefalse),如果immediatetrue,意味着是一个同步的回调,可以传递返回值。

关键的地方是,单独拿出了一个later函数通过控制timer来觉得连续的时间除一开始后,是不是要执行回调。

loadshdebounce,接受更多的配置:

lodashdebounce源代码

leading=true等效于underscoreimmediate=truetrailing则正好相反。maxWait设置了超时时间,在规定的超时间后,一定调用回调。(通过内部设置了两个setTimeout,一个用来完成基础功能,让回调只执行一次,还有一个用来控制超时)

Throttle

英文释义:

n. 节流阀

throttle就是设置固定的函数执行速率,从而降低频繁事件回调的执行次数。前面提到的canvas绘制好的图表后,用户改变窗口大小后,重新绘制图表,就很适合使用throttle。最近在写一个 Tank 游戏,用户可以非常快的点击开火,但是我们需要通过 Throttle,来降低一些开火的频率。

我们先自己实现一个

/**
*
* @param fn {Function}   实际要执行的函数
* @param delay {Number}  执行间隔,单位是毫秒(ms)
*
* @return {Function}     返回一个“节流”函数
*/

function throttle(fn, threshhold) {

  // 记录上次执行的时间
  var last

  // 定时器
  var timer

  // 默认间隔为 250ms
  threshhold || (threshhold = 250)

  // 返回的函数,每过 threshhold 毫秒就执行一次 fn 函数
  return function () {

    // 保存函数调用时的上下文和参数,传递给 fn
    var context = this
    var args = arguments

    var now = +new Date()

    // 如果距离上次执行 fn 函数的时间小于 threshhold,那么就放弃
    // 执行 fn,并重新计时
    if (last && now < last + threshhold) {
      clearTimeout(timer)

      // 保证在当前时间区间结束后,再执行一次 fn
      timer = setTimeout(function () {
        last = now
        fn.apply(context, args)
      }, threshhold)

    // 在时间区间的最开始和到达指定间隔的时候执行一次 fn
    } else {
      last = now
      fn.apply(context, args)
    }
  }
}

代码中,比较关键的部分是最后部分的if .. else ..,每次回调执行以后,需要保存执行的函数的时间戳,为了计算以后的事件触发回调时与之前执行回调函数的时间戳的间隔,从而根据间隔判断要不要执行回调。

_.throttle()分析

直接看underscore的。因为lodash没有对其再进行包装。

 _.throttle = function(func, wait, options) {
    var timeout, context, args, result;
    var previous = 0;
    if (!options) options = {};

    var later = function() {
      previous = options.leading === false ? 0 : _.now();
      timeout = null;
      result = func.apply(context, args);
      if (!timeout) context = args = null;
    };

    var throttled = function() {
      var now = _.now();
      if (!previous && options.leading === false) previous = now;
      var remaining = wait - (now - previous);
      context = this;
      args = arguments;
      if (remaining <= 0 || remaining > wait) {
        if (timeout) {
          clearTimeout(timeout);
          timeout = null;
        }
        previous = now;
        result = func.apply(context, args);
        if (!timeout) context = args = null;
      } else if (!timeout && options.trailing !== false) {
        timeout = setTimeout(later, remaining);
      }
      return result;
    };

    throttled.cancel = function() {
      clearTimeout(timeout);
      previous = 0;
      timeout = context = args = null;
    };

    return throttled;
  };

其中previous相当于自己实现代码中的last。它还接受leadingtrailing来控制真正回调触发的时机,这和lodash_.debounce 差不太多。

最后

这里有一个可视化分析页面:http://demo.nimius.net/debounce_throttle/,大家可以点开看看。

tank0317 commented 7 years ago

最近在学习debouce的实现原理,lodash的源码maxWait的部分还是没太看懂,博主这篇文章也很棒,学习~

mrxf commented 6 years ago

讲的非常清晰。

AsceticBoy commented 6 years ago

楼主,你的 throttlenow 是一开始确定就不变的,建议修复下

KangdaOOLin commented 6 years ago

楼主,有个问题想问一下。throttle 的实现中,为什么需要判断时间间隔没到,就通过 clearTimeout 取消掉?

// 如果距离上次执行 fn 函数的时间小于 threshhold,那么就放弃
    // 执行 fn,并重新计时
    if (last && now < last + threshhold) {
      clearTimeout(timer)

      // 保证在当前时间区间结束后,再执行一次 fn
      timer = setTimeout(function () {
        last = now
        fn.apply(context, args)
      }, threshhold)

    // 在时间区间的最开始和到达指定间隔的时候执行一次 fn
    } 

按照我的理解,throttle 不应该是固定的时间间隔内确保只执行一次吗?所以,代码如下:

if (timer) {
  return;
}
timer = setTimeout(function() {
  clearTimeout(timer);
  timer = null;
  fn.apply(context, args);
}, threshold);
Jancat commented 6 years ago

我觉得在延迟执行时,应该计算剩余时间,在剩余时间后执行。这样能实现如果频繁调用的话,每个 threshhold 时间点都会执行一次。

否则如果每次都延迟 threshhold 时间执行,有可能会出现两次执行间隔接近 2*threshhold,延迟太久了。

function throttle(fn, threshhold) {
  let timer, last

  return function() {
    const context = this
    const args = arguments

    const now = +new Date()
    // 如果之前有执行过并且距离上次执行时间小于阈值,则延迟剩余时间后再执行
    // 保证执行间隔大于阈值
    const remaining = last ? last + threshhold - now : 0
    if (remaining > 0) {
      clearTimeout(timer)

      timer = setTimeout(() => {
        last = +new Date()
        fn.apply(context, args)
      }, remaining)
    }
    // 第一次调用会执行,从上次执行开始超过阈值也会执行
    else {
      last = +new Date()
      fn.apply(context, args)
    }
  }
}
Wangszzju commented 6 years ago

@Jancat 在setTimeout中使用了箭头函数的话,是不是应该不用保存context上下文?

Jancat commented 6 years ago

@Wangszzju 还是需要保存 context 上下文的。

setTimout 中的箭头函数中如果使用到this,则会绑定到调用该函数的目标对象,通常是目标元素,但 this 绑定不会影响到箭头函数中调用的函数内部 this

如果不使用 apply(),则 fn 中的 this 指向 window,通常不是我们希望的,所以需要保存 context,通过 fn.apply(context) 来让 fn 中的 this 指向 context(目标元素)。

overcache commented 5 years ago

@Jancat 用箭头函数不用保存 this 为 context. 他执行的时候的 this 值就是创建它的环境的 this, 跟它在什么环境下执行无关

overcache commented 5 years ago

@lishengzxc 你的 throttle 实现是不是有不妥当的地方. 假设我们要求每 60ms 只能开火一次. 如果用户每 20ms 点一次开火, 点了4次. 那么, 要求应该是 0ms 以及 60ms 的时候各发射一次子弹.对吧?

0ms : 第一次点击. else 分支. 马上发射一次子弹; 20ms: 第二次点击. if 分支. 设置定时器: 60ms 后即 80ms 时发射子弹 40ms: 第三次点击. if 分支. 取消 20ms 时设置的定时器, 并重新设置定时器: 60ms 后即 100ms 时发射子弹 60ms: 第四次点击. else 分支. 发射子弹. ... 100ms: 定时器生效, 又发射了一次(不该发射)

应该不管 if/else, 都要 clearTimeout

function throttle(fn, threshhold = 250) {
  let last
  let timer

  return function inner(...args) {
    const now = Date.now()
    clearTimeout(timer) // 每次触发都清除 timer

    if (last && now < last + threshhold) {
      timer = setTimeout(() => {
        last = Date.now()
        fn.apply(this, args)  // 箭头函数的 this 为创建它的环境的 this, 即 inner 函数的 this
      }, threshhold)
    } else {
      last = now
      fn.apply(this, args)
    }
  }
}
TimRChen commented 5 years ago

作者你好,我把你的debounce实现做了下修改,修改如下:


function debounce(func, delay) {
  // 保存函数调用时的上下文和参数,传递给 func
  const context = this
  const args = arguments
  // 返回一个函数,这个函数会在一个时间区间结束后的 delay 毫秒时执行 func 函数
  return () => {
    // 每次这个返回的函数被调用,就清除定时器,以保证不执行 func
    if (window.GLOBAL_TIMER) clearTimeout(window.GLOBAL_TIMER)
    // 当返回的函数被最后一次调用后(也就是用户停止了某个连续的操作),再过 delay 毫秒就执行 func
    window.GLOBAL_TIMER = setTimeout(() => func.apply(context, args), delay)
  }
}
TimRChen commented 5 years ago

问题在于debounce函数中clearTimeout(timer)语句,其实并没有实质作用。

因为timer变量被保存在debounce方法执行后返回的防抖函数的作用域中,清除的只是当前作用域内的timer变量,重复执行debounce函数,会产生多个防抖函数及其作用域,每个作用域中的timer变量都是不相干的,所以会导致防抖失效。

我稍作修改,这样就正常的实现了防抖功能,以上只是我的个人见解,还请您指教哈哈。

WanderHuang commented 5 years ago

作者你好,我把你的debounce实现做了下修改,修改如下:

function debounce(func, delay) {
  // 保存函数调用时的上下文和参数,传递给 func
  const context = this
  const args = arguments
  // 返回一个函数,这个函数会在一个时间区间结束后的 delay 毫秒时执行 func 函数
  return () => {
    // 每次这个返回的函数被调用,就清除定时器,以保证不执行 func
    if (window.GLOBAL_TIMER) clearTimeout(window.GLOBAL_TIMER)
    // 当返回的函数被最后一次调用后(也就是用户停止了某个连续的操作),再过 delay 毫秒就执行 func
    window.GLOBAL_TIMER = setTimeout(() => func.apply(context, args), delay)
  }
}

兄弟你这个this指向debounce调用空间的上下文,然后func实际执行时传的是debounce的参数。是不对的。 楼主那种写法能保证fn实际执行时的this与执行环境有关,然后fn的参数能够拿到实际传入的参数。

TimRChen commented 5 years ago

@WanderHuang 是的,您没指出来我都没注意这个问题,感谢指正!

John-Oldman-Wang commented 5 years ago

总感觉哪里不对- -

hanshou101 commented 5 years ago

好的,谢谢啦

kid1412621 commented 5 years ago

@icymind 你好,我想问下, 传入的 fn 的 this 变化时, 你的代码片段是不是不适用呢?

DevinXian commented 4 years ago

我觉得在延迟执行时,应该计算剩余时间,在剩余时间后执行。这样能实现如果频繁调用的话,每个 threshhold 时间点都会执行一次。

否则如果每次都延迟 threshhold 时间执行,有可能会出现两次执行间隔接近 2*threshhold,延迟太久了。

function throttle(fn, threshhold) {
  let timer, last

  return function() {
    const context = this
    const args = arguments

    const now = +new Date()
    // 如果之前有执行过并且距离上次执行时间小于阈值,则延迟剩余时间后再执行
    // 保证执行间隔大于阈值
    const remaining = last ? last + threshhold - now : 0
    if (remaining > 0) {
      clearTimeout(timer)

      timer = setTimeout(() => {
        last = +new Date()
        fn.apply(context, args)
      }, remaining)
    }
    // 第一次调用会执行,从上次执行开始超过阈值也会执行
    else {
      last = +new Date()
      fn.apply(context, args)
    }
  }
}

我觉得间隔没有问题吧,问题在于定时器没做更新,不能更新 args,节流虽然是节流了,但是拿到的不是最新的参数