函数防抖与节流

467 阅读6分钟

前言

首先,防抖和节流是函数的高级应用。它们本质的作用都是优化页面性能,避免在用户高频率的操作下消耗过多的内存性能。它们在真实项目业务中的使用场景非常广泛,比如搜索框的搜索建议、页面滚动监听,窗口变化监听、AJAX数据请求、子菜单的显示等等......,总之它非常重要。那么,接下来我会分析什么是防抖和节流,然后一步一步由浅到深的去实现防抖和节流。

防抖(什么是防抖?)

防抖就是事件在一段时间后触发(为了方便理解,这里假设一段时间就是一秒)。如果,一秒之内再次触发事件,那么就重新计算时间。当在一秒内没有触发事件,那么一秒后将执行事件函数。

也就是说如果在一秒内多次触发事件那么事件函数将永远不会执行,必须停下来等一秒后才会触发事件函数。

如果还不能理解没关系,先看下面的例子,在回来看概念可能就懂了。

提一个需求

是这样的,页面中有一个大盒子,盒子中间有个数字。要求当鼠标在盒子上移动的时候数字自动递增,但是如果频繁地移动鼠标时,数字不会自动增加。移动鼠标后必须等待一秒数字才递增。

普通版实现

const oDiv = document.querySelector('#container')
let t = null
oDiv.addEventListener('mousemove', function () {
    clearTimeout(t)
    t = setTimeout(() => {
        this.innerText = parseInt(this.innerText) + 1
    }, 1000);
})

效果演示:

实现的思路就是有一个全局的定时器,每次触发事件就清除这个定时器,然后又重新赋值一个定时器(相当于上文中提到的重新计算时间)。这样如果每次触发的间隔时间没有超过一秒那么真正的逻辑代码就不会被执行。就是这么个简单的道理。

可以从上面的演示中看到效果完全符合要求,但是这样也存在问题:

  • 全局变量污染,真实的业务中用到防抖的地方可能不止一个,难道都要在全局定义计时器吗?
  • 代码无法复用,如果防抖多的话,会有非常多的冗余代码
  • 不够灵活,无法取消防抖,也无法根据需求改变防抖行为 为了解决以上的问题,我们要将防抖封装成一个函数,为了能够访问到计时器,那么肯定要返回一个闭包函数。

封装防抖函数

初步实现版
function debounce (fn, wait, immediate) {
  // 定时器
  let t = null
  // 返回的防抖函数,闭包
  let debounce = function () {
      // 收集参数
      const args = arguments
      // 清除定时器
      if (t) {
          clearTimeout(t)
      }
      // 立即执行
      if (immediate) {
          // 定时器被设置为null才执行
          let exec = !t
          t = setTimeout(() => {
              // wait秒后将定时器设置为null
              t = null
          }, wait);
          // 执行函数
          if (exec) {
              fn.apply(this, args)
          }
      } else {
          // 不是立即执行,等待wait秒后执行
          t = setTimeout(() => {
              fn.apply(this, args)
          }, wait);
      }
  }
  // 将函数返回
  return debounce
}

以上代码就简单实现了一个防抖函数,第一个参数是事件处理函数,第二个参数是间隔时间,第三个参数表示是否立即执行。核心的逻辑就是利用闭包缓存定时器,这样每次调用函数都能访问到同一个定时器,防抖的本质就是延迟执行,只要搞明白什么时候延迟执行,什么时候应该重新计算时间,就很简单了。

函数返回值和取消防抖

上面封装的防抖函数无法处理函数的返回值问题和取消防抖,下面代码将弥补这两个问题,其实也非常的简单

function debounce (fn, wait, immediate) {
  // 定时器
  let t = null
  // 返回的防抖函数,闭包
  let debounce = function () {
      // 收集参数
      const args = arguments
      // 保存函数返回值的结果
      let result = null
      // 清除定时器
      if (t) {
          clearTimeout(t)
      }
      // 立即执行
      if (immediate) {
          // 定时器被设置为null才执行
          let exec = !t
          t = setTimeout(() => {
              // wait秒后将定时器设置为null
              t = null
          }, wait);
          // 执行函数
          if (exec) {
              result = fn.apply(this, args)
          }
      } else {
          // 不是立即执行,等待wait秒后执行
          t = setTimeout(() => {
              result = fn.apply(this, args)
          }, wait);
      }
      // 将结果返回
      return result
  }
  debounce.cancel = function () {
  // 清除本次定时器,并赋值为null,这样无论是否立即执行函数还是延迟执行函数都可以取消
      clearTimeout(t)
      t = null
  }
  // 将函数返回
  return debounce
}

节流(什么是节流)

节流就是一段时间内不管触发多少次事件,对应的事件函数只执行一次

改变需求

还是接着上文中用到的例子,但是要求,当不停的滑动的时候,每隔一秒数字递增一次

时间戳版节流

const oDiv = document.querySelector('#container')
oDiv.addEventListener('mousemove', throttle(function () {
    this.innerText = parseInt(this.innerText) + 1
}, 1000))
function throttle (fn, wait) {
	// 函数返回值,函数调用时的时间戳
    let result, begin = new Date().getTime()
    return function () {
    // 当前时间戳
        let now = new Date().getTime()
        // 当前时间戳和函数上一次调用函数时的时间戳之间的间隔大于等待的时间才执行函数
        if (now - begin > wait) {
            result = fn.apply(this, arguments)
            begin = now
        }
        return result
    }
} 

定时器版节流

function throttle (fn, wait) {
    let result, timeout = null
    return function () {
        if (!timeout) {
            timeout = setTimeout(() => {
                timeout = null
                result = fn.apply(this, arguments)
            }, wait);
        }
        return result
    }
}

小结:上面两段代码都可以实现节流,不知道细心的小伙伴时候发现了两种方法的不同之处。第一种当首次触发事件时,数字会立即递增,说明函数立即执行了,最后停止触发事件时,函数也就停止执行了。而第二种首次触发事件,函数却延迟了1秒执行,最后停止触发事件,函数还会执行一次。

那能不能有一种方法让函数首次立即执行,最后离开时,函数再执行一次,把这两者的优点结合一下呢?答案是:可以的。

完整版节流函数

完整版节流函数接收第三个参数options对象,options.leadingtrue表示首次立即执行,options.trailing为true表示停止触发事件时,函数再执行一遍,值为false则反之。

function throttle (fn, wait, options) {
    let result,
        timeout = null,
        begin = 0
    return function () {
    // 当前时间
        let now = new Date().getTime()
        // 为false首次不执行
        if (options.leading === false) {
        // 缩小时间差这样就不满足下列条件
            begin = now
        }
        // 时间差超过间隔时间,执行函数
        if (now - begin > wait) {
            if (timeout) {
                clearTimeout(timeout)
                timeout = null
            }
            fn.apply(this, arguments)
            begin = now
        }
        // 没有定时器并且需要执行最后一次
        if (!timeout && options.trailing !== false) {
            timeout = setTimeout(() => {
                begin = new Date().getTime()
                timeout = null
                fn.apply(this, arguments)
            }, wait);
        }
    }
  }
  
  const oDiv = document.querySelector('#container')
  oDiv.addEventListener('mousemove', throttle(function () {
      this.innerText = parseInt(this.innerText) + 1
  }, 1000, {
      leading: true,
      trailing: true
  }))

这样我们就可以通过配置对象决定我们要怎样去执行函数。