带你“深入”防抖

3,027 阅读5分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

说白话:

抖是什么?它啊,就像大炮,投一个炸弹,装一个炸弹。那个函数啊,触发一次就执行一次。

那么,防抖又是什么?就像机关枪,突突突,不管打多少次,打完子弹仓里都要重新装子弹。高频触发函数,时间间隔会重新计算。当在最后一次触发函数时(最后一个子弹打完),时间到达执行一次。

B0003763AC281C21E791E523E80881C2.png

说人话:

事件响应函数在一段规定时间(前/后)才执行。如果在规定时间内,再次触发,重新计算时间。

初模样:

<div class="box"></div>
<button id="btn">取消防抖</button>
<script>
  let obox = document.querySelector('.box')
  let count = 0
  obox.innerHTML = count
  obox.onmousemove = function () {
    obox.innerHTML = count++
    console.log(count);
  }
</script>

1.gif 当鼠标移动n次,就会触发n次。

整改模样:

// <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script> 
// 或
<script src="https://cdn.jsdelivr.net/npm/underscore@1.13.1/underscore-umd-min.js"></script>
<script>
let obox = document.querySelector('.box')
let count = 0
obox.innerHTML = count
function todo(e) {
  obox.innerHTML = ++count
  console.log(e);
}
obox.onmousemove = _.debounce(todo, 1000)
</script> 

直接使用lodash.js或者underscore.js中的防抖函数,就可以做到1s内,鼠标疯狂移动只触发一次。

2.gif

造个模样

对于我们而言,光知其然,是远远不够的;我们更要知其所以然!
二话不说,咱们就来凭空捏造一个把!

就underscore而言,先剖析这个debounced(防抖动)函数。它有三个参数:防抖动的函数fun、需要延迟的毫秒数wait、是否立即执行immediate。

第一版

先照葫芦画瓢,把形参先整好。最先在鼠标移动时,它接收的是一个函数,所以需要返回一个函数;其次,需要等待规定时间内执行,需要一个定时器。

function debounce(fn, wait = 200, immediate = false) {
  let timer = null
  return function () {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      fn()
    }, wait)
  }
}

可以使用setTimeout定时器,将功能函数在一定时间内执行一次。这样最基础的防抖函数就🆗拉!

第二版

我们不光需要考虑功能函数,还需要考虑到在执行函数功能时,fn函数中可能使用event事件、内部this指向问题。此外第一版只完成了后执行,我们还需要完成立即执行的功能。

let obox = document.querySelector('.box')
let count = 0
obox.innerHTML = count
function todo(e) {
  obox.innerHTML = ++count
  console.log(this, e);
}
obox.onmousemove = _.debounce(todo, 1000,true)
// <div class="box">1</div>
// MouseEvent{isTruted: true, screenX: 87, screenY: 388, clientX: 68, clientY: 295,...}

在使用我们第一版的this指向的是window,并且e为undefined。
在自定义debounce函数中,我们发现返回的函数this指向div,这时我们就需要在fn函数执行时,改变this指向。

考虑参数传递问题,在返回函数中接收参数,在函数执行时传入参数即可。

function debounce(fn, wait = 200, immediate = false) {
  let timer = null
  return function (...args) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, wait)
  }
}

此外,我们还需要考虑是否立即实行,及第三个参数。

如果传入的参数immediate为true,那么就执行fn函数;如果为false的话,那就需要在一定时间之后执行(使用setTimeout)。

使用immediate来判断是否立即执行:当立即执行时,此时必须没有定时器,执行函数。等待2s,将定时器清空,等待执行下一次。

function debounce(fn, wait = 200, immediate = false) {
  let timer = null, result
  return function (...args) {
    if (timer) clearTimeout(timer)
    if (immediate) {// 立即执行
      (!timer) && fn.apply(this, args)  // 一开始就执行,无定时
      timer = setTimeout(() => {
        timer = null
      }, wait)
    } else {// 后执行
      timer = setTimeout(() => {
        fn.apply(this, args)
      }, wait)
    }
  }
}

此外还可以通过变量存储,记录执行顺序。

