JavaScript 防抖与节流


作者:Seiya

时间:2019年08月22日


防抖


防抖的原理:当持续触发事件时,debounce 会合并事件且不会去触发事件,当一定时间内没有触发再这个事件时,才真正去触发事件。


我们开始尝试写第一段代码:

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


this

以上代码存在一个问题,在开始执行回调函数时,此时的 this 会指向 window 对象。所以我们进行一些修改,得到第二版代码:

function debounce(func, wait) {
  var timeout;
  return function() {
    var _this = this;

    clearTimeout(timeout)
    timeout = setTimeout(function() {
      func.apply(_this)
    }, wait)
  }
}


event 对象

接下来看另外一个问题:JavaScript 在事件处理函数中会提供事件对象 event。比如,我们有这样一个函数:

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

如果我们不使用 debounce 函数,这里可以正常打印出 event 对象,但是在我们实现的 debounce 函数中,只会打印出 undefined。所以我们对代码进行修改,得到第三版代码:

function debounce(func, wait) {
  var timeout;
  return function() {
    var _this = this;
    var args = arguments;

    clearTimeout(timeout)
    timeout = setTimeout(function() {
      func.apply(_this, args)
    }, wait)
  }
}


立即执行

这时候的防抖函数,功能基本已经完善了,接下来考虑一个新的需求:不用等到事件停止触发后才执行,而是立刻执行函数,然后等到停止触发 n 秒后,才可以重新触发执行。

function debounce(func, wait) {
  var timeout;
  return function() {
    var _this = this;
    var args = arguments;

    if(timeout) clearTimeout(timeout)
    var callNow = !timeout;
    timeout = setTimeout(function() {
      timeout = null;
    }, wait)

    if(callNow) func.apply(_this, args)
  }
}


结合“非立即执行”和“立即执行”

function debounce(func, wait, immediate) {
  var timeout;
  return function() {
    var _this = this;
    var args = arguments;

    if(timeout) clearTimeout(timeout)
    if (immediate) {
      // 如果已经执行过,不再执行
      var callNow = !timeout;
      timeout = setTimeout(function() {
        timeout = null
      }, wait)
      if(callNow) func.apply(_this, args)
    }
    else {
      timeout = setTimeout(function() {
        func.apply(_this, args)
      }, wait)
    }
  }
}


返回值

我们需要执行的函数可能是有返回值的,所以我们需要返回函数的执行结果。但是,当 immediate 为 false 的时候,因为使用了 setTimeout ,我们将 func.apply(context, args) 的返回值赋给变量,最后再 return 的时候,值将会一直是 undefined,所以我们只在 immediate 为 true 的时候返回函数的执行结果。

function debounce(func, wait, immediate) {
  var timeout, result;
  return function() {
    var _this = this;
    var args = arguments;

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


取消

最后在思考一个小需求:手动取消 debounce 函数。最后一版代码如下:

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

  var debounced =  function() {
    var _this = this;
    var args = arguments;

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

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

  return debounced;
}

我们可以这样使用:

var setUseAction = debounce(getUserAction, 3000, true);
container.onmousemove = setUseAction;
document.getElementById("button").addEventListener('click', function(){
  setUseAction.cancel();
})

具体效果如下图所示:





节流


节流的原理:当持续触发事件时,保证隔间时间触发一次事件。 持续触发事件时,throttle 会合并一定时间内的事件,并在该时间结束时真正去触发一次事件。


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



使用时间戳

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

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

  return function() {
    var now = +new Date();
    context = this;
    args = arguments;
    if (now - previous > wait) {
      func.apply(context, args);
      previous = now;
    }
  }
}

具体演示如下:



使用定时器

function throttle(func, wait) {
  var timeout;
  var context, args;

  return function() {
    context = this;
    args = arguments;
    if (!timeout) {
      timeout = setTimeout(function() {
        timeout = null;
        func.apply(context, args);
      }, wait)
    }
  }
}

tips

第一种节流事件会立刻执行,第二种事件会在 n 秒后第一次执行。

第一种节流事件停止触发后没有办法再执行事件,第二种事件停止触发后依然会再执行一次事件。



结合两种节流

鼠标移入能立刻执行,停止触发的时候还能再执行一次。代码如下:

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

  var later = function() {
    previous = +new Date();
    timeout = null;
    func.apply(context, args)
  };

  var throttled = function() {
    var now = +new Date();
    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);
    } else if (!timeout) {
      timeout = setTimeout(later, remaining);
    }
  };
  return throttled;
}




underscore 源码


防抖函数

/**
 * underscore 防抖函数,返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行
 *
 * @param  {function} func        回调函数
 * @param  {number}   wait        表示时间窗口的间隔
 * @param  {boolean}  immediate   设置为ture时,是否立即调用函数
 * @return {function}             返回客户调用函数
 */
_.debounce = function(func, wait, immediate) {
  var timeout, args, context, timestamp, result;

  var later = function() {
    var last = _.now() - timestamp;
    // 如果当前间隔时间少于设定时间且大于0就重新设置定时器(避免设置更多的定时器,提升性能)
    if (last < wait && last >= 0) {
      timeout = setTimeout(later, wait - last);
    } else {
      timeout = null;
      if (!immediate) {
        result = func.apply(context, args);
        if (!timeout) context = args = null;
      }
    }
  };

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

    timestamp = _.now();
    var callNow = immediate && !timeout;
    // 如果定时器不存在就创建一个
    if (!timeout) timeout = setTimeout(later, wait);
    if (callNow) {
      result = func.apply(context, args);
      context = args = null;
    }

    return result;
  };
}


节流函数

/**
 * underscore 节流函数,返回函数连续调用时,func 执行频率限定为 次 / wait
 *
 * @param  {function}   func      回调函数
 * @param  {number}     wait      表示时间窗口的间隔
 * @param  {object}     options   表示禁用第一次执行,传入{leading: false}。
 *                                表示禁用停止触发的回调,传入{trailing: false}
 *                                两者不能共存,否则函数不能执行
 * @return {function}             返回客户调用函数
 */
_.throttle = function(func, wait, options) {
  var context, args, result;
  var timeout = null;
  var previous = 0;

  if (!options) options = {};

  var later = function() {
    // 如果设置了 leading,就将 previous 设为 0,用于下面函数的第一个 if 判断
    previous = options.leading === false ? 0 : _.now();
    timeout = null;
    result = func.apply(context, args);
    if (!timeout) context = args = null;
  }

  return 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) {
      // 判断是否设置了定时器和 trailing
      // 没有的话就开启一个定时器
      // 并且不能不能同时设置 leading 和 trailing
      timeout = setTimeout(later, remaining);
    }
    return result;
  };
}
最后更新时间: 2019-8-22 16:13:28