JS Advance --- 防抖和节流

171 阅读6分钟

这是我参与11月更文挑战的第18天,活动详情查看:2021最后一次更文挑战

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

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

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

防抖(debounce)

  • 当事件触发时,相应的函数并不会立即触发,而是会等待一定的时间(也就是进行事件的延迟触发
  • 当事件密集触发时,函数的触发会被频繁的推迟
  • 只有等待了一段时间也没有事件触发,才会真正的执行响应函数

IM8Qtz.png

使用场景

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

节流(throttle)

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

IM8njt.png

使用场景

  • 监听页面的滚动事件
  • 鼠标移动事件
  • 用户频繁点击按钮操作
  • 游戏中的一些设计(如在一段时间内,用户无论点击了多少次空格,都只会发射一个子弹)

underscore

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

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

但是目前underscore还在维护,lodash已经很久没有更新了

防抖

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

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

    let count = 0

    const inputHandler = () => {
      console.log(`第${++count}次请求被触发`)
    }

    // 防抖
    // 下划线 --- underscore提供的全局对象
    // 参数1:需要进行防抖的函数
    // 参数2:函数需要被推迟的时间
    // 返回值: 一个实现了防抖功能的新函数
    inputEl.oninput = _.debounce(inputHandler, 500)
  </script>
</body>
</html>

节流

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

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

    let count = 0

    const inputHandler = () => {
      console.log(`第${++count}次请求被触发`)
    }

    // 节流
    // 下划线 --- underscore提供的全局对象
    // 参数1:需要进行节流的函数
    // 参数2:函数被触发的间隔时间
    // 返回值: 一个实现了节流功能的新函数
    inputEl.oninput = _.throttle(inputHandler, 500)
  </script>
</body>
</html>

自定义防抖和节流函数

防抖

// 防抖函数定义
function debounce(fn, delay) {
  // 闭包所使用的自由变量
  let timer = null

  return function() {
    clearTimeout(timer)
    
    timer = setTimeout(() => {
      fn()
      
      // 初始化自由变量
      timer = null
    }, delay)
  }
}

我们在使用函数的时候,可能需要传递对应的参数或者在函数内部使用this关键字

但是使用上面的方式实现防抖功能的时候,是无法获取到准确的this和参数的

所以对其进行如下的调整

function debounce(fn, delay) {
  // 闭包所使用的自由变量
  let timer = null

  // 返回的函数是实际调用的函数
  // 所以该函数可以获取到正确的this以及参数
  return function(...args) {
    clearTimeout(timer)
    
    timer = setTimeout(() => { 
    	fn.apply(this, args)
      
      timer = null
   	}, delay)
  }
}

有的时候,我们希望在每一个阶段的第一次操作的时候,并不进行防抖操作(便于先向用户展示对应的效果),而对于每一个阶段中的其它执行周期都进行防抖操作

function debounce(fn, delay, isimmediate=false) {
  let timer = null
  // 虽然可以直接使用isimmediate完成对应功能
  // 但是isimmediate其实是一个参数,不可以随便修改 --- 为了是一个纯函数,不产生副作用
  // 所以新开了变量isinvoke用来辅助判断
  let isinvoke = false

  return function(...args) {
    clearTimeout(timer)

    if (isimmediate && !isinvoke) {
      fn.apply(this, args)
      isinvoke = true
    } else {
      timer = setTimeout(() => {
        fn.apply(this, args)
        isinvoke = false
        timer = null
      }, delay)
    }
  }
}

在实际使用中,我们可能遇到如下场景,用户在进行操作后,防抖函数执行前,用户执行了取消操作或者用户离开了当前页面

而防抖函数中存放的是一些比较耗时的异步操作,此时这些异步操作的函数其实是没有执行的必要的

所以需要为自定义的防抖函数添加取消功能

// 防抖函数
function debounce(fn, delay, isimmediate=false) {
  let timer = null
  let isinvoke = false

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

    if (isimmediate && !isinvoke) {
      fn.apply(this, args)
      isinvoke = true
    } else {
      timer = setTimeout(() => {
        fn.apply(this, args)
        isinvoke = false
        timer = null
      }, delay)
    }
  }

  _debounce.cancel = function() {
    clearTimeout(timer)
    isinvoke = false
    timer = null
  }

  return _debounce
}
<!-- 使用 -->
<!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>Document</title>
  <script src="./index.js"></script>
</head>
<body>
  <input type="text" id="input" />
  <button id="btn">cancel</button>

  <script>
    const inputEl = document.getElementById('input')
    const btnEl = document.getElementById('btn')

    let count = 0

    const inputHandler = function(e) {
      console.log(`第${++count}次请求被触发`)
    }

    const debounceHandler = debounce(inputHandler, 2000)
    inputEl.oninput = debounceHandler

    btnEl.onclick = function() {
      debounceHandler.cancel()
    }
  </script>
</body>
</html>

虽然我们很少直接去获取防抖函数的返回值,但是不排除存在这样的可能性,所以我们需要将对于函数的返回值返回

方法1 --- 回调函数

function debounce(fn, delay, isimmediate=false, callback) {
  let timer = null
  let isinvoke = false

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

    if (isimmediate && !isinvoke) {
      const res = fn.apply(this, args)
      callback && callback(res)
      isinvoke = true
    } else {
      timer = setTimeout(() => {
        const res = fn.apply(this, args)
        callback && callback(res)
        isinvoke = false
        timer = null
      }, delay)
    }
  }

  _debounce.cancel = function() {
    clearTimeout(timer)
    isinvoke = false
    timer = null
  }

  return _debounce
}