function debounce(fn, wait = 200, immediate = false) {
  let timer = null
  let isEnd = true // 默认后执行
  return function (...args) {
    if (timer) clearTimeout(timer)
    if (immediate) { // 先执行
      isEnd && fn.apply(this, args)
      isEnd = false
    }
    timer = setTimeout(() => {
      (!immediate) && fn.apply(this, args) // 后执行
      isEnd = true
    }, wait)
  }
}

第三版

在第二版的基础上我们可以添加函数返回值和取消抖动的方法。
添加函数返回值,可以记录执行函数的值,不管是立即执行还是后执行,最后统一返回这个值。

function debounce(fn, wait = 200, immediate = false) {
  let timer = null, isEnd = true, result
  let debounced = function (...args) {
    if (timer) clearTimeout(timer)
    if (immediate) {
      isEnd && (result = fn.apply(this, args))
      isEnd = false
    }
    timer = setTimeout(() => {
      (!immediate) && (result = fn.apply(this, args))
      isEnd = true
    }, wait)
    return result
  }
  return debounced
}

使用result记录返回值,最后返回即可。上述代码做了一点点小改动,将整个返回函数使用变量记录,将该变量返回。这样方便于接下来,给函数添加取消抖动的方法。

function debounce(fn, wait = 200, immediate = false) {
  let timer = null, isEnd = true, result
  let debounced = function (...args) {
    if (timer) clearTimeout(timer)
    if (immediate) {
      isEnd && (result = fn.apply(this, args))
      isEnd = false
    }
    timer = setTimeout(() => {
      (!immediate) && (result = fn.apply(this, args))
      isEnd = true
    }, wait)
    return result
  }
  debounced.cancel = function () {
    if (timer) clearTimeout(timer)
    timer = null
  }
  return debounced
}

在cancel方法中,直接清除抖动的定时器,并将该变量回收。

函数返回值异步问题

很感谢读者提的建议,我使用underscore后发现,确实接收的返回值存在异步问题。

let obox = document.querySelector('.box')
let obtn = document.querySelector('#btn')
let count = 0
function todo(e) {
  obox.innerHTML = ++count
  console.log(this, e);
  return count
}
let debounceFn = _.debounce(todo, 1000, false)
obox.onmousemove = (e) => {
  let value = debounceFn(e)
  console.log(value);
}

当我第一次进入div时,执行一次todo函数,此时返回值count应该为1,但是实际输出为undefined。第二次进入的时候,输出为1,但是页面的count为2。返回值返回的是上一个返回值。 image.png 为解决异步问题,我们可以使用promise来解决。

function debounce(fn, wait, immediate) {
  let timer = null, result
  let debounced = function (...args) {
    return new Promise(res => {
      if (timer) clearInterval(timer)
      if (immediate) {// 立即执行
        if (!timer) {
          result = fn.apply(this, args)
          res(result)
        }
        timer = setTimeout(() => {
          timer = null
        }, wait);
      } else {
        timer = setTimeout(() => {
          result = fn.apply(this, args)
          res(result)
        }, wait);
      }
    })
  }
  debounced.cancel = function () {
    if (timer) clearTimeout(timer)
    timer = null
  }
  return debounced
}
let obox = document.querySelector('.box')
let obtn = document.querySelector('#btn')
let count = 0
function todo(e) {
  obox.innerHTML = ++count
  console.log(this, e);
  return count
}
let debounceFn = debounce(todo, 1000, false)
obox.onmousemove = async (e) => {
  try {
    let value = await debounceFn(e)
    console.log(value);
  } catch (e) {
    console.log(e);
  }
}

使用promise解决返回值异步问题,在调用时,使用async/await,将其同步。进入div,调用一次,输出值为1,调用两次,输出值为2,返回值同步。

image.png

有什么用

防抖最常见的应用莫过于解决频繁访问接口的问题了。
总结一下常见的应用:

  • 防止表单多次提交

  • 搜索框输入查询(监听输入框输入内容,设定每隔一段时间访问接口)

  • scroll滚动触发

  • 浏览器窗口缩放时,resize事件

回顾

防抖函数中牵扯到apply改变this绑定、闭包等知识点。我们可以对一下文章做个回顾: