背景

scroll 事件,resize 事件、鼠标事件(mousemovemouseover 等)、键盘事件(keyupkeydown 等)都存在被频繁触发的风险。
而频繁触发回调导致的大量计算会引发页面的抖动甚至卡顿。为了规避这种情况,我们需要一些手段来控制事件被触发的频率。

  • throttle(事件节流)
  • debounce(事件防抖)

debounce.gif
(图引自冴羽的学防抖一文)

节流的本质

防抖节流都以闭包的形式存在。
它们通过对事件对应的回调函数进行包裹、以自由变量的形式缓存时间信息,最后用 setTimeout 来控制事件的触发频率。

Throttle

throttle 的中心思想在于:在某段时间内,不管你触发了多少次回调,我都只认第一次,并在计时结束时给予响应。**每一段时间,只执行一次事件。**

大巴司机打开了计时器,在这十分钟内,后面下飞机的乘客都只能乘这一辆大巴,十分钟过去后,不管后面还有多少没挤上车的乘客,这班车都必须发走。

  • 司机,是节流阀,他控制发车的时机;
  • 乘客,是因频繁操作事件而不断涌入的回调任务,它需要接受“司机”的安排;
  • 计时器,是以自由变量形式存在的时间信息,它是“司机”决定发车的依据;
  • 发车,这个动作,就对应到回调函数的执行。

节流:通过在一段时间内无视后来产生的回调请求来实现的。只要一位客人叫了车,司机就会为他开启计时器,一定的时间内,后面需要乘车的客人都得排队上这一辆车,谁也无法让司机重新计时

实现:

1.使用时间戳

  1. // fn是我们需要包装的事件回调, interval是时间间隔的阈值
  2. function throttle(fn, interval) {
  3. // last为上一次触发回调的时间
  4. let last = 0
  5. // 将throttle处理结果当作函数返回
  6. return function () {
  7. // 保留调用时的this上下文
  8. let context = this
  9. // 保留调用时传入的参数
  10. let args = arguments
  11. // 记录本次触发回调的时间
  12. let now = +new Date()
  13. // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
  14. if (now - last >= interval) {
  15. // 如果时间间隔大于我们设定的时间间隔阈值,则执行回调
  16. last = now;
  17. fn.apply(context, args);
  18. }
  19. }
  20. }
  21. function getUserAction(e){...}; // 全局作用域中定义
  22. // 用throttle来包装scroll的回调
  23. const better_move = throttle(getUserAction, 1000);
  24. document.addEventListener('mousemove', better_move)

当鼠标移入时,事件立刻执行每过 1s 会执行一次,如果在 4.2s 停止触发,以后不会再执行事件。

throttle1.gif

this

在上述代码的第19行,若不使用fn.apply(context, args) ;而直接使用fn(args) ,
此时因为fn是在全局上下文中定义的,其this值会指向Window对象,而不会指向正确的调用对象— DOM元素。
所以这里需要保存this的值,当然,这里不使用context,直接使用this的效果也是一样。

简要分析一下,this的指向问题:

  • throttle函数的返回值赋给了better_move,所以这里保存的thisarguments都是better_move的调用者(DOM元素)传入的。


args

JavaScript 在事件处理函数中会提供事件对象 event;如果不使用 throttle函数,这里会打印 MouseEvent 对象:
函数节流 - 图4

但是在 throttle函数中,如果不传入args,即:fn.apply(context)却只会打印 undefined

2.使用定时器

  1. 当触发事件的时候,我们设置一个定时器;
  2. 再触发事件的时候,如果定时器存在,就不执行;
  3. 直到定时器执行,然后执行函数,清空定时器,这样就可以设置下个定时器。
    1. function throttle(func, wait) {
    2. var timeout;
    3. var last = 0;
    4. return function() {
    5. context = this;
    6. args = arguments;
    7. if (!timeout) {
    8. timeout = setTimeout(function(){
    9. timeout = null;
    10. func.apply(context, args)
    11. }, wait)
    12. }
    13. }
    14. }
    可以看到:当鼠标移入的时候,事件不会立刻执行,3s 后执行了一次,此后每 3s 执行一次,当数字显示为 3 的时候,立刻移出鼠标,相当于大约 9.2s 的时候停止触发,但是依然会在第 12s 的时候再执行一次事件