方法2 --- Promise

function debounce(fn, delay, isimmediate=false) {
    let timer = null
    let isinvoke = false

    const _debounce =function(...args) {
      return new Promise((resolve, reject)=> {
        clearTimeout(timer)

        if (isimmediate && !isinvoke) {
          try {
            const res = fn.apply(this, args)
            resolve(res)
            isinvoke = true
          } catch(e) {
            console.error(e.message)
          }
        } else {
          timer = setTimeout(() => {
            try {
              const res = fn.apply(this, args)
              resolve(res)
              isinvoke = false
              timer = null
            } catch(e) {
              console.error(e.message)
            }
          }, delay)
        }
      })
    }

    _debounce.cancel = function() {
      clearTimeout(timer)
      isinvoke = false
      timer = null
    }

    return _debounce
}

节流

节流函数的本质就是限制在一定的时间间隔之间触发对应的事件

所以基本思路是获取上一次响应函数的触发时间(lastTime)和当前触发事件的触发时间(currentTime)

如果currentTime - lastTime的值大于或等于delay那么就需要触发对应的响应函数

function throttle(fn, interval) {
  let lastTime = 0

  return function() {
    const currentTime = Date.now()

    if (currentTime - lastTime > interval) {
      fn()
      lastTime = currentTime
    }
  }
}

但是此时的节流函数在执行的时候,会发现第一次刚刚触发的时候,对应的函数就会执行,这是因为默认currentTime是第一次执行的时间戳,lastTime是0,所以currentTime - lastTime一定是一个大于0的数值,所以第一次节流函数的回调一定会被触发

function throttle(fn, interval, options = { leading: true }) {
  const {
    leading // heading 用于控制第一次输入的时候,函数是否会被触发
  } = options // options --- 传入的配置对象

  let lastTime = 0

  return function() {
    const currentTime = Date.now()

      // 第一次执行且heading的值为false的时候
      if (!lastTime && !leading) {
        // 此时确保delay的值是0
        // 以保证下次throttle函数被触发的时候,正好是delay毫秒后
        lastTime = currentTime
      }

    if (currentTime - lastTime > interval) {
      fn()
      lastTime = currentTime
    }
  }
}

此时存在一个问题,即如果间隔为10s,此时我在10.3s执行了一次事件,按照之前的逻辑函数,到20s的时候,对应的事件并不会被触发,如果此时我需要到20s的时候,在10s到20s之间执行的时间会被触发,就需要进行如下修改

function throttle(fn, interval, options) {
  const {
    leading = true,
    trailing = false
  } = options

  let lastTime = 0
  let timer = null

  return function() {
    const currentTime = Date.now()

    if (!leading && !lastTime) {
      lastTime = Date.now()
    }

    const remainTime = interval - (currentTime - lastTime)

    if (remainTime <= 0) {
      // 清除定时器,并重置对应的定时器变量
      // 避免throttle函数被重复触发多次
      clearTimeout(timer)
      timer = null

      fn()
      lastTime = currentTime
      return
    }

    // 最后一次回调函数被触发
    if (trailing && !timer) {
      timer =setTimeout(() => {
        fn()

        // 最后一次throttle函数执行完毕
        // 相关变量初始化
        timer = null
        // 确保定时器开启以后,所有的throttle函数都在定时器中执行(★★★)
        lastTime = !leading ? 0: Date.now()
      }, remainTime)
    }
  }
}

和防抖函数一样,我们可以修正throttle函数的this并添加取消功能

function throttle(fn, interval, options) {
  const {
    leading = true,
    trailing = false
  } = options

  let lastTime = 0
  let timer = null

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

    if (!leading && !lastTime) {
      lastTime = Date.now()
    }

    const remainTime = interval - (currentTime - lastTime)

    if (remainTime <= 0) {
      clearTimeout(timer)
      timer = null

      fn.apply(this, args)
      lastTime = currentTime
      return
    }

    if (trailing && !timer) {
      timer =setTimeout(() => {
        // 这里的setTimeout使用了回调函数
        // 所以可以获取到正确的this
        fn.apply(this, args)

        timer = null
        lastTime = !leading ? 0: Date.now()

      }, remainTime)
    }
  }

  // 取消函数
  _throttle.cancel = () => {
    clearTimeout(timer)
    timer = 0
    lastTime = 0
  }

  return _throttle
}

同样,我们也可以通过Promise或对应的callback获取到throttle函数的返回值

function throttle(fn, interval, options) {
  const {
    leading = true,
    trailing = false
  } = options

  let lastTime = 0
  let timer = null

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

      if (!leading && !lastTime) {
        lastTime = Date.now()
      }

      const remainTime = interval - (currentTime - lastTime)

      if (remainTime <= 0) {
        clearTimeout(timer)
        timer = null

        const res = fn.apply(this, args)
        resolve(res)
        lastTime = currentTime
        return
      }

      if (trailing && !timer) {
        timer =setTimeout(() => {
          // 这里的setTimeout使用了回调函数
          // 所以可以获取到正确的this
          const res = fn.apply(this, args)
          resolve(res)

          timer = null
          lastTime = !leading ? 0: Date.now()

        }, remainTime)
      }
    })
  }

  // 取消函数
  _throttle.cancel = () => {
    clearTimeout(timer)
    timer = 0
    lastTime = 0
  }

  return _throttle
}