“狂点提交” ≠ 更快上传!一文带你吃透 JS 防抖

84 阅读7分钟

前言

不知道大家有没有在表单提交页面,输完内容之后,点击提交按钮,因为网络延迟导致一定时间内没有得到响应,这个时候你可能会尝试多次点击,试图通过多次点击来提高提交效率,但是你有没有想过它也许并不能提高你的上传效率,甚至还会大大降低你的提交效率!
这时候你会说:什么?他不但不会加快上传速度,反而会增加延迟时间?不可能绝对不可能,你当我这么多年怎么过来的,那不都是凭手速领先他人一大步嘛!
好别急,我们今天要讲的 防抖 正好能为你解惑,待我缓缓道来。

一、防抖究竟是何方神圣?

首先当我们点击提交表单,电脑会提交数据到后端数据库里面,如果这个时候你因为网络延迟没有第一时间得到响应而反复点击提交,它会带来高并发,从而增加了服务器的压力。明明是同样的指令同样的数据结果,如果单单是因为你点击 n 次那数据就提交 n 次,这会不会显得没那么必要,这要是不同的人同时点击 n 次提交,只要人够多,那系统不得直接崩溃,同一个数据同一个人传一次不就够了嘛你说是不是,没错!程序员们也是这么想的,但到底怎么样才能实现点击 n 次,只接收其中的一次呢?
在这里我们可以举两个生活中的例子:

  1. 我们去餐厅吃饭点餐,有些餐厅会给每一桌客人放一个漏斗,这个漏斗用来进行倒计时,在漏斗漏完之前必须会把点的菜上齐,如果在漏斗漏完之后菜没上齐那么就会进行打折免单等一系列补偿。那么问题来了,如果你在一开始点完第一次菜过了一分钟你突然想加菜,这个时候漏斗还没漏完,当你确认加菜的时候,服务员会给你放一个新的漏斗,意思这最后一个漏斗漏完前把菜上齐。
  2. 外卖员同时接收两个同小区订单,这两个订单几乎同时下单,当外卖员到达店里之后发现只好了一个订单另一个订单还需再等两分钟,这时候两个选择,一是先把做好了的先送过去,然后再回来取第二个再送一次,另一种就是等第二份外卖做好把这两份外卖一起送过去。毫无疑问,是个正常人都会选择第二种。

诶这些例子就能解决上面的问题,电脑:你喜欢点击 n 次是吧,那前面几次我都不执行,你什么时候不再点了,我再帮你执行。这就是 防抖 -- 在规定的时间内,没有新的事件触发,才执行。

二、怎么实现防抖?

话不多说直接上代码:

    let btn = document.getElementById('btn')
    function handle() { 
      console.log('提交');
    }
    btn.addEventListener('click', debounce(handle, 1000))
    function debounce(fn, wait) {  // 鼠标每次点击触发的是我
      var timer;
      return function() { 
        clearTimeout(timer)
        timer = setTimeout(() => {
          fn()
        }, wait)
      }
    }

以上代码是存在在 html 中的 js ,上面的 btn.addEventListener('click', debounce(handle, 1000)) ,它的原型是 xxx.addEventListener('click', function) 意思是鼠标左键(只能用'click')点击 id 为 xxx 的按钮就执行 function 这个函数(指函数的引用),这里的 debounce(handle, 1000) 他返回的也是一个函数,至于 setTimeout(() => {}, wait) 它是一个定时器, wait 里面填倒计时时间, clearTimeout() 则是删除定时器,总代码的运行可以用一个图来表示

lQLPJw0ynt9EsMPNBDjNB4CwZ_MWkGIK6NsJAGGTMmsUAA_1920_1080.png
我们把 timer 声明在 debounce 这个函数里面,在执行 debounce 上下文时,多余的随着执行结束会被销毁,唯独留着 timer ,形成一个 闭包,把它先晾在旁边,此时它还没有一个值(undefined),直到执行第一次倒计时,它被赋值为 setTimeout ,但是如果在 wait 倒计时还没结束,此时你又点击了一次鼠标,debounce 函数又被触发,clearTimeout(timer) 开始发力, timer 又变成 undefined,只要你倒计时结束前一直点,那 fn 就一直不被触发,就一直没有结果,直到你不再点击鼠标,这串代码才能完整的运行下去。这就实现了 防抖 效果。

