JS 保姆级贴心,从零教你手写实现一个防抖debounce方法

944 阅读11分钟

壹 ❀ 引

防抖在前端开发中算一个基础但很实用的开发技巧,在对于一些高频操作例如监听输入框值变化触发更新之类,会有奇效。除了实际开发,在面试中我们也可能偶遇手写防抖节流的问题,鉴于不同公司考核要求不一,对于实现深度也会不同。本文主要围绕防抖从基础概念到手写实现展开,从基础版逐渐演变为一个相对强大的版本,且文中很多实现细节我都会一一说明,直接让你们少才踩坑,那么本文开始。

贰 ❀ 防抖场景与概念理解

要想手写一个防抖,我们总得先知道它是什么,它有什么使用场景,因此先聊聊防抖的使用场景。在实际开发中,我们常常会遇到这样的场景,比如我们有一个通过监听输入框实时修改名称的需求:

<body>
  <input type="text" class="input">
  <br>
  <span class="name"></span>
</body>
const span = document.querySelector('.name');
const input = document.querySelector('.input');
​
const changeName = function () {
  console.log(this);
  span.innerHTML = input.value;
};
​
input.onkeyup = changeName;

img

如上,只要我们修改input的值,对应span标签内的文本就会被修改,这看起来很正常。

但假设我们此时的修改并不是一次innerHTML的更新,而是要将最新的name同步给后端,那么这里就需要发起一次后端请求。抛开请求耗时不说,假设用户在一瞬间增增删删了十余次,此刻你就得发送十多次请求,抛开性能消耗,万一用户网络不好,你甚至还会遇到因调度问题导致数据展示后发先至的bug,有兴趣可读一读如何做好一个基础的搜索功能?记一个因客户大数据量而导致的后发先至Bug这一文。

回到问题本身,有同学可能马上想到,我们能不能感知input输入动作的停止呢?理想状态下,我们肯定希望用户在犹豫不决一顿操作后输入了最终的内容,并暂时停止了输入,这时候再发起请求;之后他想改内容同理,一样在停止输入后做第二次更新,那么防抖就是为了解决此类场景。

所谓防抖,简单理解,就是为我们需要执行的方法附加一个时间限制,比如3S,只要用户停止输入的间隔时间超过3S,我们就执行此方法。若用户停止输入2S后又输入了,因为时间并不满足3S,我们还是不会执行,而是重置等待时间,依旧等待下一个3S。

我们总结下实际可能的场景:

  • 第一次输入与第二次输入的间隔大于3S,执行方法。
  • 第一次输入与第二次输入间隔小于3S,不执行方法,重置等待时间。
  • 输入完后不再输入了,那也肯定大于3S,执行方法。

可能有同学不理解这个重置等待时间是什么意思,我们可以用生活中电梯自动关门的场景来解释这个问题:

  • 电梯开门,等待3S没有人进来/出去,自动关门。
  • 电梯开门,等待2S后结果有人进来/出去了,重置等待时间,继续等3S,若中途不断有人上下,电梯永远不会关。

你看,看似是解决某个前端需求问题,其实它是生活中很常见的场景。

叁 ❀ 从零手写debounce

叁 ❀ 壹 debounce基础实现

OK,前文我们通过抛出问题解释了防抖的作用,同时解释了防抖究竟做了什么事,那么接下来我们就从零实现一个自己的防抖。

了解定时器的同学都知道,当我们创建一个定时器时,会返回一个当前定时器的ID,且我们能根据此ID清除当前定时器,因此我们可以借用定时器来实现防抖:

// 定义防抖函数
const debounce = function (fn, wait) {
  // 自由变量,debounce执行完成被释放,time也不会被释放
  let time;
  // 返回一个闭包
  return function () {
    // 清除上一次的定时器
    if (time) {
      clearTimeout(time);
    };
    // wait时间后执行
    time = setTimeout(fn, wait);
  }
};
​
// 通过闭包,得到一个方法
const changeName_ = debounce(changeName, 3000);
​
input.onkeyup = changeName_;

img

