详谈防抖和节流

增强用户体验的小招数

Posted by Li Yucang on December 29, 2017

详谈防抖和节流

在我们的日常开发中,经常会出现这种情况:

  1. 实现了 dom 拖拽功能,但是在绑定拖拽事件的时候发现每当元素稍微移动一点便触发了大量的回调函数,导致浏览器直接卡死。
  2. 给一个按钮绑定了表单提交的 post 事件,但是用户有些时候在网络情况极差的情况下多次点击按钮造成表单重复提交。

为了应对如上场景,便出现了函数防抖和函数节流两个概念。

函数防抖(debounce)

概念

概念: 在事件被触发 n 秒后再执行回调,如果在这 n 秒内又被触发,则重新计时。

生活中的实例: 如果有人进电梯(触发事件),那电梯将在 10 秒钟后出发(执行事件监听器),这时如果又有人进电梯了(在 10 秒内再次触发该事件),我们又得等 10 秒再出发(重新计时)。

对于函数防抖,有以下几种应用场景:

  1. 给按钮加函数防抖防止表单多次提交。
  2. 对于输入框连续输入进行 AJAX 验证时,用函数防抖能有效减少请求次数。
  3. 判断 scroll 是否滑到底部,滚动事件加上函数防抖

总的来说,适合多次事件一次响应的情况。

代码实现

这里我们简单实现一个 debounce 函数:

// 将会包装事件的 debounce 函数
function debounce(fn, delay) {
  // 维护一个 timer
  let timer = null;

  return function(...args) {
    // 通过 this 和 args 获取函数的作用域和变量
    let context = this;

    clearTimeout(timer);
    timer = setTimeout(function() {
      fn.apply(context, args);
    }, delay);
  }
}

我们用上面的 debounce 函数装饰对应的目标函数:

// 当用户滚动时被调用的函数
function foo() {
  console.log('You are scrolling!');
}

// 在 debounce 中包装我们的函数,过 2 秒触发一次
let elem = document.getElementById('container');
elem.addEventListener('scroll', debounce(foo, 2000));

首先,我们为 scroll 事件绑定处理函数,这时 debounce 函数会立即调用, 因此给 scroll 事件绑定的函数实际上是 debounce 内部返回的函数。

每一次事件被触发,都会清除当前的 timer 然后重新设置超时调用。 这就会导致每一次高频事件都会取消前一次的超时调用,导致事件处理程序不能被触发。

只有当高频事件停止,最后一次事件触发的超时调用才能在 delay 时间后执行

更进一步,我们不希望非要等到事件停止触发后才执行,我希望立刻执行函数,然后等到停止触发 n 秒后,才可以重新触发执行。

这里可以增加一个 immediate 参数来设置是否要立即执行:

function debouce(func,delay,immediate){
    var timer = null;
    
    return function(...args){
        var context = this;
        if(timer) clearTimeout(time);
        if(immediate){
            //根据距离上次触发操作的时间是否到达delay来决定是否要现在执行函数
            var doNow = !timer;
            //每一次都重新设置timer,就是要保证每一次执行的至少delay秒后才可以执行
            timer = setTimeout(function(){
                timer = null;
            },delay);
            //立即执行
            if(doNow){
                func.apply(context,args);
            }
        }else{
            timer = setTimeout(function(){
                func.apply(context,args);
            },delay);
        }
    }
}

函数节流(throttle)

概念

概念: 规定一个单位时间,在这个单位时间内,只能有一次触发事件的回调函数执行,如果在同一个单位时间内某事件被触发多次,只有一次能生效。

它和防抖动最大的区别就是,节流函数不管事件触发有多频繁,都会保证在规定时间内一定会执行一次真正的事件处理函数。

生活中的实例:比如在页面的无限加载场景下,我们需要用户在滚动页面时,每隔一段时间发一次 Ajax 请求,而不是在用户停下滚动页面操作时才去请求数据。这样的场景,就适合用节流阀技术来实现。

对于函数节流,有如下几个场景:

  1. 游戏中的刷新率
  2. DOM 元素拖拽
  3. Canvas 画笔功能
  4. 抢购时疯狂点击(鼠标按下)

总的来说,适合大量事件按时间做平均分配触发。

代码实现

通过比较函数上次执行时的时间戳和当前时间,我们来实现节流:

var throttle = function(func,delay){
    var prev = Date.now();
    return function(...args){
        var context = this;
        var now = Date.now();
        if(now-prev>=delay){
            func.apply(context,args);
            prev = Date.now();
        }
    }
}

当高频事件触发时,第一次应该会立即执行(给事件绑定函数与真正触发事件的间隔如果大于 delay 的话),而后再怎么频繁触发事件,也都是会每 delay 秒才执行一次。而当最后一次事件触发完毕后,事件也不会再被执行了。

但有时候,我们不仅需要在一个事件触发时立即执行,还需要在事件触发完毕还能执行一次节流函数,我们来调整一下上面的节流函数:

var throttle = function(func,delay){
    var timer = null;
    var startTime = Date.now();

    return function(...args){
        var curTime = Date.now();
        var remaining = delay-(curTime-startTime);
        var context = this;

        clearTimeout(timer);
        if(remaining<=0){
            func.apply(context,args);
            startTime = Date.now();
        }else{
            timer = setTimeout(function(){
                func.apply(context,args);
            },remaining);
        }
    }
}

需要在每个 delay 时间中一定会执行一次函数,因此在节流函数内部使用开始时间、当前时间与 delay 来计算 remaining,当 remaining<=0 时表示该执行函数了,如果还没到时间的话就设定在 remaining 时间后再触发。当然在 remaining 这段时间中如果又一次发生事件,那么会取消当前的计时器,并重新计算一个 remaining 来判断当前状态。

小结

函数防抖和函数节流是在时间轴上控制函数的执行次数,在实际的应用中,我们需要结合具体的场景和需求采取相应的策略。