JavaScript防抖、节流以及rAF

防抖(Debounce) 和节流(throttle) 是两个可以控制在一段时间内函数执行次数的技巧。也是前端界的出名人物,我们在输入框、DOM事件等场景下经常要用到。今天来完整地梳理一遍防抖和节流,顺便提一下rAF。

1 防抖与节流的定义

  • 防抖:事件持续触发,但只有当事件停止触发后n秒才执行函数。
  • 节流:事件持续触发时,每n秒执行一次函数。

2 防抖 debounce

场景

监听鼠标移动事件,每次监听到移动屏幕数字+1。

<div id="container"></div>
<script>
  let count = 1
  let container = document.getElementById('container')
  function getUserAction() {
    container.innerHTML = count++
  }
  container.onmousemove = debounce(getUserAction, 1000)
</script>

第一版

// 第一版
function debounce(fn, wait) {
  let timeout
  return function () {
    timeout && clearTimeout(timeout)
    timeout = setTimeout(fn, wait)
  }
}

第二版(修正 this 指向)

发现问题

fn 送入 setTimeout() 之后,getUserAction() 中的 this 指向了 Window

解决方案

手动绑定 this 指针

// 第二版 解决 this 指向
function debounce(fn, delay) {
  let timeout
  return function () {
    const self = this
    timeout && clearTimeout(timeout)
    timeout = setTimeout(() => {
      fn.apply(self)
    }, delay)
  }
}

第三版(解决传参)

发现问题

fn 无法传参数

解决问题

使用 ES6 的 剩余参数 将参数传入 fn

拓展:剩余参数和 arguments对象的区别

// 第三版 解决传参
function debounce(fn, delay) {
  let timeout
  return function (...args) {
    const self = this
    timeout && clearTimeout(timeout)
    timeout = setTimeout(() => {
      fn.apply(self, args)
    }, delay)
  }
}

到了这里,我们的防抖函数已经完成了!下面是三个可能的拓展需求。

新需求:立即执行

不等到事件停止触发后才执行,希望立即执行函数。然后等到停止触发n秒后,才重新触发执行。

也就是—— immediate 参数将执行时机从末尾改成了开头

// 添加 immediate 参数控制是否立即执行
function debounce(fn, delay, immediate = false) {
  let timeout
  // 首次立即执行
  let callNow = true

  return function (...args) {
    const self = this
    if (timeout) clearTimeout(timeout)

    // 需要立即执行的情况
    if (immediate) {
      // 说明已经过了 n 秒,允许执行
      if (callNow) {
        fn.apply(self, args)
      }
      callNow = false
      timeout = setTimeout(() => {
        callNow = true  // n 秒后设置 callNow 为 true,也就是可以执行
      }, delay)
    } else {
      timeout = setTimeout(() => {
        console.log('延迟执行')
        fn.apply(self, args)
      }, delay)
    }
  }
}

新需求:返回值

此时注意一点,就是 getUserAction 函数可能是有返回值的,所以我们也要返回函数的执行结果,但是当 immediatefalse 的时候,因为使用了 setTimeout ,我们将 fu.apply(self, args) 的返回值赋给变量,最后再 return 的时候,值将会一直是 undefined,所以我们只在 immediatetrue 的时候返回函数的执行结果。

// 在immediate为true的时候返回函数的执行结果。
function debounce(fn, delay, immediate = false) {
  let timeout
  let callNow = true
  let result
  return function (...args) {
    const self = this
    if (timeout) clearTimeout(timeout)

    // 需要立即执行的情况
    if (immediate) {
      // 说明已经过了 n 秒,允许执行
      if (callNow) {
        result = fn.apply(self, args)
      }
      callNow = false
      timeout = setTimeout(() => {
        callNow = true  // n 秒后设置 callNow 为 true,也就是可以执行
      }, delay)
    } else {
      timeout = setTimeout(() => {
        fn.apply(self, args)
      }, delay)
    }
    return result
  }
}

...

// 让函数有返回值
function getUserAction(e) {
  let ret = count
  container.innerHTML = count++
  return `当前移动${ret}次`
}

