[TOC]

认识防抖和节流

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

  • 而JavaScript是事件驱动的,大量的操作会触发事件,加入到事件队列中处理。
  • 而对于某些频繁的事件处理会造成性能的损耗,我们就可以通过防抖和节流来限制事件频繁的发生;

    什么是防抖 debounce

    image.png
    image.png

    什么是节流 throttle

    image.png
    image.png

    对比理解防抖和节流

    image.png
    防抖:从触发事件计算,延迟执行处理函数,每次触发事件,都从 0 开始计算延迟
    节流:在一个周期内,无论事件触发多少次,处理函数都是按周期执行

    Underscore 库实现防抖和节流

    image.png
    防抖和节流都是一个工具函数,它接收回调函数,然后进行防抖或者节流处理返回一个函数,然后我们将处理好的新函数注册成事件的回调函数。 ```html

<a name="JHzBW"></a>
# 手写防抖函数
基本功能的实现:通过**定时器延迟执行响应函数**
```javascript
function Mydebounce(fn, delay) {
  return function () {
    setTimeout(() => {
      fn()
    }, delay)
  }
}

现在有个问题就是我一直输入,每次输入都延迟触发了响应函数,而我们想要的效果是只有最后一次在输入到达延迟后执行。所以一直输入的时候要清除上一次的定时器,让它不执行。

function Mydebounce(fn, delay) {
  let timer = null
  return function () {
    if (timer) clearTimeout(timer) // 清除上一次的定时器
    timer = setTimeout(() => {
      fn()
    }, delay)
  }
}

原回调函数中的 this 是绑定了触发事件的元素对象的,但是我们因为用 setTImeout 包裹了原来的回调函数,this 指向了全局,所以如果原回调函数中使用了 this ,这会导致函数不按预期执行。
我们需要手动将 this 指回 dom 元素。现在绑定在元素上的函数是 Mydebounce 返回的新函数,所以新函数中的 this 是指向 dom 元素的。
原回调函数可能还有事件对象 event 等参数,所以我们可以用剩余参数来接收。

function Mydebounce(fn, delay) {
  let timer = null
  return function (...args) { // 剩余参数接收原回调的参数
    if (timer) clearTimeout(timer) // 清除上一次的定时器
    timer = setTimeout(() => {
      fn.apply(this, args) // 箭头函数this向上找,所以箭头函数中 fn 直接显示绑定 this 即可
    }, delay)
  }
}

额外提个醒:如果回调函数中,想要使用指向 dom 元素的 this,则回调函数不能使用箭头函数,因为 this 会从本应该绑定的 dom 元素向上查找,其实已经指向 window 了。

立即执行的防抖函数

上面的防抖函数基本实现了防抖的功能。但我们还可以额外优化一下用户体验,锦上添花。目前的状态是如果用户一直输入,输入的时候不会响应任何东西。我们希望可以给用户一点反馈,让防抖和节流一样,第一次输入的时候,立即触发一次响应,让用户感受到变化。
我们可以加一个可选参数,默认第一次不执行,为 true 则第一次需要执行

function Mydebounce(fn, delay, immediate = false) { // 默认不立即执行
  let timer = null
  return function (...args) {
    if (timer) clearTimeout(timer)
    // immediate 为true,立即响应执行
    if (immediate) {
      fn.apply(this, args)
      // 将 immediate 改为 false,清除后续输入的立即执行,让它们走定时器
      immediate = false 
    } else {
      timer = setTimeout(() => {
        fn.apply(this, args)
      }, delay)
    }
  }
}

但是这还有个可有可无的小问题,当我第一次输入完,然后停止输入了,再重新开始输入的时候,因为 immediate 已经为 false 了,所以第二次开始输入的时候,不会立即响应执行。
为了解决这个问题,我们可以再加一个布尔变量来做逻辑控制。
其实很多时候通过增加添加布尔类型配合逻辑操作符能给出更多的控制路线,支撑精细的逻辑控制。

function Mydebounce(fn, delay, immediate = false) { // 默认不立即执行
  let timer = null
  let isInvolve = true
  return function (...args) {
    if (timer) clearTimeout(timer)

    // 只有两个状态都为 true 才立即执行
    if (immediate && isInvolve) {
      fn.apply(this, args)
      isInvolve = false // 改成 false 清除后续连续输入导致的立即执行
    } else {
      timer = setTimeout(() => {
        fn.apply(this, args)
        // 看起来好像又开启了连续输入带来的不停触发立即执行
        // 其实并没有,因为连续输入时,定时器都被清除了,压根不会执行到这把状态改回去
        // 等执行到这时,说明已经延迟执行了,说明了下一次输入又是一次“第一次”输入
        isInvolve = true 
      }, delay)
    }
  }
}

增加取消功能的防抖函数

如果延迟执行的时间过长,用户可能会取消,或者关闭页面,这个时候就不需要再延迟去响应事件函数了。我们就可以给返回的新函数中添加一个取消的函数。

// 防抖函数
function Mydebounce(fn, delay, immediate = false) { // 默认不立即执行
  let timer = null
  let isInvolve = true
  const _debounce = function (...args) {
    // 清除定时器
    if (timer) clearTimeout(timer)
    // 立即执行
    if (immediate && isInvolve) {
      fn.apply(this, args)
      isInvolve = false 
    } else {
      timer = setTimeout(() => {
        fn.apply(this, args)
        isInvolve = true 
      }, delay)
    }
  }
  // 添加取消函数
  _debounce.cancel = function() {
    if (timer) clearTimeout(timer)
    console.log('cancel success');    
  }
  return _debounce
}

// 监听事件
const debounce = Mydebounce(function (event) {
  console.log("防抖:响应输入事件", this, event)
}, 5000, true)
document.querySelector("#debounce").oninput = debounce
document.querySelector("button").onclick = debounce.cancel // 取消执行

回调函数返回值的处理

  1. 继续添加参数处理
  2. 放回 promise 处理

    手写节流函数

    我们可以通过剩余时间来控制回调函数执行。剩余时间就是周期减去第一次触发事件到最后一次触发的时间,如果剩余时间小于等于 0,说明周期到了,就执行一次回调函数。
    function Mythrottle(fn, interval) {
    let firstExcuteTime = 0
    const _throttle = function(...args) {
     // 获取最新的触发时刻
     let lastExcuteTime = new Date().getTime()
     let remainTime = interval - (lastExcuteTime - firstExcuteTime)
     if (remainTime <= 0) {
       fn.apply(this, args)
       // 回调执行后,当前时间就是下一轮循环的第一次触发
       firstExcuteTime = new Date().getTime()
     }
    }
    return _throttle
    }
    
    上面的实现,第一次触发事件,回调会立即执行。因为最后一次执行时刻很大(从1970年到现在),周期时间减去最后一次执行时刻肯定是个负数,所以第一次触发会立即执行。