手把手教你实现一个防抖函数(debounce)

3,706 阅读10分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情

手把手教你实现一个防抖函数

前言:防抖函数在日常开发中属于是一个非常非常重要的知识点。通常在一个项目的最开始构建的时候,都会在 utils文件夹下备上这样一个函数,来为以后做准备。 (tipsutils 在大部分翻译软件内好像都叫跑龙套的,这个翻译不是那么合理。这个单词在这个场景下更像存放工具类的函数的文件夹。通常我们会放一些比如格式化时间,格式化文件大小格式,节流之类的函数。)

这篇文章原意是想紧随在姊妹篇文章节流函数的原理之后发布的。但是那时候自己对闭包、高阶函数的概念不是特别清楚,害怕误导读者,故拖了比较久的才发布这个重要知识点。

注:本文不会讲解防抖的高级写法,只会一步一步带你理清思路,如何拓展功能还需各位看官举一反三。


一. 什么是防抖?使用场景是什么?

  1. 首先我们要知道,这里的防抖具体指的是什么?我们假设一个场景,这里就拿我们日常最常用的功能,《搜索〉来举例子。 image.png

  2. 我们用 v-model 指令绑定这个 <input> 框。然后绑定一个根据用户输入的关键词,去后端数据库检索数据的模拟函数。(这里我们用 console 代替)。 image.png image.png 然后我们用 watch 去监视 searchKeyword 的变化,每当用户输入关键词后,我们就向后端发起一次请求。 image.png

  3. 我们可以非常明显的看到,在这种情况下。我仅仅只是想最后搜索 hanzhenfang 这几个关键词,但是我在输入每一个字符的时候,都会去后端请求一次,数据量小还好,如果数据量过大的话,由于前几次的请求都是毫无意义的,势必会造成性能和资源上的浪费。
    搜索.gif

4.什么?你说为什么不等最后点击搜索按钮的时候再去搜索? emm... 这个确实是可以。但是突然有一天,产品经理说:“这个搜索框如果有联想功能的话就更好了!我们要赶超百度,赛过谷歌!”你怎么办嘞?目前的情况到不是不行🤔,就是有可能挨后端的一顿毒打(bushi)“...服务器为什么老莫名挂”
联想.gif

  1. ok,现在压力来到了前端这边。接口该调还是得调,但是我希望他在我输入完 hanzhenfang 的时候,然后检测我没有继续往下输入了,再去调后端的接口,然后我再把返回的联想词联系给它展示在这里是不是就可以了呢?

二.理清思路

  1. 让我们转化一下思路,只是单纯的这样说你可能不太理解。我们换一个更为简单的场景。 image.png image.png 现在页面只有一个简单的按钮,通过点击这个按钮,我们会向后端发起请求。(这种场景我知道有很多别的限制方法🚫,比如在某个时间段内把按钮的 disabled 属性改为 true 等等,我们暂时不讨论这种解决方案。) image.png

  2. 现在我们尝试疯狂点击按钮就会疯狂发送请求。 按钮.gif

  3. 我们现在来修改一下这个函数,我们思考一下🤔,假设我们不借助 debounce 可以实现一个伪防抖的功能吗?答案是百分百可以的。我们先在这个文件下设定一个数字类型的变量叫做 timerID。稍后我会告诉你为什么是数字类型的。(tips:其实也不是特别需要限制类型 null 这些的也可以)
    image.png

  4. 然后我们设定一个定时器,来使这个 console.log("发请求")1.5s 后执行。 image.png

  5. 我担心个别读者对《 setTimeout 是有返回值的》这件事不是特别了解。我来穿插讲解一下你可能不知道的知识。 image.png image.png
    其实 setTimeout 会在 setTimeout 执行的时候返回一个大于 0 的正整数。 所以我们这句话其实是在给 timerID 赋值! 并不是将 setTimeout 函数本身赋值给 timerID 这个变量。 image.png

  6. ⚠️注意: 全文重点是下面这句话: image.png
    这里我们需要特别搞清楚 setTimout函数本身执行的时候,是马上赋值的,并不是等到 1.5s 后再赋值的。我希望你多读几句这句话,一定要理解这个概念!
    什么意思呢? 我设置了大约在10几年后再执行的一个函数,千万不要觉得 timerID 是会在10年以后才会被赋值。 什么?你不信?来给你演示一下。 image.png
    timer.gif

  7. 为什么要这样设计呢?因为如果这样执行的话,就会给我们一个反悔的机会。还说上面的例子。假设我在 5 年后突然反悔不想执行了。我只需要取消这个 timerID 就可以中途放弃执行。

  8. 我们编写一个 cancleSearch 函数,这个函数非常简单。就是一个调用了 clearTimout 这个取消定时器的方法,并且我们把定时器的延时设定为3s。
    image.png
    演示一下:
    取消.gif 可以清楚的看到,我的前两次请求已经被我成功阻止了。第三次由于我没点击取消,从而正确的在 3s后帮我执行了 getSearch 函数。

  9. 聪明的你可能已经想到了,这个 timerID 就是每一个 setTimeout 的身份证。每当你执行一次 setTimout 后,setTimout 所接收的回调函数就会被分配一个唯一 ID,来被放进任务队列。注意!!!一旦任务顺利从任务队列被推进主线程执行后,这个唯一 id 其实作用也就没什么特别大的意义了。 WechatIMG24733.jpeg

  10. clearTimeout 的功能恰好就是清除位于任务队列里指定的 id 所绑定的那个回调函数。

三. 实现一个简单的自我防抖函数

  1. 由上面的前备知识,我们就可以实现一个非常简单的自我防抖函数。接下来我梳理一下思路。 image.png

  2. 当我们每次执行 getSearch 之前,如果当前任务队列里有上一次同样的任务,我们就先清除掉。
    image.png

  3. 然后再去开启一个定时器任务推进任务队列。
    image.png
    至此我们就做到了该函数本身一个简陋的防抖。测试一下,在此之前我们设定一个计算我们点击了多少次按钮的变量,该函数仅仅是为了计数而已。 image.png 我们测试一下:
    点击了基础.gif

四. debounce 函数的实现

  1. 我们只有一个函数需要防抖的话其实这样看着还行,但是现在有10个,100函数呢?我难道一个个这样写吗?nonono,程序员都是很懒滴~是不可能写重复的低质量代码的。所以聪明的你可能会想到会写一个生产 自我防抖 函数的函数。没错,引出我们今天的主角 debounce

  2. 好的,铺垫了这么久,也该敲敲代码了。
    我们先在 utils 文件夹下创建 debounce.ts 的文件来放我们的防抖函数。顾名思义。 image.png image.png

  3. 既然是包装函数,那么你得给它一个东西,它才能帮你包装吧。那么这个函数应该接受一个参数,这个参数应该也是一个函数。(后期我们需要把上面我们写到的请求后端的函数, toSearch 函数给放进去) image.png

  4. 然后在 debounce 函数定义一个局部变量 timeID 来存放我们后面定时器返回的身份证idimage.png

  5. ⚠️注意:接下来是本文的第二个重点。这里我们需要用到高阶函数。让我们先看看高阶函数的定义是什么。 image.png 不要怕,它并不是像数学高等数学的差距那样!如果你是第一次听到这个名词,你可以这样理解:就像你送别人礼物,你为了好看,你会把这个物品给用精美的包装给包一层。那么我们的 防抖 函数在这里的作用其实就是帮你把这个函数包装一下的意思,它并不会直接影响这个函数的内部逻辑,就像你的礼物包装一层包装纸🎁后,它本身是没有发生任何变化的。

  6. 所以在这里我们应该返回一个函数来存放我们真正的业务代码。(为了方便写成了匿名函数,你也可以先在函数内部使用 function关键词声明一个带名字的函数 最后返回,效果是一样的) image.png

  7. 然后直接把我们上一步实现的自我防抖函数内部的逻辑复制过来。 image.png

  8. 就是这么简单~
    image.png
    哦,稍等,别忘了把 setTimeout里的 console.log('发请求') 替换成我们的参数 fn
    image.png

  9. 接下来去 app.vue 里引入这个函数。 image.png

五. 闭包和 debounce 的关系

  1. 等等,别着急。我大概能能猜到你会这样使用。 image.png

  2. 然后抱着这个毫无反应的页面怀疑人生。 bibao.gif

  3. 在这里我需要额外说明一下,这种写法在 react 中可以正确执行的。 image.png 主要原因有兴趣的读者可以自行去搜索一下,还是很有意思的~
    陈.gif

  4. 回到 Vue,还记得我们最开始的写法吗? image.png 我们是在这个组件内定义了一个《相当于这个组件的“全局变量”》。那么当我在这个页面有多个需要防抖的函数的话,就会造成这样的场面。 image.png
    极度不优雅和难以维护。

  5. 那怎么办呢?这里我们就需要用到闭包函数。 闭包不另开一篇文章讲解是讲不完的,并且阮一峰阮大的闭包讲解的已经很好了,我就不献丑了)
    对于现在的场景简单来说,你可以这样理解。闭包相当于在自身的范围内,通过在函数内部引用自己的 变量timerID 来达成变量 timeIDdebounce 函数执行后并不会被马上销毁的目的。 image.png

  6. 那么我们正确的写法就是,用一个变量来接收 debounce 返回的那个函数。并且此时此刻,已经同步生成了一个暂时不会被销毁的 timeID 来保存我们 setTimeout 生产的那个 id身份证。(这里可能不是特别好理解,需要读者自行去了解闭包的机制)
    image.png
    测试一下:
    jieshu.gif

总结:

如果读者能够细心品文本篇文章的细节,你可能会自然而然的理解节流的原理是什么。节流相关知识我之前也是通俗易懂的用游戏技能冷却🎮带你去理解原理是什么。有兴趣的读者可以自行查阅~

源码

更新于2023/2/24

增加了 this 指向,防抖函数可以传递参数


/**
 *
 * @param fn 要被防抖的函数
 * @param delay  防抖的延迟时间
 * @param immediately 第一次的时候是否立即执行,默认为 true
 */

export function debounce(
  fn: Function,
  delay: number = 1000,
  immediately: boolean = true
) {
  let timerID: number = -1;
  return function (this: any, ...arg: any) {
    if (timerID < 0 && immediately) {
      fn.apply(this, arg);
      timerID = 1;
      return;
    }
    if (timerID > 0) {
      clearTimeout(timerID);
    }
    timerID = window.setTimeout(() => {
      console.log("arg", arg);
      fn.apply(this, arg);
    }, delay);
  };
}

特别感谢:

@林水溶君
@Baoyuan0808

最后感谢两位大佬对我写本文提供的思路和技术指导。🎁