防抖与节流

防抖

防抖的原理就是:你尽管触发事件,但是我一定在事件触发 n 秒后才执行,如果你在一个事件触发的 n 秒内又触发了这个事件,那我就以新的事件的时间为准,n 秒后才执行,总之,就是要等你触发完事件 n 秒内不再触发事件,我才执行,真是任性呐!

初步实现

function debounce(func,wait){
  let timeout
  return function(){
    clearTimeout(timeout)
    timeout = setTimeout(func,wait)
  }
}

完善

this 的指向问题,因为涉及到了定时器

  let count = 1;
  const container = document.getElementById('container');

  function getUserAction() {
      container.innerHTML = count++;
      console.log(this)
  };

  function debounce(func,wait){
    let timeout
    return function(){
      clearTimeout(timeout)
      timeout = setTimeout(() => {
        func.call(this)
      },wait)
    }
  }

  container.onmousemove = debounce(getUserAction,1000);

不使用箭头函数的话,需要主动绑定 this

  function debounce(func,wait){
    let timeout
    return function(){
      const ctx = this // 事件回调函数指向正确的this
      clearTimeout(timeout)
      timeout = setTimeout(function() {
        func.call(ctx) // 调用call和apply方法的同时会执行函数,所以必须包裹在一个匿名函数内
      },wait)
    }
  }

事件 event 对象的缺失

  function debounce(func,wait){
    let timeout
    return function(ev){
      clearTimeout(timeout)
      timeout = setTimeout(() => {
        func.call(this,ev)
      },wait)
    }
  }

关键是意识到真正作为回调函数的是 debounce 函数的返回值,所以这个函数有着正确的 this 指向和传入的参数

* 需求

立即触发

希望第一次可以立即触发函数,如果频繁触发,则需要在停止触发 n 秒后才可以继续触发

function debounce(func, wait, immediate) {

  var timeout;
  var first  = true
  var callNow = false
  return function () {
    var context = this;
    var args = arguments;

    if (timeout) clearTimeout(timeout);
    if (immediate) {
      timeout = setTimeout(function(){
        callNow = true;
      }, wait)
      // 保证第一次执行,和之后可以重复执行
      if (first || callNow){
        func.apply(context, args)
        first = false
        callNow = false
      }
    }
    else {
      timeout = setTimeout(function(){
        func.apply(context, args)
      }, wait);
    }
  }
}

利用定时器的返回值的特性,减少变量

function debounce(func, wait, immediate) {

  var timeout;

  return function () {
    var context = this;
    var args = arguments;

    if (timeout) clearTimeout(timeout);
    if (immediate) {
      // timeout的值不为0,说明开启了定时器,不可执行
      var callNow = !timeout;
      // 只要在wait时间内不再触发事件,timeout会从一个数字(定时器编号)变成null
      timeout = setTimeout(function(){
        timeout = null;
      }, wait)
      if (callNow) func.apply(context, args)
    }
    else {
      timeout = setTimeout(function(){
        func.apply(context, args)
      }, wait);
    }
  }
}

取消防抖

希望能取消 debounce 函数,比如说我 debounce 的时间间隔是 10 秒钟,immediate 为 true,这样的话,我只有等 10 秒后才能重新触发事件,现在我希望有一个按钮,点击后,取消防抖,这样我再去触发,就可以又立刻执行啦

  function debounce(func, wait, immediate) {
  
    var timeout, result;
  
    var debounced = function () {
      var context = this;
      var args = arguments;
  
      if (timeout) clearTimeout(timeout);
      if (immediate) {
        // 如果已经执行过,不再执行
        var callNow = !timeout;
        timeout = setTimeout(function(){
          timeout = null;
        }, wait)
        if (callNow) result = func.apply(context, args)
      }
      else {
        timeout = setTimeout(function(){
          func.apply(context, args)
        }, wait);
      }
      return result;
    };
  
    debounced.cancel = function() {
      clearTimeout(timeout);
      timeout = null;
    };
  
    return debounced;
  }

使用

var count = 1;
var container = document.getElementById('container');

function getUserAction(e) {
  container.innerHTML = count++;
};

var setUseAction = debounce(getUserAction, 10000, true);

container.onmousemove = setUseAction;

document.getElementById("button").addEventListener('click', function(){
  setUseAction.cancel();
})

返回值

虽然说一般事件绑定函数是没有返回值的,因为传入的是一个回调函数,想要获得回调函数的处理结果需要再传入一个回调函数接受这个结果,但是这样没办法再 immediate 为 false 的时候获得返回值,因为定时器的 this 指向 window,且不接受额外的回调函数,最好的方法还是使用 promise

对比

前面的防抖表现为:停止一段时间后,由定时器触发

需求内的防抖变成了:立即执行,停止一段时间后,才可以再去触发

后一种情况才是实际中更加多见的吧