throttle2.gif

比较

  1. 时间戳实现:
    • 事件会立刻执行
    • 事件停止触发后没有办法再执行事件


  1. 定时器实现:
    • 事件会在 n 秒后第一次执行
    • 事件停止触发后依然会再执行一次事件

**

3.合并

有头有尾

鼠标移入能立刻执行,停止触发的时候还能再执行一次

  1. function throttle(func, wait) {
  2. var timeout, context, args, result;
  3. var previous = 0;
  4. var later = function() {
  5. previous = +new Date();
  6. timeout = null;
  7. func.apply(context, args)
  8. };
  9. var throttled = function() {
  10. var now = +new Date();
  11. //下次触发 func 剩余的时间
  12. var remaining = wait - (now - previous);
  13. context = this;
  14. args = arguments;
  15. // 如果没有剩余的时间了或者你改了系统时间
  16. if (remaining <= 0 || remaining > wait) {
  17. if (timeout) {
  18. clearTimeout(timeout);
  19. timeout = null;
  20. }
  21. previous = now;
  22. func.apply(context, args);
  23. } else if (!timeout) {
  24. timeout = setTimeout(later, remaining);
  25. }
  26. };
  27. return throttled;
  28. }

这里注意上述代码的第21行:当没有剩余时间时,需要做两件事:

  • 调用函数;
  • 取消定时器,可以说:时间戳的权重要大于定时器;

可以看到:鼠标移入,事件立刻执行; 晃了 3s后,事件再一次执行,当数字变成 3 (第6s) 的时候,移出鼠标,停止触发事件,9s 的时候,依然会再执行一次事件。

停止触发仍执行

这里解释一下:在第9s时,为什么会在执行一次事件?
因为:在第6s移除鼠标时,仍触发一次事件;但因为没有到达时间戳,所以第19行的判断体不执行, 而执行第26行的判断体,最后添加了一个计时器,保证停止后仍能触发一次事件。
throttle3.gif

4.underscore的节流实现

options参数

可以设置 options 作为第三个参数:

  • leading:false 表示禁用第一次执行
  • trailing: false 表示禁用停止触发的回调

leading:falsetrailing: false 不能同时设置。

  1. function throttle(func, wait, options) {
  2. var timeout, context, args, result;
  3. var previous = 0;
  4. if (!options) options = {};
  5. var later = function() {
  6. previous = options.leading === false ? 0 : new Date().getTime();
  7. timeout = null;
  8. func.apply(context, args);
  9. if (!timeout) context = args = null;
  10. };
  11. var throttled = function() {
  12. var now = new Date().getTime();
  13. // previous为0,代表最开始那次执行;
  14. // 设置为now表示,则后续的时间戳会未到达,禁止第一次回调执行
  15. if (!previous && options.leading === false) previous = now;
  16. var remaining = wait - (now - previous);
  17. context = this;
  18. args = arguments;
  19. if (remaining <= 0 || remaining > wait) {
  20. if (timeout) {
  21. clearTimeout(timeout);
  22. timeout = null;
  23. }
  24. previous = now;
  25. func.apply(context, args);
  26. if (!timeout) context = args = null;
  27. } else if (!timeout && options.trailing !== false) {
  28. // 此判断题根据trailing的值,来确定是否执行最后一次回调
  29. timeout = setTimeout(later, remaining);
  30. }
  31. };
  32. return throttled;
  33. }


取消

  1. throttled.cancel = function() {
  2. clearTimeout(timeout);
  3. previous = 0;
  4. timeout = null;
  5. }

参考文章