我们将新的方法绑定在input上再不断输入,你会发现只要你不停就一直不会执行,如果停止输入间隔时间大于wait则会执行。

我们分析下这段实现,debounce接受真正需要执行的方法fn,以及等待时间wait;内部定义了一个自由变量time以及返回一个闭包,后续input值变化时,其实触发的是这个闭包。

因为闭包的缘故,即使debounce执行完毕被释放,time也会一直存在,这就导致不管调用几次闭包,大家其实都共用了一个time变量。

第一次调用闭包,于是创建了一个定时器,且记录在time上,那后续就有两种情况:

  • 3S内又调用了一次,于是定时器来不及执行被清除了,同时重新创建定时器并记录在time中。
  • 3S后调用或者没继续调用,由于满足定时器条件,fn成功执行。

这就是一个最基础的防抖实现,考核的知识其实就是闭包与定时器的创建/清除。

叁 ❀ 贰 满足接收参数

上述实现虽然已经初步达到了效果,但假定changeName接收一些参数,上述实现很明显达不到要求,它不具备处理参数的能力;比如我们知道事件可以通过event获取到当前操作的对象target,我们修改changeName为:

const changeName = function (e) {
  console.log(this)
  span.innerHTML = e.target.value;
};

再执行这时就报错了,毕竟定时器中没地方帮我们接受e,我们再改改防抖实现:

// 定义防抖函数
const debounce = function (fn, wait) {
  // 自由变量,debounce执行完成被释放,time也不会被释放
  let time;
  // 返回一个闭包,接受参数
  return function (...args) {
    // 清除上一次的定时器
    if (time) {
      clearTimeout(time);
    };
    // 不再是直接执行fn,在内部传递参数
    time = setTimeout(function () {
      fn(...args);
    }, wait);
  }
};

仔细想想,因为我们是通过防抖得到了一个闭包,所以input本质上调用的是这个闭包方法,所以参数要接在闭包上,再通过定时器传递给真正的changeName方法,这样就顺利达到效果了。

若对于闭包有疑问,可以阅读博主一篇文章看懂JS闭包,从执行上下文角度解析有趣的闭包一文。

叁 ❀ 叁 修正this指向

在我们没使用防抖直接为input绑定changeName方法时,此时changeName被调用后方法内部的this一定指向input,但因为防抖的介入,此时定时器执行后,你会发现changeNamethis指向window(非严格模式)。

这里我简单解释下this指向问题,之前因为是input直接调用,属于隐式绑定,所以this自然指向input。之后我们添加了防抖,此刻input直接调用的是debounce返回的闭包,而闭包内的定时器又调用了changeName,此时changeName的调用属于默认绑定,自然指向window

所以可以确定闭包内的this其实是changeName真正想要的this,那我们就先将闭包的this存起来,再通过显示绑定的做法来修改changeNamethis指向,再次修改防抖实现:

const debounce = function (fn, wait) {
  // 自由变量,debounce执行完成被释放,time也不会被释放
  let time;
  // 返回一个闭包,接受参数
  return function (...args) {
    // 保存闭包被调用时的this
    const this_ = this;
    // 清除上一次的定时器
    if (time) {
      clearTimeout(time);
    };
    // 不再是直接执行fn,在内部传递参数
    time = setTimeout(function () {
      // 通过apply修改fn的this
      fn.apply(this_, args);
    }, wait);
  }
};

文章到这里,可能有同学是跟着我的思路自己手写结果发现明明跟我一样也绑定了this,怎么changeName还是输出了window,这时候你就要检查下changeName是不是用了箭头函数,因为即便我们手动绑定了this,但箭头函数的this永远指向外层作用域的this,所以还是window。凡是涉及到this操作的函数,保险起见不要使用箭头函数,这会让你少踩很多坑。

第二点,可能有同学会疑惑为什么闭包内部要做this保存的操作?首先,闭包被调用时,此时闭包的this确实指向input没错,但是你要注意fn.apply的执行是被setTimeout的回调包裹,定时器这种回调的执行默认就是指向window,这就导致fn.applythis也是window,所以你必须得手动将闭包的this存起来赋予给fn,这就是为什么要保存this的缘故。