防抖应用场景

  1. 搜索框输入查询,如果用户一直在输入中,没有必要不停地调用去请求服务端接口,等用户停止输入的时候,再调用,设置一个合适的时间间隔,有效减轻服务端压力。
  2. 表单验证
  3. 浏览器窗口缩放,resize 事件等。

按钮的防抖都是一次性的结果, 不希望多次

但是双十一的购买按钮, 不会是防抖, 只会是节流, 一直点击直到可以购买

节流

节流的原理很简单:如果你持续触发事件,每隔一段时间,只执行一次事件。

根据首次是否执行以及结束后是否执行,效果有所不同,实现的方式也有所不同。我们用 leading 代表首次是否执行,trailing 代表结束后是否再执行一次。

关于节流的实现,有两种主流的实现方式,一种是使用时间戳,一种是设置定时器。

使用时间戳

使用时间戳,当触发事件的时候,我们取出当前的时间戳,然后减去之前的时间戳 (最一开始值设为 0 ),如果大于设置的时间周期,就执行函数,然后更新时间戳为当前的时间戳,如果小于,就不执行。

let count = 1;
const container = document.getElementById('container');

function getUserAction(ev) {
  container.innerHTML = count++;
  // console.log(this)
  console.log(ev)
};

function throttle (func,wait){
  let ctx
  let prev = 0

  return function(...args){
    // 返回的是时间戳对象,先转换为数字
    let now = +new Date()
    ctx = this
    if(now - prev > wait){
      func.apply(ctx,args)
      prev = now
    }
  }

}

container.onmousemove = throttle(getUserAction,1000);

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

使用定时器

当触发事件的时候,我们设置一个定时器, 触发事件的时候,如果定时器存在,就不执行,直到定时器执行,然后执行函数,清空定时器,这样就可以设置下个定时器。

function throttle(func, wait) {
  var timeout;

  return function() {
    context = this;
    args = arguments;
    // timeout 为 null 的时候会启动一个定时器
    // 除非定时器执行,否则无法开启下一个定时器
    if (!timeout) {
      timeout = setTimeout(function(){
        timeout = null;
        func.apply(context, args)
      }, wait)
    }
  }
}

当鼠标移入的时候,事件不会立刻执行,晃了 3s 后终于执行了一次,此后每 3s 执行一次,当数字显示为 3 的时候,立刻移出鼠标,相当于大约 9.2s 的时候停止触发,但是依然会在第 12s 的时候执行一次事件。

所以比较两个方法:

  1. 第一种事件会立刻执行,第二种事件会在 n 秒后第一次执行
  2. 第一种事件停止触发后没有办法再执行事件,第二种事件停止触发后依然会再执行一次事件

各取所长

可能会有这样的需求:就是鼠标移入能立刻执行,停止触发的时候还能再执行一次

function throttle(func, wait) {
  var timeout, context, args, result;
  var previous = 0;

  var throttled = function () {
    var now = +new Date();
    //下次触发 func 剩余的时间
    var remaining = wait - (now - previous);
    context = this;
    args = arguments;
    // 如果没有剩余的时间了或者你改了系统时间
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        // 防止重复触发,剩余时间小于0,说明定时器马上就要执行了
        // 由于事件循环机制可能造成的误差
        clearTimeout(timeout);
        timeout = null;
        console.log('in timeout')
      }
      // 头执行
      previous = now;
      func.apply(context, args);
    } else if (!timeout) {
      // 中间节流和尾执行 调用 later 传入剩余时间
      // 还有剩余时间,且上一次设置的定时器已经执行完毕
      // 频繁触发时 remaining 都是差不多的值 990+
      timeout = setTimeout(later, remaining);
      console.log(remaining)
    }
  };

  var later = function () {
    // 本来 时间戳 部分的逻辑 也可以做到中间节流的,但是每次都被later抢先了
    previous = +new Date();
    timeout = null;
    func.apply(context, args)
  };

  return throttled;
}

但是我有时也希望无头有尾,或者有头无尾,这个咋办?

那我们设置个 options 作为第三个参数,然后根据传的值判断到底哪种效果,我们约定:

leading:false 表示禁用第一次执行

trailing: false 表示禁用停止触发的回调

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

  var later = function() {
    // 不需要leading,previous为0,需要leading,previous为 时间戳
    previous = options.leading === false ? 0 : new Date().getTime();
    timeout = null;
    func.apply(context, args);
    // 防止内存泄漏
    context = args = null;
  };

  var throttled = function() {
    var now = new Date().getTime();
    // previous为0 不需要leading
    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;
      func.apply(context, args);
      if (!timeout) context = args = null;
    } else if (!timeout && options.trailing !== false) {
      timeout = setTimeout(later, remaining);
    }
  };
  return throttled;
}

https://github.com/mqyqingfeng/Blog/issues/26

应用场景

  1. 按钮点击事件
  2. 拖拽事件
  3. onScoll
  4. 计算鼠标移动的距离 (mousemove)