JS高级 - 防抖和节流

209 阅读8分钟

防抖和节流的概念其实最早并不是出现在软件工程中,防抖是出现在电子元件中,节流出现在流体流动中

JavaScript是事件驱动的,大量的操作会触发事件,加入到事件队列中处理

对于某些频繁的事件处理会造成性能的损耗,我们就可以通过防抖和节流来限制事件频繁的发生

防抖 (debounce)

  • 当事件触发时,相应的函数并不会立即触发,而是会等待一定的时间
  • 当事件密集触发时,函数的触发会被频繁的推迟
  • 有等待了一段时间也没有事件触发,才会真正的执行响应函数
  • 如果在等待期间,不停的触发对应的函数,那么对应的事件响应就会被不断的延迟

防抖: 只有在某个时间内,没有再次触发某个函数时,才真正的调用这个函数

也就是说防抖函数主要用来对一些会频繁触发的函数的触发频率进行限制

IM8Qtz.png

使用场景

  1. 输入框中频繁的输入内容,搜索或 者提交信息
  2. 频繁的点击按钮,触发某个事件
  3. 监听浏览器滚动事件,完成某些特 定操作
  4. 用户缩放浏览器的resize事件
  5. 。。。

简单实现

核心功能

const inputEl = document.getElementById('foo')

function debounce(fn, delay = 0) {
  if (typeof fn !== 'function') {
    throw new Error('param1 must be a function')
  }

  let timer = null

  // 返回值 是实际使用的函数,所以对应的参数需要被传入到这个函数中
  // 返回的函数不能为箭头函数,因为内部需要使用正确的this来调用fn
  return function(...args) {
    // 如果timer为null的时候,clearTimeout(timer)依旧可以正常执行
    // 但是推荐加上timer是否存在的判断,以减少clearTimeout方法的执行

    // 如果有新的事件被触发,就清除上一次对应的事件回调
    if (timer) clearTimeout(timer)

    timer = setTimeout(() => {
      fn.apply(this, args)
      // 定时器执行完毕后,即使cleatTimeout了,timer变量也不会重置
      // 所以需要手动进行重置
      timer = null
    }, delay)
  }
}

inputEl.addEventListener('input', debounce(e => console.log(e.target.value), 1000))

边界功能

取消功能

但有的时候,我们可能需要手动去清除对应的回调事件

例如用户触发了对应的回调事件后,在回调事件触发等待时间之前,就进入了SPA的另一个页面,此时原本的回调函数的触发就显得没有意义了

function debounce(fn, delay = 0) {
  if (typeof fn !== 'function') {
    throw new Error('param1 must be a function')
  }

  let timer = null

  const _debounce = function(...args) {
    if (timer) clearTimeout(timer)

    timer = setTimeout(() => {
      fn.apply(this, args)
      timer = null
    }, delay)
  }

  // 在导出的函数上新绑定一个方法
  // 如果调用者调用了这个函数,那么会清除对应的事件回调
  _debounce.cancel = () => {
    if (timer) clearTimeout(timer)
    // 重置状态
    timer = null
  }

  return _debounce
}
立即执行

目前实现的防抖函数在第一次触发回调的时候并不会立即执行而是会进行等待,也就是函数第一次被触发的时候就会进行对应的防抖功能

如果我们希望函数第一次被触发的时候,就立即执行对应的回调函数,从第二次被触发才开始进行防抖,我们可以进行如下改进

function debounce(fn, delay = 0, immediate = false) {
  if (typeof fn !== 'function') {
    throw new Error('param1 must be a function')
  }

  let timer = null
  // 一个函数尽可能只执行一个功能
  // 一个变量尽可能只存储一个状态
  // 所以新建isInvoke来记录是否是第一次执行,而不是使用immediate
  let isInvoke = false

  const _debounce = function(...args) {
    if (timer) clearTimeout(timer)

    // 如果需要立即执行 且是第一次触发对应的事件回调
    if (immediate && !isInvoke) {
      fn.apply(this, args)
      isInvoke = true
    }

    timer = setTimeout(() => {
      fn.apply(this, args)

      // 回调函数被实际触发后,重置对应的状态
      timer = null
      isInvoke = false
    }, delay)
  }

  _debounce.cancel = () => {
    if (timer) clearTimeout(timer)

    // 回调事件被取消后,重置对应的状态
    timer = null
    isInvoke = false
  }

  return _debounce
}
获取返回值

但有的时候,我们可能会需要使用到防抖函数的返回值,此时我们可以对代码进行如下修改:

function debounce(fn, delay = 0, immediate = false) {
  if (typeof fn !== 'function') {
    throw new Error('param1 must be a function')
  }

  let timer = null
  let isInvoke = false

  const _debounce = function(...args) {
    // 因为对应的事件回调会被延迟执行,所以对应的函数就被转换为了异步函数
    // 所以为了拿到对应的结果,需要返回对应的Promise
    return new Promise((resolve, reject) => {
      try {
        if (timer) clearTimeout(timer)

        if (immediate && !isInvoke) {
          // 返回执行结果
          resolve(fn.apply(this, args))
          isInvoke = true
        }

        timer = setTimeout(() => {
          // 返回执行结果
          resolve(fn.apply(this, args))

          timer = null
          isInvoke = false
        }, delay)

      } catch (error) {
        reject(error)
      }
    })
  }

  _debounce.cancel = () => {
    if (timer) clearTimeout(timer)

    timer = null
    isInvoke = false
  }

  return _debounce
}

// Test Code
const foo = debounce(name => {
  return `my name is ${name}`
}, 1000)

foo('klaus').then(res => console.log(res))
foo('klaus').then(res => console.log(res))
foo('klaus').then(res => console.log(res))
/*
  第一个和第二个Promise 因为定时器被移除,所以一直处于pending状态
  只有第三个Promise的状态被确定,所以最终只会输出一句结果
  => my name is klaus
*/

节流 (throttle)

  • 当事件触发时,会执行这个事件的响应函数
  • 如果这个事件会被频繁触发,那么节流函数会按照一定的频率来执行函数
  • 不管在这个中间有多少次触发这个事件,执行函数的频繁总是固定的

节流函数的主要功能是对于频繁触发的事件,控制其响应函数在单位时间内,按照自定义的频率去触发对应的事件回调

不管事件实际被触发多少次,在单位时间内,对应的回调函数被触发的次数是固定的

IM8njt.png

使用场景:

  • 监听页面的滚动事件
  • 鼠标移动事件
  • 用户频繁点击按钮操作

简单实现

核心功能

const inputEl = document.getElementById('foo')

function throttle(fn, intervalTime) {
  // 剩余时间的初始值为0
  // 第一次计算剩余时间时
  // 间隔时间 - (时间戳 - 0) 一定会小于0的
  // 所以这确保了节流函数在事件被触发的时候
  // 会先执行一次,随后按照预设的间隔执行对应的回调函数
  let startTime = 0

  function _throttle(...args) {
    const currentTime = Date.now()

    // 剩余时间 = 间隔时间 - (当前时间 - 起始时间)
    const waitTime = intervalTime - (currentTime - startTime)

    // 如果剩余时间为0的时候,就执行对应的回调函数
    if (waitTime <= 0) {
      fn.apply(this, args)
      // 执行完毕后,更新开始时间,重新计算剩余时间
      startTime = currentTime
    }
  }

  return _throttle
}

inputEl.addEventListener('input', throttle(e => console.log(e.target.value), 1500))

image.png

边界功能

立即执行

首次执行

默认情况下,节流函数在事件回调第一次被触发的时候就会执行对应的回调,随后按照一定的频率去触发对应的回调

有的时候,我们可能希望第一次事件被触发时,对应的回调函数不被执行,可以进行如下修改;

const inputEl = document.getElementById('foo')

// 参数三 是节流函数对应的配置对象
// leadInvoke - 首次执行 是否需要立即执行
// trailInvoke - 尾部执行 是否需要执行
function throttle(fn, intervalTime, { leadInvoke = true, trailInvoke = false } = {}) {
  // 开始时间在这里应该被初始化为0
  // 因为throttle方法返回一个新函数
  // 返回的新函数并不一定会在接收后立即执行
  let startTime = 0
l
  function _throttle(...args) {
    const currentTime = Date.now()

    // 如果首次不需要执行 且 开始执行时间为0时
    if (!leadInvoke && !startTime) {
      startTime = currentTime
    }

    const waitTime = intervalTime - (currentTime - startTime)

    if (waitTime <= 0) {
      fn.apply(this, args)
      startTime = currentTime
    }
  }

  return _throttle
}

inputEl.addEventListener('input', throttle(e => console.log(e.target.value), 1000, {
  leadInvoke: false
}))

尾部执行

默认情况下,假设我们的事件触发间隔设置的是10s, 而我在15s的时候触发事件后,再也不触发任何的事件的时候,对应的时间计算就会停止

也就是说第15s对应的事件并不会被触发,如果我们希望第15s的时候,对应的事件依旧可以被触发,基本思路如下:

image.png

假设时间间隔为10s

在第10s的时候,开启一个延迟时间为10s的定时器

在第20s的时候,如果有触发对应的事件,就移除对应的定时器,并通过事件进行响应

