防抖函数知多少

为什么要做函数防抖?
作为一个前端开发同学,在开发的过程中肯定有要做过搜索功能的吧?

就比如类似百度这种的


对于这样的一个搜索功能来说,最重要的一个点,应该就是触发请求的时机了吧?

你们的交互同学一般会怎么设计这个功能?

不说你们的交互同学了,来说说我们的吧。

用户输入之后点击搜索按钮的时候,调用一次搜索请求
为了增加用户体验,用户在输入的时候也要调用搜索的请求(这里我说一句用户的懒堕是因为交互同学的设计造成的不过分吧?)
到我们开发的时候会怎么设计这个功能呢?

第一点,点击搜索按钮调用搜索接口,这个没毛病,也就该这么搞,没啥可说的。
第二点,也就是上边的第二个问题,在输入的时候,我们什么时候去调搜索接口呢?
我们都知道,input 输入框,有两个事件可以供我们使用,第一个是 input 事件,第二个是 change 事件

但是这里 change 事件不能用啊!为什么呢?你想想哈,change 事件是怎么触发的?这里我来讲一下。

当 input 输入框捕获到焦点后,系统会储存当前值
当 input 失去焦点的时候,也会记录一个值,并且去判断当前值与之前存储的值是否一致,如果一致,则触发 change 事件
MDN上边有这么一段代码,作为解释,大家可以看一下

  control.onfocus = focus;
  control.onblur = blur;
  function focus () {
    original_value = control.value;
  }

  function blur () {
    if (control.value != original_value) {
      control.onchange();
    }
  }
所以, change事件的触发,是要以失去焦点作为前置条件的。如果说用户输入之后,并没有做其他任何操作,那这个时候是没法触发 change 事件的,也就不会调用卸载 change 事件中的搜索接口。

也许你会想着说,敲回车啊,敲回车所有不是一个很正常的操作嘛?

其实我也是这么想的,毕竟作为一个开发同学,敲回车也是我们一个习惯性的动作。但是交互同学不这么想啊,她们想的是,如果用户不敲回车呢?那这个功能也得用不是(对,你说的都对)。

而恰恰 input 事件是每输入一个字符都会去请求,好像满足我们的需求。

这么说来,我们只能在 input 事件执行的时候去调用喽。下边让我们来写一写

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>debounce</title>
  </head>
  <body>
    <input id="inp" type="text" />
    <script>
      const inp = document.getElementById('inp')
      inp.addEventListener('input', ({ target }) => {
        // 数据,拿来吧你
        fetch.get('/api/xxxxxx')
      })
    </script>
  </body>
</html>

得,这次数据是拿回来了,真拿回来了,接口啥的也都能正常发送请求,返回的数据也没问题。

但是呢,成也萧何,败也萧何。每输入一个字符都要去发送一次请求。但是我们真正想要的其实只有最后一次请求返回的数据,其他请求返回的数据对我们来说根本就是无效的。

这样其实造成了很大的请求资源的浪费。

那如何解决这个问题呢?

今天要讲的主角终于在前边一堆铺垫之下姗姗来迟……

没错,函数防抖就是专门用来处理这件事滴……

如何实现函数防抖呢?
在实现函数防抖之前,我们先来思考一下这个问题:

「我们要解决的问题是什么?」

就拿上边的例子来说,在短时间内,相同的操作,在输入每次都要执行,而对于我们来说,我们只想要最后一次的结果。






所以,我们现在要做的就是想办法,阻断在一定时间段内的前n次执行。

那如果我们操作的这个时长超过了我们规定的这个时间段呢?对于输入框的例子来说,就是我们输入内容需要 5s,但是我们规定的时间段是 1s,那是不是请求就要发送5次呢?

其实不是,对于我们来说,前边的几次请求依然是没有必要的浪费,所以,如果输入的时间超过了我们规定的时间,那就取消前边的执行,只要最后一次。

接下来,我们最关心的地方来了,如何实现函数防抖呢?

话不多说,开干……

/*
 * 函数防抖
 * @param { Function } handleFn  处理函数
 * @param { Number } wait 延迟执行时间
 */