而很多手写debounce的文章都没解释这一点,或者都没有保存this的行为,其实这些都是有问题的。

另外,关于this的隐式绑定,默认绑定这些若有疑问,可以阅读博主五种绑定彻底弄懂this,默认绑定、隐式绑定、显式绑定、new绑定、箭头函数绑定详解一文。

叁 ❀ 肆 增加立即执行机制

现在的debounce实现其实已满足一个防抖所具备的基本能力,但现在不管我们是第一次输入,还是一共就只输入了一次,我们都得等待wait后才能得到反馈。我们现在加个需求,我们希望每次输入的第一次都能立刻得到反馈,之后的输入需要等待wait后才会再次触发。

比如我输入1111,第一个1是立刻执行反馈,后续的111并不会反馈,而是需要等待wait后我再输入才会再次触发。

在实现这个需求前,我先问大家一个问题,定时器都会返回一个定时器ID,我们可以根据这个ID清除定时器后,那么这个ID是nullundefined吗?

const time = setTimeout(console.log('echo'), 0);
clearTimeout(time);
console.log(`定时器ID:${time}`); // ?

事实上同作用域下只要定时器ID产生,即便我们清除定时器,ID依旧会保留且下次调用会继续递增。

let time = setTimeout(console.log('echo'), 0);
clearTimeout(time);
console.log(`定时器ID:${time}`); // 定时器ID:1
time = setTimeout(console.log('听风'), 0);
console.log(`定时器ID:${time}`); // 定时器ID:2

而在debounce中我们声明了一个自由变量time,它一开始确实是未定义,而之后触发了定时器后才有ID,那我们完全可以根据这个time是否是空值来判断是否是第一次执行。

现在还需要考虑第二个问题,time从未定义到有值这个过程只有一次,后续我再111的输入,第一次还是得等,所以我还需要在某个实际将time置为空值,什么时机呢?当然同样是每隔wait时间段置空一次,我们实现来实现这个需求:

// 定义防抖函数
const debounce = function (fn, wait, immediate) {
  // 自由变量,debounce执行完成被释放,time也不会被释放
  let time;
  // 返回一个闭包,接受参数
  return function (...args) {
    // 保存闭包被调用时的this
    const this_ = this;
    // 清除上一次的定时器
    if (time) {
      clearTimeout(time);
    };
    // 配置开关
    if (immediate) {
      const action = !time;
      // time没置空前因为time存在,所以fn不会执行
      time = setTimeout(function () {
        fn.apply(this_, args);
        // 每隔wait时间将time置为空
        time = null;
      }, wait);
      if (action) {
        fn.apply(this_, args);
      };
    } else {
      // 不再是直接执行fn,在内部传递参数
      time = setTimeout(function () {
        // 通过apply修改fn的this
        fn.apply(this_, args);
      }, wait);
    }
  }
};
const changeName_ = debounce(changeName, 2000, true);

再来看看现在的效果,只有第一次会及时响应,后续的操作除非等待wait后再操作才会触发:

img

有同学可能就说了,道理我是懂了,但是我觉得这个体验不太好啊,我每次输入只响应第一次输入,后续输入的内容如果wait之后我不继续输入,你直接不给我反馈了,我觉得不是很OK的样子。

简单,我们修改immediate中的定时器为:

time = setTimeout(function () {
  // 每隔wait时间将time置为空
  time = null;
  fn.apply(this_, args);
}, wait);

img

此时再来看效果,是否符合你的预期了呢?每次输入的第一次操作立刻响应,之后输入都是在停止输入后的wait才响应,如果你一直输入,那我们就只响应第一次,之后你不停就一直不响应。具体怎么实现,还是要看当下是什么需求。

肆 ❀ 总

到这里,我们从一个最基本的防抖已经实现到能接收参数、正确指向this,以及控制是否立即执行,它已经能满足我们日常大部分场景。在分析了大家可能遇到的所有场景问题,我想大家对于防抖的理解应该也不会有太大问题,本质上防抖考验了对于闭包、定时器的组合使用,若大家对于文章还有疑问也欢迎留言提问,那么到这里本文结束。