如果在第20s的时候,如果没有触发对应的事件,那么就通过之前所设置的定时器来实现对应的响应

所以对节流函数的修改如下:

const inputEl = document.getElementById('foo')

function throttle(fn, intervalTime, { leadInvoke = true, trailInvoke = false } = {}) {
  let startTime = 0
  let timer = null

  function _throttle(...args) {
    const currentTime = Date.now()

    if (!leadInvoke && !startTime) {
      startTime = currentTime
    }

    const waitTime = intervalTime - (currentTime - startTime)

    if (waitTime <= 0) {
      if (timer) {
        clearTimeout(timer)
      }

      fn.apply(this, args)
      startTime = currentTime
      timer = null
      return
    }

    if (trailInvoke && !timer) {
      timer = setTimeout(() => {
        fn.apply(this, args)
        startTime = Date.now()
        timer = null
      }, waitTime)
    }
  }

  return _throttle
}

inputEl.addEventListener('input', throttle(e => console.log(e.target.value), 1000, {
  trailInvoke: true
}))
取消功能

既然有的时候节流函数可以在尾部单独执行,那么对应的节流函数就存在对应的取消功能

function throttle(fn, intervalTime, { leadInvoke = true, trailInvoke = false } = {}) {
  let startTime = 0
  let timer = null

  function _throttle(...args) {
    const currentTime = Date.now()

    if (!leadInvoke && !startTime) {
      startTime = currentTime
    }

    const waitTime = intervalTime - (currentTime - startTime)

    if (waitTime <= 0) {
      if (timer) {
        clearTimeout(timer)
      }

      fn.apply(this, args)
      startTime = currentTime
      timer = null
      return
    }

    if (trailInvoke && !timer) {
      timer = setTimeout(() => {
        fn.apply(this, args)
        startTime = Date.now()
        timer = null
      }, waitTime)
    }
  }

  _throttle.cancel = () => {
    // 只有trailInvoke为true的时候,才可能出现timer
    if (timer) {
      clearTimeout(timer)
      timer = null
      startTime = 0
    }
  }

  return _throttle
}
返回值

节流函数和防抖函数一样,我们有的时候需要获取对应的返回值

function throttle(fn, intervalTime, { leadInvoke = true, trailInvoke = false } = {}) {
  let startTime = 0
  let timer = null

  function _throttle(...args) {
    return new Promise((resolve, reject) => {
      try {
        const currentTime = Date.now()

        if (!leadInvoke && !startTime) {
          startTime = currentTime
        }

        const waitTime = intervalTime - (currentTime - startTime)

        if (waitTime <= 0) {
          if (timer) {
            clearTimeout(timer)
          }

          resolve(fn.apply(this, args))
          startTime = currentTime
          timer = null
          return
        }

        if (trailInvoke && !timer) {
          timer = setTimeout(() => {
            resolve(fn.apply(this, args))
            startTime = Date.now()
            timer = null
          }, waitTime)
        }
      } catch (e) {
        reject(e)
      }
    })
  }

  _throttle.cancel = () => {
    if (timer) {
      clearTimeout(timer)
      timer = null
      startTime = 0
    }
  }

  return _throttle
}

throttle(() => 'foo', 1000)().then(res => console.log(res))

underscore

事实上我们可以通过一些第三方库来实现防抖操作,如 underscorelodash

lodash是underscore的升级版,它更重量级,功能也更多

但underscore还在维护,lodash已经很久没有更新了, 所以这里使用underscore来进行举例

使用underscore来完成防抖操作

<!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>防抖</title>
</head>
<body>
  <input type="text" id="foo">
  <!-- underscore被引入后,会在全局挂载一个对象 _ -->
  <!-- 所有underscore的方法 都会被挂载到全局对象_上 -->
  <script src="https://cdn.jsdelivr.net/npm/underscore@1.13.4/underscore-umd-min.js"></script>

  <script>
    const inputEl = document.getElementById('foo')

    // _.debounce方法会返回一个经过防抖处理的新函数
    // _.debounce方法参数1: 需要进行防抖处理的函数
    // _.debounce方法参数2: 防抖等待的时间 单位为毫秒
    inputEl.addEventListener('input', _.debounce(e => console.log(e.target.value), 500))
  </script>
</body>
</html>

使用underscore来完成节流操作

<!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>节流</title>
</head>
<body>
  <input type="text" id="foo">
  <script src="https://cdn.jsdelivr.net/npm/underscore@1.13.4/underscore-umd-min.js"></script>

  <script>
    const inputEl = document.getElementById('foo')

    inputEl.addEventListener('input', _.throttle(e => console.log(e.target.value), 2000))
  </script>
</body>
</html>