function myDebounce(handleFn, wait) {
  if (typeof handleFn !== 'function') {
    throw new Error('handleFn must be an function')
  }
  // 默认时长给300ms
  if (typeof wait === 'undefined') {
    wait = 300
  }
  return function proxy(...args) {
    let self = this
    setTimeout(() => {
      handleFn.call(self, ...args)
    }, wait)
  }
}
一顿操作猛如虎,我写了这个函数。

前边一连串的判断,是为了防止对该函数的误使用,当使用不当的时候,会给出一些提示,或者直接给出默认值。

这里将防抖函数做成一个高阶函数,返回一个代理函数,对我们真正需要我操作做代理,也就是参数 handleFn。

这么写完了我们来看看效果吧,总得看看我们写的成果不是(手动狗头……)

function handleInput(e) {
  console.log(e.target.value)
}
inp.addEventListener('input', myDebounce(handleInput, 500, false))

what! 为啥打印出来个这玩意儿?

开动智慧的小脑瓜,开始思考……

这里我们实质上只是给 input 的执行函数套了一个定时器,这里 input 事件被触发了4次,输入的事件没有超出指定的时间,所以在输入的时候没有执行时间处理函数。

因为 setTimeout 是一个异步任务,等延迟结束,要执行的时候,input 输入框中的值已经变成了 asdf 所以,打印出来的都是相同过的结果。(这里有点牵扯Event Loop的内容,以后再讲)

总之,这里我们并没有做到取消前边函数的执行,只是把所有的函数的执行时间给往后延了,到最后还是会执行。

再想想办法,想想办法……

其实也简单,因为每次防抖函数执行的时候,都会创一个新的定时器,我们把这个定时器记录下来,然后对没有用的定时器进行清楚就OK了。

于是就有了下边这一版。

function myDebounce(handleFn, wait) {
  if (typeof handleFn !== 'function') {
    throw new Error('handleFn must be an function')
  }
  // 默认时长给300ms
  if (typeof wait === 'undefined') {
    wait = 300
  }
  let timer = null
  return function proxy(...args) {
    let self = this
    clearTimeout(timer)
    timer = setTimeout(() => {
      handleFn.call(self, ...args)
    }, wait)
  }
}
创建一个变量 timer 记录当前的定时器,在代理函数执行的时候,先把上一次的定时器清除掉,这样我们保留的始终都是最新的一个定时器。

再看看效果


从控制台可以看出,这次确实是只执行了一次,所以成了吗?成了吗?

如果只是想要实现当前的需求,那做到这一步,已经可以了,但是我们是只会应付需求的那种人吗?不是!我们不是!我们要做的更好!

现在我们接到的需求是执行最后一次,那万一突然有一天新接到的需求是要执行第一次呢?

给我们的函数再添加一个参数,去控制事件处理函数的执行时机。

于是就有了下边的最终版

/*
 * 函数防抖
 * @param { Function } handleFn  处理函数
 * @param { Number } wait 延迟执行时间
 * @param { Boolean } immediate 判断是执行处理函数第一次还是最后一次,false为最后一次
 */
function myDebounce(handleFn, wait, immediate) {
  if (typeof handleFn !== 'function') {
    throw new Error('handleFn must be an function')
  }
  // 默认时长给300ms
  if (typeof wait === 'undefined') {
    wait = 300
  }
  // 如果wait的类型是布尔,说明wait没有传值,wait的位置穿的是immediate
  // 此时应该把wait的值还给immediate,并且给wait赋一个初始值
  if (typeof wait === 'boolean') {
    immediate = wait
    wait = 300
  }
  // 如果immediate的值不是布尔,说明immediate没有传值,或是传值类型不对
  // 这里手动给它一个初始值false,代表执行最后一次操作
  if (typeof immediate !== 'boolean') {
    immediate = false
  }
  let timer = null
  return function proxy(...args) {
    let self = this
    let immediateHandle = immediate && !timer
    clearTimeout(timer)
    timer = setTimeout(() => {
      timer = null
      !immediate ? handleFn.call(self, ...args) : null
    }, wait)
    immediateHandle ? handleFn.call(self, ...args) : null
  }
}
老规矩,写完了看看效果


只能说一句perfect!

完结,撒花……

你学废了吗?

参考链接:

https://developer.mozilla.org/zh-CN/docs/Web/API/GlobalEventHandlers/onchange

作者:向阳


欢迎关注微信公众号 :大话前端