每日一个npm包 —— _.throttle

221 阅读4分钟

throttle 背景

在前端开发中会遇到一些频繁的事件触发,比如:

  • scroll 页面滚动
  • mousemove 鼠标移动

但有的时候,我们不想这些事件绑定的回调被频繁执行,否则可能出现页面卡顿等情况降低用户体验感。为了解决这个问题,我们可以给事件的回调加上节流 throttle

throttle 原理

💡 一个事件持续触发,每隔一段时间只会执行一次事件的回调。

现实生活的例子就是饮料机的按钮。当顾客按按钮时,饮料机会出一杯饮料,出完便自动停止。如果在出饮料的时候,顾客又按了按钮,饮料机也不会多出饮料(不响应本次事件),等一杯饮料出完后,顾客再次按按钮,才会出下一杯饮料。

throttle 源码

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

时间戳版本

使用时间戳,当触发事件的时候,我们记录本次事件触发的时间戳,然后减去之前的时间戳,如果大于等于设置的时间周期,就执行函数,并更新时间戳。注意 functhis 绑定和入参 args 这两个小细节。

function throttle(func, wait) {
  // 记录 func 上次执行的时间戳
  let previous = 0; // 初始化为 0 代表从未执行过 func

  function throttled(...args) {
    // 记录本次事件触发的时间戳
    const now = Date.now();

    if (now - previous >= wait) {
      // 更新 func 执行的时间戳
      previous = now;
      return func.call(this, ...args);
    }
  }

  return throttled;
}

定时器版本

当触发事件的时候,我们设置一个定时器,再触发事件的时候,如果定时器存在,就不执行任何逻辑,直到定时器回调执行,执行 func,清空定时器,这样就可以设置下个定时器。

function throttle(func, wait) {
  let timeout = null;  // 值为 null 代表当前没有计时器

  function throttled(...args) {
    if (!timeout) {  // 没有定时器,代表当前事件为连续事件的第一个事件
      timeout = setTimeout(() => {
        timeout = null;
        func.call(this, ...args);
      }, wait);
    }
  }

  return throttled;
}

双剑合璧

可以观察到:时间戳版本会在第一个事件触发时立即执行 func,定时器版本则相反,在第一个事件触发后 n 秒后才执行 func

leading 代表首次是否执行,trailing 代表结束后是否再执行一次。我们可以实现一个有头有尾的 throttle。如下图所示:

Untitled.png

回调会在首尾都被调用(此图源自www.npmjs.com/package/@os…

我们综合时间戳和定时器两者的优势,写出如下代码:

function throttle(func, wait) {
  let timeout;
  let previous = 0;

  function throttled(...args) {
    const now = Date.now();
    // 下次触发 func 剩余的时间
    const remaining = wait - (now - previous);

    // 如果没有剩余的时间了或者系统时间发生变化
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }

      previous = now;
      return func.call(this, ...args);
    } else if (!timeout) {
      timeout = setTimeout(() => {
        previous = Date.now();
        timeout = null;
        func.call(this, ...args);
      }, remaining);
    }
  }

  return throttled;
}

主要思想是让时间戳部分负责连续事件的第一个事件的回调执行,后续触发的事件归计时器负责。

上述代码综合了定时器和时间戳,且需要两者相互配合,代码逻辑有点复杂,读者可以试着连续触发三次事件,手动过一下代码到底是如何执行的,来加深理解。

支持配置

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

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

  • leadingfalse 表示禁用第一次执行
  • trailingfalse 表示禁用停止触发的回调
function throttle(func, wait, options = {}) {
  let timeout;
  let previous = 0;

  function throttled(...args) {
    const now = Date.now();
    // 该事件为连续事件的第一个事件 且 leading 为 false
    if (!previous && options.leading === false) previous = now;
    const remaining = wait - (now - previous);

    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }

      previous = now;
      return func.call(this, ...args);
    } else if (!timeout && options.trailing !== false) { // trailing 为 false 则直接放弃计时器逻辑
      timeout = setTimeout(() => {
        previous = options.leading === false ? 0 : Date.now();
        timeout = null;
        func.call(this, ...args);
      }, remaining);
    }
  }

  return throttled;
}

我们要注意 underscore 的实现中有这样一个问题:那就是 leading: false 和 trailing: false 不能同时设置。

支持取消

underscore 中的 throttle 还支持取消。只需要给 throttle 挂一个 cancel 函数即可:

function throttle(func, wait, options) {
  let timeout = null;
  let previous = 0;

  function throttled(...args) { ... }

  throttled.cancel = function () {
    clearTimeout(timeout);
    timeout = null;
    previous = 0;
  };

  return throttled;
}

引用资料&延申阅读