// 测试一下
const getDebounceResult = debounce(getUserAction, 600, true)
document
  .getElementById('container')
  .addEventListener('mousemove', function(e) {
    const result = getDebounceResult.call(this, e)
    console.log('result', result)
  })

测试结果

image-20200302153109791

疑问:为什么在immediatetrue时,才可能存在返回值。如果为false,在n秒之后执行就没有返回值了吗?

    ...
    } else {
      timeout = setTimeout(() => {
        fn.apply(self, args)
      }, delay)
    }
    return result
  }
}

如果 immediatefalse,那么这个事件是 delay 毫秒后才执行的,但是执行完 else 的代码块之后,马上就执行 return result 这一行代码了。而 setTimeout 中的回调函数是延迟执行的,result 没有办法获取到结果,所以会返回 undefined也就是说,如果 immediatefalse,那你本来就没办法拿到 result 了。

新需求:取消本次防抖计时

举个夸张的例子哈,delay 设置为10分钟。那么用户一旦进入防抖计时,他就必须等十分钟才能执行。我们能否加一个功能,让用户可以马上取消这次计时,重新开始呢?

我们只要清除掉原来的定时器就可以了。

// 添加重置防抖计时功能
function debounce(fn, delay, immediate = false) {
  let timeout
  let callNow = true
  let result

  // 正常的防抖函数
  const debounced = function (...args) {
    const self = this
    if (timeout) clearTimeout(timeout)

    if (immediate) {
      if (callNow) {
        result = fn.apply(self, args)
      }
      callNow = false
      timeout = setTimeout(() => {
        callNow = true
      }, delay)
    } else {
      timeout = setTimeout(() => {
        fn.apply(self, args)
      }, delay)
    }
    return result
  }

  // 这里再挂上重置定时器的方法
  debounced.cancel = function () {
    clearTimeout(timeout)
    console.log('清除了定时器')
    timeout = null
    callNow = true
  }

  return debounced
}

我们把测试用例也写掉

<body>
  <div id="container"></div>
  <button id="btn">点击取消防抖</button>
  <script src="debounce.js"></script>
  <script>
    let count = 1
    let container = document.getElementById('container')

    function getUserAction(e) {
      let ret = count
      container.innerHTML = count++
    }
	
    const getDebounceResult = debounce(getUserAction, 2000, true)
    container.onmousemove = getDebounceResult
		// 点击按钮重置防抖定时
    document.getElementById('btn').addEventListener('click', function() {
      getDebounceResult.cancel()
    })
  </script>
</body>

在防抖计时过程中,点一下按钮然后马上进入检测区域,数字会马上+1,说明我们成功重置了防抖计时。

3 节流 throttle

节流的原理很简单:如果你持续触发事件,每隔一段时间,只执行一次事件。

关于节流的实现,有两种主流的实现方式,一种是使用时间戳,一种是设置定时器。

使用时间戳(有头无尾)

使用时间戳,当触发事件的时候,我们取出当前的时间戳,然后减去之前的时间戳(最一开始值设为 0 ),如果大于设置的时间周期,就执行函数,然后更新时间戳为当前的时间戳,如果小于,就不执行。

// 时间戳版
function throttle(fn, delay) {
  let previous = 0
  let self
  return function (...args) {
    const now = +new Date()
    self = this
		// 持续一段时间就触发事件
    if (now - previous > delay) {
      fn.apply(self, args)
      previous = now
    }
  }
}

这样鼠标移入立即执行,时间间隔不满 delay 就不会执行,所以“有头无尾”。

使用定时器(有尾无头)

当触发事件的时候,我们设置一个定时器,再触发事件的时候,如果定时器存在,就不执行,直到定时器执行,然后执行函数,清空定时器,这样就可以设置下个定时器。

// 定时器版
function throttle(fn, delay) {
  let timeout
  let self

  return function (...args) {
    self = this
    if (!timeout) {
      timeout = setTimeout(() => {
        timeout = null
        fn.apply(self, args)
      }, delay)
    }
  }
}

鼠标移入不会立即执行,而是等待了 delay 秒后执行。当你移除鼠标,仍然会在满 delay 后再次执行,所以“有尾无头”。

