防抖与节流

9 分钟

防抖 & 节流

前端开发为防止事件的频繁触发,采用的两种解决方法:防抖 节流

防抖

原理:不管事件触发多少次,都是以最新的一次开始执行

采用定时器 实现设定时间之后才触发 设定时间之内再次触发 会重新计时

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

不改变其 this 指向

function debounce(func, wait) {
  var timeout;
  return function () {
    var context = this;
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      func.apply(context)
    }, wait);
  }
}

event 对象

JavaScript 在事件处理函数中会提供事件对象event 不使用 debouce 函数,会打印出 MouseEvent 对象,但是用了debouce函数却打印出 undefined

所以修改一下代码

function debounce(func, wait) {
  var timeout;
  return function () {
    var context = this;
    var args = arguments
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      func.apply(context, args)
    }, wait);
  }
}

立即执行

我们想要事件触发后函数立即执行,等到停止触发n秒后,才能重新触发执行 加一个 immediate 参数判断是否是立刻执行。

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

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

    if (immediate && timeout == null) {
      result = func.apply(context, args)
      timeout = setTimeout(() => {
        timeout = null
      }, wait);

      return result;
    }

    clearTimeout(timeout)
    timeout = setTimeout(() => {
      func.apply(context, args)
    }, wait);
  }
}

取消

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

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

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

    if (immediate && timeout == null) {
      result = func.apply(context, args)
      timeout = setTimeout(() => {
        timeout = null
      }, wait);

      return result;
    }

    clearTimeout(timeout)
    timeout = setTimeout(() => {
      func.apply(context, args)
    }, wait);
  }

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

  return debounced
}

节流

原理:如果持续触发事件,在预设的时间间隔内,只会执行一次事件,每隔一段时间执行一次。

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

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

还是用 debounced 那个例子

var container = document.querySelector('#container')
let count = 0

var getUserAction = function (e) {
  container.innerText = count++
}

var setUseAction = throttle(getUserAction, 1000);

container.addEventListener('mousemove', setUseAction)

使用时间戳

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

  return function () {
    /**
     * 不用加号是  Thu Sep 01 2022 18:52:49 GMT+0800 (中国标准时间)
     * 用加号是 时间戳
     */
    var now = +new Date();
    context = this;
    args = arguments;
    if (now - previous > wait) {
      func.apply(context, args);
      previous = now;
    }
  }
}

使用定时器

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

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

以上两种写法能达到的效果是:鼠标移入立刻执行,每隔1秒再执行一次,假设在3.2秒停止鼠标移动,4秒的时候不会有再执行一次,叫做有头无尾

那怎么实现移入一秒后开始执行,在3.2秒停止鼠标移动,4秒的时候再执行一次呢?也就是无头有尾

还是利用定时器,稍作修改

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

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

有头有尾(字节真题)

如果两个都要,既要进入立即执行,又要停止触发时再执行一次,俗称有头有尾

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

  return function () {
    context = this;
    args = arguments;
    var now = +new Date();
    // 计算下次触发 func 函数剩余时间
    var remaining = wait - (now - previous)
    // 如果剩余时间小于零,就是时间到该执行函数了 || 后面是系统时间被修改的情况
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      func.apply(context, args);
      previous = now;
    }
    // 时间没到的话,设置一个定时器,匹配不再触发函数的情况,时间一到就执行
    else if (!timeout) {
      timeout = setTimeout(() => {
        // 更新previous时间,不然会出现func函数连续调用两次
        previous = +new Date()
        timeout = null
        func.apply(context, args);
      }, remaining);
    }
  }
}

参数配置

现在想要有个配置参数,可以让我任选一种模式使用有头有尾、无头有尾、有头无尾

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

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

function throttle(func, wait, options) {
  var context, args;
  var previous = 0;
  var timeout = null;
  /**
   * leading: false 表示禁用第一次执行
   * trailing: false 表示禁用停止触发的回调
   * 默认有头无尾
   */
  var { leading = true, trailing = false } = options || {}

  return function () {
    context = this;
    args = arguments;
    /**
     * 不用加号是  Thu Sep 01 2022 18:52:49 GMT+0800 (中国标准时间)
     * 用加号是 时间戳
     */
    var now = +new Date();
    // 有头:就0开始remaining会为负立即执行一次;无头:remaining === wait走下面
    !previous && (previous = leading ? 0 : now)
    // 计算下次触发 func 函数剩余时间
    var remaining = wait - (now - previous)
    // 如果剩余时间小于零,就是时间到该执行函数了
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      func.apply(context, args);
      previous = now;
    }
    // 时间没到的话,设置一个定时器,匹配不再触发函数的情况,时间一到就执行
    else if (!timeout && trailing) {
      timeout = setTimeout(() => {
        // 更新previous时间,不然会出现func函数连续调用两次
        previous = leading ? +new Date() : 0
        timeout = null
        func.apply(context, args);
      }, remaining);
    }
  }
}

易懂版本

/**
 *
 * @param {function} fn 要节流的函数
 * @param {number} wait 需要节流的毫秒
 * @param {boolean} leading 首次是否执行
 * @param {boolean} trailing 最后一次是否执行
 */
function throttle(fn, wait = 200, leading = true, trailing = false) {
  let timer = null
  let previous = 0

  return function (...args) {
    const now = Date.now()
    const remaining = wait - (now - previous)

    // 首次调用且 leading = false 时,记录时间但不执行
    if (leading === false && previous === 0) {
      previous = now
    }

    // 时间间隔已到,立即执行
    if (remaining <= 0 || remaining > wait) {
      if (timer) {
        clearTimeout(timer)
        timer = null
      }
      previous = now
      fn.apply(this, args)
    }
    // 时间间隔未到,且需要 trailing
    else if (trailing && !timer) {  // ✅ 只在没有 timer 时设置
      timer = setTimeout(() => {
        previous = leading === false ? 0 : Date.now()  // ✅ 正确重置
        timer = null
        fn.apply(this, args)
      }, remaining)  // ✅ 使用剩余时间
    }
  }
}