壹 ❀ 引

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

贰 ❀ 防抖场景与概念理解

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

<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;

如上,只要我们修改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_;

我们将新的方法绑定在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方法,这样就顺利达到效果了。

叁 ❀ 叁 修正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后再操作才会触发:

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

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

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

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

肆 ❀ 总

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

JS 保姆级贴心,从零教你手写实现一个防抖debounce方法的更多相关文章

  1. 保姆级教程!手把手教你使用Longhorn管理云原生分布式SQL数据库!

    作者简介 Jimmy Guerrero,在开发者关系团队和开源社区拥有20多年的经验.他目前领导YugabyteDB的社区和市场团队. 本文来自Rancher Labs Longhorn是Kubern ...

  2. 【保姆级教程】手把手教你进行Go语言环境安装及相关VSCode配置

    [Go语言入门系列]前面的文章: [Go语言入门系列](七)如何使用Go的方法? [Go语言入门系列](八)Go语言是不是面向对象语言? [Go语言入门系列](九)写这些就是为了搞懂怎么用接口 本篇文 ...

  3. JS中的数组,添加删除元素,判断是否存在一个值的方法总结

    一.添加元素: 1:在最后添加,返回数组长度:arr.push(...); 2:在最前面添加,返回数组长度:arr.unshift(...); 3:在指定位置添加,没有返回值:arr[i] = xxx ...

  4. 手把手教你手写一个最简单的 Spring Boot Starter

    欢迎关注微信公众号:「Java之言」技术文章持续更新,请持续关注...... 第一时间学习最新技术文章 领取最新技术学习资料视频 最新互联网资讯和面试经验 何为 Starter ? 想必大家都使用过 ...

  5. 【保姆级】利用Github搭建自己的个人博客,看完就会

    大家好,我是辰哥~ 作为一名喜欢技术的爱好者,平时喜欢把自己学习技术的心得或者一些踩坑.易错的过程记录下来,首选的是技术平台(博客),今天辰哥来教大家如何利用Github来搭建一个自己的个人博客平台. ...

  6. 自建本地服务器,自建Web服务器——保姆级教程!

    搭建本地服务器,Web服务器--保姆级教程! 本文首发于https://blog.chens.life/How-to-build-your-own-server.html. 先上图!大致思路就是如此. ...

  7. 重磅:保姆级Java技术图谱发布!够学到元宵节了,赶紧收藏!

    最近因为参与社群交流的时间比较多,除了唠唠白酒的嗑之外,很大一部分时间都是看到群里问到一些关于Spring Boot和Spring Cloud应用过程中碰到的问题以及一些开发过程中的报错信息.在这些帮 ...

  8. 强大博客搭建全过程(1)-hexo博客搭建保姆级教程

    1. 前言 本人本来使用国内的开源项目solo搭建了博客,但感觉1核CPU2G内存的服务器,还是稍微有点重,包括服务器内还搭建了数据库.如果自己开发然后搭建,耗费时间又比较多,于是乎开始寻找轻量型的博 ...

  9. [奶奶看了都会]ChatGPT保姆级注册教程

    大家好,我是小卷 最近几天OpenAI发布的ChatGPT聊天机器人火出天际了,连着上了各个平台的热搜榜.这个聊天机器人最大的特点是模仿人类说话风格同时回答大量问题. 有人说ChatGPT是真正的人工 ...

  10. ChatGPT保姆级注册教程

    ChatGPT保姆级注册教程 最近几天OpenAI发布的ChatGPT聊天机器人火出天际了,连着上了各个平台的热搜榜.这个聊天机器人最大的特点是模仿人类说话风格同时回答大量问题. 有人说ChatGPT ...

随机推荐

  1. cs 保研经验贴 | 英语口试

    很多夏令营都有英语面试环节.但这其实是有迹可循的,多说几遍就熟练了. 无论是笔试面试,还是联系导师 联系 hr,这种自我推销的事情,都会越做越熟练的.希望发表也是如此吧-(来自博零菜鸟的碎碎念-) 目 ...

  2. 深入理解java线程池 一

    本文为博主原创,未经允许不得转载: 在多线程和高并发场景中,需要创建大量的线程来进行业务处理,我们通常创建线程有两种方法,一种是通过继承Thread类,另一种是实现Runnable的接口,但是我们创建 ...

  3. Linux telnet安装及端口测试联通性

    安装步骤: 可使用该文中的步骤进行安装,已经过本人验证,是可以安装成功的: https://blog.csdn.net/doubleqinyan/article/details/80492421 安装 ...

  4. spring--JDK动态代理的实现原理

    JDK 动态代理的实现原理涉及到 Java 的反射机制.它允许在运行时动态创建一个代理类,这个代理类实现了一组接口,并将所有方法调用转发到一个 InvocationHandler 实例.下面是 JDK ...

  5. asp.net core之Kestrel

    简介 在ASP.NET Core中,Kestrel是一个重要的组件,它是一个跨平台的.开源的Web服务器,专门为ASP.NET Core应用程序而设计.Kestrel以其轻量级和高性能而闻名,本文将介 ...

  6. Memory Bist

    SRAMC主要是对SRAM进行控制 对于SRAM的逻辑,根据地址将数据存储到SRAM中,然后根据地址将SRAM中的数据读取出来 如何测试Memory,生产工艺原因造成的问题,采用DFT或者Bist测试 ...

  7. [转帖]How fast are Unix domain sockets?

    https://blog.myhro.info/2017/01/how-fast-are-unix-domain-sockets Jan 3, 2017 • Tiago Ilieve Warning: ...

  8. [转帖]Influxdb 2.x 快速入门

    Influxdb 2.x 快速入门 https://www.jianshu.com/p/268fca65f10e Influxdb是由Golang 构建的时序数据库,由于由Go语言构建使得其跨平台部署 ...

  9. [转帖]Python-Mock接口测试

    https://www.cnblogs.com/zhangwuxuan/p/12928850.html 前言 今天跟小伙伴们一起来学习一下如何编写Python脚本进行mock测试. 什么是mock? ...

  10. [转帖]从DDR到DDR4,内存核心频率基本上就没太大的进步!

    https://zhuanlan.zhihu.com/p/84194049 从2001年DDR内存面世以来发展到2019年的今天,已经走过了DDR.DDR2.DDR3.DDR4四个大的规格时代了(DD ...