带你一步步用Javascript简单实现和优化防抖

848 阅读10分钟

1、什么是防抖

防抖(debounce):每次触发定时器后,取消上一个定时器,然后重新触发定时器。防抖一般用于用户未知行为的优化,比如搜索框输入弹窗提示,因为用户接下来要输入的内容都是未知的,所以每次用户输入就弹窗是没有意义的,需要等到用户输入完毕后再进行弹窗提示。

2、使用场景

防抖在连续的事件,只需触发一次回调的场景有:

1.搜索框搜索输入。只需用户最后一次输入完,再发送请求

2.手机号、邮箱验证输入检测

3.窗口大小resize。只需窗口调整完成后,计算窗口大小。防止重复渲染。

3、手写防抖函数

1.初级版本

在正式实现之前我们要想两个问题

  • 防抖函数需要接收什么参数 ?
  • 函数的返回值是什么 ?

接受参数

  • 参数一:回调的函数
  • 参数二:延迟函数delay
function mydebounce(fn, delay){  
  
}

函数返回值

最终我们返回结果是要绑定对应的监听事件的, 所以返回值一定是一个新的函数.

function mydebounce(fun, delay) {  
const _debounce = () => {  
  
}  
return _debounce  
}

内部具体实现

可以在_debounce 函数中开启一个定时器,定时器的延迟时间就是 参数delay 每次开启定时器时,都需要将上一次的定时器取消掉

    function myDebounce(fn,delay) {
      // 1.用于记录上一次事件触发的timer
      let timer = null
      
      // 2.触发事件时执行的函数
      const _debounce = () => {
      // 1.用于记录上一次事件触发的timer

        if(timer) clearTimeout(timer)

        // 2.2.延迟去执行对应的fn函数(传入的回调函数)
        timer = setTimeout(()=>{
          fn()
          // 执行过函数之后, 将timer重新置null
          timer = null
        },delay)
      }

      return _debounce;
    }

代码详解

if(timer) clearTimeout(timer)

当再次触发了对应的事件(比如输入框又输入了新字符,触发了输入事件,而这个输入事件关联了这个防抖逻辑)时,如果之前已经设置过定时器(timer 有值),那就通过 clearTimeout 方法把之前设置的定时器取消掉。这就保证了在延迟时间内只要有新的触发,就重新计时,不会执行上一次还没到时间要执行的回调函数。

timer = setTimeout(()=>{
          fn()
          // 执行过函数之后, 将timer重新置null
          timer = null
        },delay)

这里通过 setTimeout 来设置一个新的定时器,在经过 delay 所指定的延迟时间后,会执行传入的匿名函数。

  • 在这个匿名函数内部,首先会执行 fn(),也就是去调用那个真正的业务回调函数,完成对应的操作,比如发起网络请求获取数据、进行页面重绘等具体动作。
  • 然后将 timer 置为 null,这一步很关键,因为只有把 timer 置空了,下次再次触发事件时,if (timer) 这个判断条件才能正确识别出之前的定时器已经执行完毕(或者压根还没设置新的定时器),避免出现错误地取消定时器等情况。例如,第一次触发后经过 delay 时间执行了 fn 并将 timer 置 null,下一次触发时就可以正常走重新设置定时器的流程了。

测试一下

    const inputEl = document.querySelector("input")
    // 3.自己实现的防抖
    let counter = 1
    inputEl.oninput = hydebounce(function() {
      console.log(`发送网络请求${counter++}`,this.value,this)
    }, 2000)

image.png

缺陷

  • 可以看到此时的this指向的是Window,而window没有value值,所以无法知道事件的调用者,我们需要让this指向事件的调用者
  • 这段代码中无法传递任何额外的参数,例如event

this优化和参数绑定

    function myDebounce(fn,delay) {
      // 1.用于记录上一次事件触发的timer
      let timer = null
      
      // 2.触发事件时执行的函数
      const _debounce = (...args) => {
      // 1.用于记录上一次事件触发的timer

        if(timer) clearTimeout(timer)

        // 2.2.延迟去执行对应的fn函数(传入的回调函数)
        timer = setTimeout(()=>{
          fn.apply(this,args)
          // 执行过函数之后, 将timer重新置null
          timer = null
        },delay)
      }

      return _debounce;
    }

代码解释

  • 使用了剩余参数语法(...args),意味着它可以接收任意数量的参数。
  • 过 apply 方法调用 fn 函数,并将当前的 this 上下文以及接收到的参数 args 传递给 fn。这就保证了 fn 函数在正确的 this 环境下执行,并且能接收到外部传入的相应参数,比如在某个对象方法上应用防抖逻辑时,this 指向该对象,参数也能正确传入 fn 函数进行业务处理。

注意

function myDebounce(fn,delay)

这里千万不要写箭头函数,因为箭头函数里面没有this,影响我们后续的优化

