跳至主要內容

手写防抖与节流

Loclink手写合集js前端原创大约 11 分钟约 3269 字

手写防抖与节流

什么是防抖和节流?

首先需要澄清一个比较常见的误区,那就是防抖和节流在前端开发中并不是特指一个方法,而是应该将其拆分去看待,实质上, 防抖(debounce)节流(throttle) 是两个不同的函数,只不过他们都是优化高频执行代码的一种手段而已,所以通常是一起出现在我们的视野中,导致经常会被我们误认为是一个方法。而它们的使用场景是在监听一些浏览器事件时,例如:resizescrollkeypressmousemove等,这类事件会不断的调用已绑定事件的回调函数,极大的浪费资源,降低了前端性能。为了优化体验,需要对这类事件进行调用次数的限制,为此我们就可以使用 防抖(debounce)节流(throttle) 的方式来减少调用频率。

定义

  • 节流:n 秒内只运行一次,若在 n 秒内重复触发,只有一次生效。

    • 实际场景:
      在《飞机大战游戏》中按下空格键发射炮弹,而在每一秒钟无论按下多少次空格键,仅会发射出一枚炮弹。
  • 防抖:n 秒后再执行该事件,若在 n 秒内重复执行了此事件,则重新计时。

    • 实际场景:
      向某个输入框输入内容时,需要向服务器发送网络请求,查询当前已输入关键字的联想数据,若用户在 500ms 内一直保持持续的输入内容,则不向服务器发送网络请求,若 500ms 内未输入任何内容,则在此之后将已输入内容发送给服务器获取关键字检索数据。

节流和防抖函数都属于高阶函数,所以它们都接收一个函数作为参数并返回一个新的函数。在我们真实开发过程中,会直接将返回的函数绑定到高频触发的事件上。

实现节流

一、基础版:

  • 参数一:fn 需要节流的函数
  • 参数二:interval 执行间隔时间

使用时间戳记录本次与上次的执行时间,每次调用时都判断相隔时间是否大于 interval(执行间隔时间),大于则执行该函数

const throttle = (fn, interval) => {
  let lastTime = 0; // 上次调用的时间,初始值为0

  function _throttle() {
    const nowTime = new Date().getTime(); // 获取当前时间戳
    const remainTime = interval - (nowTime - lastTime);
    // 剩余时间如果小于或等于0,则表示与上次执行的间隔时间大于了给定值interval 则执行该函数
    if (remainTime <= 0) {
      fn(); // 执行传入的函数

      // 将当前时间赋值给上次时间,在下次调用此函数时作为上次时间进行判断
      lastTime = nowTime;
    }
  }
  return _throttle;
};

使用一下:

// 获取btn元素
const btnEl = document.querySelector('.btn');

// 定义事件绑定的函数
const foo = () => {
  console.log('foo 被执行');
};

// 节流处理
const fooThrottle = throttle(foo, 500);

// 绑定执行函数
btnEl.onclick = fooThrottle;

// 连续点击按钮,测试节流效果...

此版本虽然实现了节流的效果,但该版本中对函数this的绑定我们并没有做任何处理,所以在函数体中如果用户使用了this,则该this必然是指向window的,如果需要被执行的函数的 this 是指向某个对象的(例如在绑定的事件中调用,this一定指向被调用的Dom元素对象),那么在传入后,这个 this 的指向将会被丢失,甚至我们希望在foo函数中可以拿到button点击事件传入的event事件对象,目前看来也是拿不到的,那么如何去解决这些问题呢?这就引出了我们的进阶版。

二、进阶版:

  • 参数一:fn 需要节流的函数
  • 参数二:interval 执行间隔时间
  • 参数三:options 选项: { leading: 是否在起始时刻执行, trailing: 是否在末尾时刻执行 }

该版本加入了对this 的显式绑定处理,在返回的节流函数中还支持接收参数,并且使用定时器来控制末尾时刻的执行,还提供了一个取消末尾时刻执行的方法,具体请参考以下代码:

const throttle = (fn, interval, options = { leading: true, trailing: false }) => {
  const { leading, trailing } = options;

  let lastTime = 0; // 上次调用的时间,初始值为0

  let timer = null;

  // 在返回的函数中,接收多个参数
  function _throttle(...arg) {
    const nowTime = new Date().getTime(); // 获取当前时间戳

    // 为了可控第一次是否立即执行,只需要控制lastTime的值即可
    // 使lastTime = nowTime 这样的话 nowTime - lastTime 就等于0了,
    // remainTime就会等于interval,大于零就不会执行函数
    if (!leading && !lastTime) lastTime = nowTime;

    const remainTime = interval - (nowTime - lastTime);

    if (remainTime <= 0) {
      if (timer) {
        clearInterval(timer);
        timer = null;
      }

      // 显示绑定this,将参数传入函数并执行
      fn.apply(this, arg);

      // 将当前时间赋值给上次时间,在下次调用此函数时作为上次时间进行判断
      lastTime = nowTime;
    }

    // 判断末尾时刻是否执行
    if (!timer && remainTime > 0 && trailing) {
      // 如果条件达成则开启一个定时器来处理以下逻辑
      timer = setTimeout(() => {
        // 如果leading为true 则 lastTime 为当前时间戳 下次执行时将在起始时刻执行,否则lastTime = 0,下次执行时起始时刻将不会执行
        lastTime = !leading ? 0 : new Date().getTime();

        // 清空定时器
        timer = null;

        // 显示绑定this并 传入参数
        fn.apply(this, arg);
      }, remainTime);
    }
  }

  // 取消末尾时刻的执行
  _throttle.cancel = () => {
    if (timer) clearTimeout(timer);
  };
  return _throttle;
};