三、 深度剖析防抖内部结构

1、默认事件

    function handle(e) {   // e 事件参数,默认存在,用来描述这个事件详情
      console.log('提交', e);
    }
btn.addEventListener('click', handle)

但凡是被 addEventListener 调用的函数(handle),它都会给这个函数传一个实参进去,这个实参是事件参数,默认存在,用来描述这个事件详情,在这里我们用形参 e 代表实参,下面是这个实参的原形

5b7bf29b-e785-4b25-8372-e957e3e27696.png

    let btn = document.getElementById('btn')

    function handle(e) {   // e 事件参数,默认存在,用来描述这个事件详情
      console.log('提交', e);
    }
    btn.addEventListener('click', debounce(handle, 1000))
    function debounce(fn, wait) {
      var timer;
      return function(...arg) {  // 鼠标每次点击触发的是我
        clearTimeout(timer)
        timer = setTimeout(() => {
          fn(...arg)
        }, wait)
      }
    }

但是当我们把它做成防抖效果的时候,原本的 handle 函数体变成了 debounce(handle, 1000) ,它输出返回的是匿名函数 function ,但是原本是应该给 handle 传事件参数,这是不能变的,那么就应该通过匿名函数重新传入到 handle 中,但是由于可能还有其他参数,我们就可以用 ...arg 来代替 e ,(其中被函数当做形参使用的 ...变量名 叫做 rest 参数,搭配的变量是一个数组,该变量将多余的参数放入数组中), 由于 fn() 中不能放数组,这时候就该用到解构了, fn(...arg) 这里面的 ...变量名 就是将上面的数组解构赋值,防止报错。通过这一系列操作就可将原本 handle 函数该有的事件参数原原本本地传了回去。

2、this

话不多说依旧放代码:

let btn = document.getElementById('btn')
    function handle(e) {   // e 事件参数,默认存在,用来描述这个事件详情
      console.log('提交', e, this);
    }
    btn.addEventListener('click', handle)

d5c8cea3-275a-463f-8098-95fae27cfad5.png 我们往函数里面放一个 this ,他是存在 handle 函数里面,指向 btn ,但是我们真正的代码是这样的

    let btn = document.getElementById('btn')
    function handle(e) {   // e 事件参数,默认存在,用来描述这个事件详情
      console.log('提交', e, this);
    }
    btn.addEventListener('click', debounce(handle, 1000))
    function debounce(fn, wait) {
      var timer;
      return function(...arg) {  // 鼠标每次点击触发的是我
        clearTimeout(timer)
        timer = setTimeout(() => {
          fn(...arg)
        }, wait)
      }
    }

此时它依旧存在 handle 中,但是函数是被独立调用的那么其指向的却是 window

eca2c7f8-e8ba-4b8a-aa84-813cca62868f.png 为了让它恢复原样,我们直接上连招

   let btn = document.getElementById('btn')
    function handle(e) {   // e 事件参数,默认存在,用来描述这个事件详情
      console.log('提交', e, this);
    }
    btn.addEventListener('click', debounce(handle, 1000))
    function debounce(fn, wait) {
      var timer;
      return function(...arg) {  // 鼠标每次点击触发的是我
        // let _this = this
        clearTimeout(timer)
        timer = setTimeout(() => {
          fn.apply(this, arg)
        }, wait)
      }
    }

我们要让 this 指向原来的 btn (这里我们将它命名为 btn ,实际上只要是指向 addEventListener 前面的 对象 即可),那么就该让 handle 里面的 this 指向匿名函数里面的 this,然后再由匿名函数来让 handle 中的 this 指向 btn。(我们用箭头函数就是利用它不会存放 this 对象,从而将其指向匿名函数里面从而达到想要的效果)

结语

一口气读完,你已经掌握了防抖的“三板斧”:

  1. 核心逻辑:闭包藏 timer,每次新事件进来先把旧 timer 掐掉,只留最后一次倒计时。
  2. 参数复原:用 ...arg + apply 把事件对象 ethis 完璧归赵,让 handle 感觉就像原配监听。
  3. 生活映射:餐厅漏斗、外卖顺路,本质上都是“等你折腾完再开火”,前端只是把现实智慧搬进了代码。