测试

 // 1.获取input元素
    const inputEl = document.querySelector("input")

    // 3.自己实现的防抖
    let counter = 1
    inputEl.oninput = hydebounce(function(event) {
      console.log(`发送网络请求${counter++}:`, this, event)
    }, 1000)

image.png

优化增加取消操作

为什么要增加取消操作呢?

例如,用户在表单输入的过程中,返回了上一层页面或关闭了页面,就意味着这次延迟的网络请求没必要继续发生了,所以可以增加一个可取消发送请求的功能

    function myDebounce(fn,delay) {
      // 1.用于记录上一次事件触发的timer
      let timer = null
      
      // 2.触发事件时执行的函数
      const _debounce = (...args) => {
      // 1.用于记录上一次事件触发的timer

        if(timer) clearTimeout(timer)

        // 2.2.延迟去执行对应的fn函数(传入的回调函数)
        timer = setTimeout(()=>{
          fn.apply(this,args)
          // 执行过函数之后, 将timer重新置null
          timer = null
        },delay)
      }

      // 取消操作
      _debounce.cancel = () => {
        if(timer) clearTimeout(timer)
        timer = null
      }

      return _debounce;
    }

代码解释

// 取消操作 _debounce.cancel = () => { if (timer) clearTimeout(timer) timer = null }
  • 首先会检查 timer 是否有值,如果 timer 不为 null,说明存在还未执行的定时器,那么就通过 clearTimeout 方法将其取消掉,避免后续执行对应的 fn 函数。
  • 然后将 timer 置为 null,将定时器状态重置,使整个防抖机制回到初始未设置定时器的状态,方便后续可能的再次触发事件时能正常重新设置定时器并执行防抖逻辑。

测试

image.png

优化增加立即执行

有些场景需要第一次输入时,立即执行,后续的输入再使用防抖

 function myDebounce(fn,delay, immediate = false) {
      // 1.用于记录上一次事件触发的timer
      let timer = null
      let isInvoke = null
      // 2.触发事件时执行的函数
      const _debounce = (...args) => {
      // 1.用于记录上一次事件触发的timer

        if(timer) clearTimeout(timer)

        if(immediate && !isInvoke) {
          fn.apply(this,args)
          isInvoke = true
        } else {
          // 2.2.延迟去执行对应的fn函数(传入的回调函数)
          timer = setTimeout(()=>{
            fn.apply(this,args)
            // 执行过函数之后, 将timer重新置null
            timer = null
          },delay)
        }
      }

      // 取消操作
      _debounce.cancel = () => {
        if(timer) clearTimeout(timer)
        timer = null
        isInvoke = false
      }

      // 取消操作
      _debounce.cancel = () => {
        if(timer) clearTimeout(timer)
        timer = null
      }

      return _debounce;
    }

代码解释

  • immediate:是一个布尔类型的可选参数,默认值为 false。当它被设置为 true 时,表示在首次触发事件时会立即执行 fn 函数;而设置为 false 时,则按照常规的防抖逻辑,等待延迟时间过后(期间没有新触发)才执行 fn 函数。
  • isInvoke:初始化为 null,这个变量主要用于配合 immediate 参数来记录函数 fn 是否已经被执行过,特别是在 immediate 为 true 的情况下,用于确保 fn 只会在首次触发且符合条件时执行一次,避免多次执行造成的逻辑错误。

测试

// 1.获取input元素
    const inputEl = document.querySelector("input")
    const cancelBtn = document.querySelector(".cancel")


    let counter = 1
    const debounceFn = hydebounce(function(event) {
      console.log(`发送网络请求${counter++}:`, this, event)
    }, 2000,true)
    inputEl.oninput = debounceFn

    // 4.实现取消的功能
    cancelBtn.onclick = function() {
      debounceFn.cancel()
    }

image.png

不使用isInvoke

function myDebounce(fn, delay, immediate = false) { let timer = null; const _debounce = (...args) => { if (timer) clearTimeout(timer); if (immediate) { fn.apply(this, args); } else { timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); } }; _debounce.cancel = () => { if (timer) clearTimeout(timer); timer = null; }; return _debounce; }
  1. 首次触发且 immediate 为 true 时
    当事件首次触发并且 immediate 参数被设置为 true,函数 fn 会被立即执行,这一步本身和原始逻辑是相符的。

  2. 后续触发情况(在延迟时间内)
    在首次触发并执行了 fn 之后,如果在设置的延迟时间 delay 范围内又再次触发了事件,因为没有 isInvoke 变量来记录 fn 是否已经执行过,所以只要 immediate 依然为 true,每次触发都会再次执行 fn 函数。这与期望的防抖逻辑不符,正常情况下,我们希望在 immediate 为 true 时,fn 仅在首次触发时执行一次,后续触发事件只要还在延迟时间内,就应该等待延迟时间结束(或者等下次符合条件的触发)后再考虑执行 fn