比较两个方法

  1. 第一种事件会立刻执行,第二种事件会在 n 秒后第一次执行
  2. 第一种事件停止触发后没有办法再执行事件,第二种事件停止触发后依然会再执行一次事件

有头有尾(最精彩的一段代码)

就是鼠标移入能立刻执行,停止触发的时候还能再执行一次!

function throttle(fn, delay) {
  let timeout
  let self
  let _args
  let previous = 0

  const later = function () {
    previous = +new Date()
    timeout = null
    fn.apply(fn, _args)
  }

  const throttled = function (...args) {
    const now = +new Date()
    const remaining = delay - (now - previous)

    self = this
    _args = args

    if (remaining <= 0 || remaining > delay) {
      if (timeout) {
        clearTimeout(timeout)
      }
      previous = now
      fn.apply(self, args)
    } else if (!timeout) {
      timeout = setTimeout(later, remaining)
    }
  }

  return throttled
}
  • 第一次触发onmousemove事件:previous = 0,那么 remaining < 0timeout 未定义,更新 previous,执行 fn.apply(self, args),屏幕显示出 1,那么这是“头”

  • 紧接着第二次触发:此时 0 < remaining < delay,并且 timeout = undefined,所以走下面的 else if此时便设置好了定时器

  • 后面的若干次触发:依然是 0 < remaining < delay,然而 timeout 已经有值,因此什么也不会做

  • 终于第二次设置的 setTimeout 设置的时间到了,执行 later 函数,这个函数里面更新了 previous,执行 fn.apply(self, _args),重置了 timeout,因此屏幕显示出 2。

  • 要注意的是,在这个时间内,你即使将鼠标不动了 or 移出去,上一步我们设置的定时器依然在,所以这就是有 “尾”

  • 后续重复上述步骤。

可调节头尾

/**
 * options {
 *  leading: boolean,是否有头,
 *  trailing: boolean,是否有尾
 * }
 */
function throttle(fn, delay, options) {
  let timeout
  let self
  let _args
  let previous = 0

  options = options || {}

  const later = function () {
    previous = options.leading ? +new Date() : 0
    timeout = null
    fn.apply(fn, _args)
    if (!timeout) self = _args = null
  }

  const throttled = function (...args) {
    const now = +new Date()

    // 如果leading = false,则让首次触发事件时 previous = now
    if (previous === 0 && !options.leading) previous = now
    const remaining = delay - (now - previous)

    self = this
    _args = args

    if (remaining <= 0 || remaining > delay) {
      if (timeout) {
        clearTimeout(timeout)
        timeout = null
      }
      previous = now
      fn.apply(self, args)
      if (!timeout) self = _agrs = null
    } else if (!timeout && options.trailing) {
      timeout = setTimeout(later, remaining)
    }
  }

  return throttled
}

大致原理和有头有尾版的类似。我们可以进行”控制变量法“来分别看leadingtrailing 的作用。

context = args = null 主要是为了释放内存,回收垃圾。

4 requestAnimationFrame(rAF)

requestAnimationFrame 是另一种能控制一个 function 执行速率的函数。理论上等价于 throttle(fn, 16.7)

在做动画时经常用到这种方式,防止在一帧时间中渲染多次造成性能浪费。而这个一帧 是取决于显示器帧数的,比如是显示器为60FPS,则一帧时间为 1000 / 60 = 16.7ms 。

更多关于requestAnimationFrame

// throttle(fn, 帧时间)
function useRAF(fn) {
  let timer
  return function () {
    timer && cancelAnimationFrame(timer)
    timer = requestAnimationFrame(fn)
  }
}

优势

  • 目标为 60fps,而且游览器会优化到最佳渲染时间;
  • 使用非常简单,而且是标准Web API,不用担心维护。

劣势

  • 不支持 Node.js
  • requestAnimationFrame() 运行在后台标签页或者隐藏的<iframe>里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命。当然对于鼠标键盘这类事件来说问题不大。

由以上,可以知道 rAF 适用于网页动画、鼠标键盘事件等等,但是不适用于网络异步请求等场景。

5 致谢