使用一下:

// 获取按钮元素
const btnEl = document.querySelector('.btn');
// 获取取消按钮元素
const cancelBtnEl = document.querySelector('.cancelBtn');

// 定义事件绑定的函数,这里我们就可以拿到点击事件的对象了
const foo = (event) => {
  console.log(event, 'foo 被执行');
};

// 节流处理
const fooThrottle = throttle(foo, 500, {
  leading: true,
  trailing: true
});

// 绑定执行函数
btnEl.onclick = fooThrottle;

// 连续点击按钮,测试节流效果...

// 点击取消按钮
cancelBtnEl.onclick = fooThrottle.cancel();

目前看来,该版本的节流函数已经解决了我们在基础版未满足的需求,貌似我们完全可以拿这个方法直接去使用了,但其实该方法还存在最后一个缺陷,那就是返回值得问题,若需要执行的函数存在返回值,我们如何去拿到这个返回值呢,请看最终版。

三、最终版:

  • 参数一:fn 需要节流的函数
  • 参数二:interval 执行间隔时间
  • 参数三:options 选项: { leading: 是否在起始时刻执行, trailing: 是否在末尾时刻执行 }
  • 参数四:resultCallback 回调函数 用于接收返回值

返回值的获取有两种方法,且这两种并不冲突:

方法一: 接收第 4 个参数(resultCallback)回调函数,在fn执行结束拿到返回值后,将返回值作为回调函数的参数传入,这样我们就可以从回调函数中拿到该函数的返回值了

方法二: 在返回的节流函数中再返回一个Promisefn执行结束后将fn的返回值传入到resolve并执行,这样我们就可以在执行函数时直接通过.then的方式拿到结果了。具体实现如下:

const throttle = (fn, interval, options = { leading: true, trailing: false }, resultCallback) => {
  const { leading, trailing } = options;

  let lastTime = 0; // 上次调用的时间,初始值为0

  let timer = null;

  // 在返回的函数中,接收多个参数
  function _throttle(...arg) {
    // 方式二:返回一个Promise
    return new Promise((resolve) => {
      const nowTime = new Date().getTime(); // 获取当前时间戳

      // 为了可控第一次是否立即执行,只需要控制lastTime的值即可
      // 使lastTime = nowTime 这样的话 nowTime - lastTime 就等于0了,
      // remainTime就会等于interval,大于零就不会执行函数
      if (!leading && !lastTime) lastTime = nowTime;

      const remainTime = interval - (nowTime - lastTime);

      if (remainTime <= 0) {
        if (timer) {
          clearInterval(timer);
          timer = null;
        }

        // 显示绑定this,将参数传入函数并执行,并拿到执行结果
        const result = fn.apply(this, arg);

        // 方式一:执行结束后将结果传入回调函数并执行
        resultCallback && resultCallback(result);

        // 方式二:将结果resolve出去
        resolve(result);

        // 将当前时间赋值给上次时间,在下次调用此函数时作为上次时间进行判断
        lastTime = nowTime;
      }

      // 判断末尾时刻是否执行
      if (!timer && remainTime > 0 && trailing) {
        // 如果条件达成则开启一个定时器来处理以下逻辑
        timer = setTimeout(() => {
          // 如果leading为true 则 lastTime 为当前时间戳 下次执行时将在起始时刻执行,否则lastTime = 0,下次执行时起始时刻将不会执行
          lastTime = !leading ? 0 : new Date().getTime();

          // 清空定时器
          timer = null;

          // 显示绑定this并 传入参数,拿到执行的结果
          const result = fn.apply(this, arg);

          // 方式一:执行结束后将结果传入回调函数并执行
          resultCallback && resultCallback(result);

          // 方式二:将结果resolve出去
          resolve(result);
        }, remainTime);
      }
    });
  }

  // 取消末尾时刻的执行
  _throttle.cancel = () => {
    if (timer) clearTimeout(timer);
  };
  return _throttle;
};

使用一下:

// 获取按钮元素
const btnEl = document.querySelector('.btn');
// 获取取消按钮元素
const cancelBtnEl = document.querySelector('.cancelBtn');