例如,在一个输入框输入联想的场景中,如果使用了这个修改后的 myDebounce 函数且设置了 immediate 为 true,本意可能是想让用户开始输入时立即发送一次联想请求(执行 fn),但如果用户快速连续输入字符(在延迟时间内多次触发),那么就会导致联想请求被频繁发送(fn 多次执行),而不是按照防抖逻辑那样只在首次触发时立即执行一次请求,后续等待合适时机再执行,这会给服务器造成不必要的压力,也可能导致显示的联想结果混乱等问题。

优化获取返回结果

1.使用回调函数
function hydebounce(fn, delay, immediate = false, resultCallback) {
      // 1.用于记录上一次事件触发的timer
      let timer = null
      let isInvoke = false

      // 2.触发事件时执行的函数
      const _debounce = function(...args) {
  
            // 2.1.如果有再次触发(更多次触发)事件, 那么取消上一次的事件
            if (timer) clearTimeout(timer)

            // 第一次操作是不需要延迟
            let res = undefined
            if (immediate && !isInvoke) {
              res = fn.apply(this, args)
              if (resultCallback) resultCallback(res)
              isInvoke = true
              return
            }

            // 2.2.延迟去执行对应的fn函数(传入的回调函数)
            timer = setTimeout(() => {
              res = fn.apply(this, args)
              if (resultCallback) resultCallback(res)
              timer = null // 执行过函数之后, 将timer重新置null
              isInvoke = false
            }, delay);
      }

      // 3.给_debounce绑定一个取消的函数
      _debounce.cancel = function() {
        if (timer) clearTimeout(timer)
        timer = null
        isInvoke = false
      }

      // 返回一个新的函数
      return _debounce
    }

代码解释

if (resultCallback) resultCallback(res)
    resolve(res)
  • 检查 resultCallback 是否有值,如果有则调用 resultCallback(res),将 fn 的执行结果传递给外部传入的回调函数进行后续处理,比如展示结果、进行额外的数据加工等操作。
  • 然后通过 resolve(res) 将 fn 的执行结果作为 Promise 的成功结果返回出去,使得外部可以通过 Promise 的相关方法获取和处理这个结果,与立即执行部分的结果返回机制相呼应,保证了无论哪种执行方式,外部都能统一地获取到 fn 的执行结果。

测试

    const myDebounceFn = hydebounce(function(name, age, height) {
      console.log("----------", name, age, height)
      return "coderwhy 哈哈哈哈"
    }, 1000, false, function(res) {
      console.log("回调函数被调用了:", res)
    })
    

    myDebounceFn("why", 18, 1.88)  

这种方法有一个缺陷,就是会陷入回调地狱,所以接下来我们使用Promise优化

Promise优化
 function hydebounce(fn, delay, immediate = false, resultCallback) {
      // 1.用于记录上一次事件触发的timer
      let timer = null
      let isInvoke = false

      // 2.触发事件时执行的函数
      const _debounce = function(...args) {
        return new Promise((resolve, reject) => {
          try {
            // 2.1.如果有再次触发(更多次触发)事件, 那么取消上一次的事件
            if (timer) clearTimeout(timer)

            // 第一次操作是不需要延迟
            let res = undefined
            if (immediate && !isInvoke) {
              res = fn.apply(this, args)
              if (resultCallback) resultCallback(res)
              resolve(res)
              isInvoke = true
              return
            }

            // 2.2.延迟去执行对应的fn函数(传入的回调函数)
            timer = setTimeout(() => {
              res = fn.apply(this, args)
              if (resultCallback) resultCallback(res)
              resolve(res)
              timer = null // 执行过函数之后, 将timer重新置null
              isInvoke = false
            }, delay);
          } catch (error) {
            reject(error)
          }
        })
      }

      // 3.给_debounce绑定一个取消的函数
      _debounce.cancel = function() {
        if (timer) clearTimeout(timer)
        timer = null
        isInvoke = false
      }

      // 返回一个新的函数
      return _debounce
    }

代码解释

return new Promise((resolve, reject)

它返回一个新创建的 Promise 对象,通过 Promise 的 resolve 和 reject 机制来处理函数 fn 的执行结果以及可能出现的错误情况,外部调用者可以使用 thencatch 等 Promise 相关方法来与这个函数进行交互。

resolve(res)

resolve(res) 将 fn 的执行结果作为 Promise 的成功结果返回出去,这样外部代码使用 then 方法就能获取到这个结果并进行相应处理了。例如,在一个输入框联想搜索的场景中,fn 可能是获取联想数据的函数,这里返回的结果就是联想数据,外部可以通过 then 拿到数据后展示在页面上。 测试

// 2.手动绑定函数和执行
    const myDebounceFn = hydebounce(function(name, age, height) {
      console.log("----------", name, age, height)
      return "coderwhy 哈哈哈哈"
    }, 1000, false)

    myDebounceFn("why", 18, 1.88).then(res => {
      console.log("拿到执行结果:", res)
    })

image.png