// 定义事件绑定的函数,这里我们就可以拿到点击事件的对象了
const foo = (event) => {
  console.log(event, 'foo 被执行');
  return 'hello throttle';
};

// 节流处理  获取返回值方式一:传入第四个参数
const fooThrottle = throttle(
  foo,
  500,
  {
    leading: true,
    trailing: true
  },
  (result) => {
    console.log(result); // hello throttle
  }
);

// 绑定执行函数
btnEl.onclick = function (e) {
  // 获取返回值方式二
  fooThrottle(e).then((res) => {
    console.log(res); // hello throttle
  });
};

// 连续点击按钮,测试节流效果...

// 点击取消按钮
cancelBtnEl.onclick = fooThrottle.cancel();

至此,我们已经实现了完整的节流函数

实现防抖

一、基础版:

  • 参数一:fn 需要防抖的函数
  • 参数二:delay 延时多久,单位 ms

防抖的封装过程与节流大体相同,此方法我同样还是使用三个版本分步骤的实现,层层递进,帮助大家也是帮助我自己可以更清晰的理解其中的原理,以下是基础版的实现过程:

const debounce = (fn, delay) => {
  let timer = null; // 定时器id初始为null

  // 返回的函数接收参数
  return function (...arg) {
    // 如果timer有值则清除定时器
    timer && clearTimeout(timer);

    // 定义定时器,延时执行fn
    timer = setTimeout(() => {
      // 两种绑定this的方法
      // fn.apply(this, arg)
      fn.call(this, ...arg);
    }, delay);
  };
};

二、进阶版:

  • 参数一:fn 需要防抖的函数
  • 参数二:delay 延时多久,单位 ms
  • 参数三:immed 是否立即执行

在基础版之上又对其进行了扩展,加入了立即执行的选项,以及取消方法。

const debounce = (fn, delay, immed = false) => {
  let timer = null; // 定时器id初始为null
  let isExecute = false; // 记录是否立即执行过

  // 返回的函数接收参数
  function _debounce(...arg) {
    // 如果timer有值则清除定时器
    timer && clearTimeout(timer);

    // 如果开启立即执行,且立即执行还未执行过,则执行fn
    if (immed && !isExecute) {
      fn.call(this, ...arg);
      isExecute = true;
    } else {
      timer = setTimeout(() => {
        // fn.apply(this, arg)
        fn.call(this, ...arg);

        // 重置状态
        isExecute = false;
        timer = null;
      }, delay);
    }
  }

  // 取消方法
  _debounce.cancel = () => {
    timer && clearInterval(timer);
  };
  return _debounce;
};

三、最终版:

  • 参数一:fn 需要防抖的函数
  • 参数二:delay 延时多久,单位 ms
  • 参数三:immed 是否立即执行
  • 参数四:resultCallback 获取返回值的回调函数

最终版加入返回值的获取方法,与节流函数一样使用回调函数和 Promise 的方式。

const debounce = (fn, delay, immed = false, resultCallback) => {
  let timer = null; // 定时器id初始为null
  let isExecute = false; // 记录是否立即执行过

  // 返回的函数接收参数
  function _debounce(...arg) {
    return new Promise((resolve, reject) => {
      // 如果timer有值则清除定时器
      timer && clearTimeout(timer);

      // 如果开启立即执行,且立即执行还未执行过,则执行fn
      if (immed && !isExecute) {
        const result = fn.call(this, ...arg);
        resultCallback && resultCallback(result);
        resolve(result);
        isExecute = true;
      } else {
        timer = setTimeout(() => {
          // fn.apply(this, arg)
          const result = fn.call(this, ...arg);

          // 处理返回值
          resultCallback && resultCallback(result);
          resolve(result);

          // 重置状态
          isExecute = false;
          timer = null;
        }, delay);
      }
    });
  }

  // 取消方法
  _debounce.cancel = () => {
    timer && clearInterval(timer);
  };
  return _debounce;
};

使用一下:

const inputEl = document.querySelector('.test-debounce');
const btnEl = document.querySelector('button');
let count = 0;
const handleInput = function (event) {
  console.log('发送了第' + ++count + '次网络请求', this, event);
  return '返回值测试';
};

const handleInputDebounce = debounce(handleInput, 300, false, (res) => {
  console.log('自己函数的返回值:', res);
});

const resultCallback = () => {
  handleInputDebounce().then((res) => {
    console.log(res);
  });
};

inputEl.oninput = resultCallback;

btnEl.addEventListener('click', () => {
  handleInputDebounce.cancel();
});

至此,我们以及完成了防抖函数的封装。相比较而言,防抖函数的封装过程比节流要稍微简单了一些。

总结

防抖(debounce)节流(throttle) 是两个非常经典而且实用的高阶函数,从代码量上来看并不算多,但麻雀虽小,五脏俱全。作为一个前端开发,还是有必要尝试手写一下的,同时也希望还没有详细了解防抖节流的同学通过本文可以有所收获。