javascript防抖和节流原理

防抖和节流的总结:

1. 防抖(debounce) 防抖是限制频率,多次出发一次执行。
2. 节流(throttle) 节流是限制次数,限制规定时间内执行次数。

这篇文章以 underscore.js 工具库中的 防抖和节流函数作为演示,并探究其源码。
underscore.js 中文文档

本篇文章只对其原理进行探究,使用方式不做太多概述,如果不知道怎么使用的同学,可自行百度。

**下边是 html 演示代码, 里边引入debounce.js throttle.js 两个文件 **

<!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>

  <style>
    #container {
      width: 100%;
      height: 400px;
      background: #999;
      text-align: center;
      line-height: 400px;
      color: #fff;
    }
  </style>
</head>
<body>
  <div id="container"></div>
  <div id="btn">取消</div>

  <!-- 这里引入  underscore.js CDN 地址,可以参考其使用方式和完成的效果是否一致 -->
  <script src="https://cdn.bootcdn.net/ajax/libs/underscore.js/1.9.1/underscore.js"></script>
  <!-- 防抖实现 -->
  <script src="./debounce.js"></script>
  <!-- 节流实现 -->
  <script src="./throttle.js"></script>
</body>
</html>

效果如下
在这里插入图片描述
通过对 container 元素添加 onmousemove 事件,来测试我们实现的这两个方法。左下角的取消按钮用来实现取消操作


let count = 0
let container = document.querySelector('#container')
let btn = document.querySelector('#btn')

function doSomeThing (e) {
  console.log(e)
  console.log(this)
  // 可能会做回调或者 ajax 请求
  container.innerHTML = count++
  return '我有返回值'
}

container.onmousemove = debounce(doSomeThing, 300, false)

debounce 方法接受三个参数,第一:要被限制防抖的函数,第二:时间间隔,第三:是否立即执行,

  1. 默认为 false 延迟执行,第一次触发不会执行,规定时间内连续触发也不会执行,停止触发以后在规定时间执行一次,
  2. true 就是onmousemove事件被触发以后立即执行,在间隔时间内连续触发不会执行,停止触发也不会再次执行,从新触发,还会立即执行。然后在开始计算时间间隔

要思考的问题:

  1. 防抖节流函数都是高阶函数,会返回一个函数
  2. 函数内部 this 指向问题,可以用 apply call bind 这些方法, 或者使用箭头函数
  3. event 对象的问题,涉及到 dom 事件的,有可能会使用 event 对象
  4. 函数有返回值的问题

防抖 (debounce.js)

根据上边的使用方式执行原理,和 问题,来完成 debounce 函数


function debounce (func, wait, immediate) {
  // timeout 是定时器对象, args 对应返回函数的 实参列表 argument, result 是被限制防抖函数中的返回值
  let timeout, args, result;
  return function () {
    // 获取 this 指向存起来,下边定时器中的 func 函数被 apply 调用时用来改变 this 执行,或者定时器函数中直接使用箭头函数
    let context = this
    // 接收实参列表,如果涉及到 dom 操作,可能会用到 event 对象,这里用来接收
    args = arguments
    // 函数被触发,先取消定时器,一直触发一直清空,只有等停止触发以后,最后一次清空以后,才会执行下边的代码
    clearTimeout(timeout)
    // 立即执行,然后在规定时间内不再触发
    if (immediate) {
      // 第一次触发,timeout = undefined, 取反即为 true, 下边会立即执行,再次触发, timeout已经有值,取反即为 false,下边便不会在立即执行
      let callNow = !timeout
      // 规定时间到了以后,timeout 置空,再次触发,上边 timeout 取反 即为 true, 下边再次执行 
      timeout = setTimeout(() => {
        timeout = null
      }, wait)
      // callNow === true 立即执行
      if (callNow) {
        result = func.apply(context, args)
      }
    } else {
      // 延迟执行,直接延迟规定时间再执行
      timeout = setTimeout(function () {
        result = func.apply(context, args)
      }, wait)    
    }
    // 考虑到如果被限制防抖函数中有返回值的情况,这里直接 return 返回值
    return result
  }
}

根据上边的代码来讲一下实现原理:

immediate: false 是延迟执行,onmousemove 触发以后 debounce 函数被触发,会返回一个函数,这就是一个高阶函数,函数内部,会先取消定时器,连续触发的情况下就会连续清空,下边的延迟执行方法就会不会执行。只有等到 onmousemove 停止触发以后,最后会执行到下边的延迟函数,等传入的时间间隔到了以后,通过 apply(context, args) 调用一次 被传入的 func (doSomeThing) 函数。
context 是为了防止调用 debounce 函数时this指向被改变,args 是 arguments 对象的实参列表,
里边会有 doSomeThing(event) 函数中的参数,比如 event 对象,可以防止 event 对象丢失。

immediate: true 是立即执行,当 onmousemove 触发以后,debounce 函数被触发,这时候不会是延迟执行,还是先取消掉定时器,第一次触发,timeout = undefined, 取反即为 true, 下边会立即执行,再次触发, timeout已经有值,取反即为 false,下边便不会在立即执行。规定时间到了以后,timeout 置空,再次触发,上边 timeout 取反 即为 true, 下边再次执行

基本上原理和思路就是这样。但是还少一个取消的方法。再来完善一下
完整版本

// 防抖函数
function debounce (func, wait, immediate) {
  // timeout 是定时器对象, args 对应返回函数的 实参列表 argument, result 是被限制防抖函数中的返回值
  let timeout, args, result;
  // 声明 debounced 对象来接收返回函数
  let debounced = function () {
    // 获取 this 指向存起来,下边定时器中的 func 函数被 apply 调用时用来改变 this 执行,或者定时器函数中直接使用箭头函数
    let context = this
    // 接收实参列表,如果涉及到 dom 操作,可能会用到 event 对象,这里用来接收
    args = arguments
    // 函数被触发,先清空定时器,一直触发一直清空,只有等停止触发以后,最后一次清空以后,才会执行下边的代码
    clearTimeout(timeout)
    // 立即执行,然后在规定时间内不再触发
    if (immediate) {
      // 第一次触发,timeout = undefined, 取反即为 true, 下边会立即执行,再次触发, timeout已经有值,取反即为 false,下边便不会在立即执行
      let callNow = !timeout
      // 规定时间到了以后,timeout 置空,再次触发,上边 timeout 取反 即为 true, 下边再次执行 
      timeout = setTimeout(() => {
        timeout = null
      }, wait)
      // callNow === true 立即执行
      if (callNow) {
        result = func.apply(context, args)
      }
    } else {
      // 延迟执行,直接延迟规定时间再执行
      timeout = setTimeout(function () {
        result = func.apply(context, args)
      }, wait)
    }
    // 考虑到如果被限制防抖函数中有返回值的情况,这里直接 return 返回值
    return result
  }
  // 在 debounced 对象上添加 cancel 方法取消定时器,并置空
  debounced.cancel = function () {
    clearTimeout(timeout)
    timeout = null
  }
  return debounced
}


// 测试示例
let count = 0
let container = document.querySelector('#container')
let btn = document.querySelector('#btn')

let doSome = debounce(doSomeThing, 1000)

btn.onclick = function () {
  doSome.cancel()
}

function doSomeThing (e) {
  console.log(e)
  console.log(this)
  container.innerHTML = count++
  return '我有返回值'
}

// container.onmousemove = _.debounce(doSomeThing, 300, true)
// container.onmousemove = debounce(doSomeThing, 300, true)
container.onmousemove = doSome
// container.onmousemove = debounce(doSomeThing, 300, false)

防抖使用场景:
1.scroll 事件滚动触发, 上拉加载
2.搜索框输入查询
3.表单验证
4.浏览器窗口缩放, resize 事件

节流 (throttle.js)

节流方法,根据 underscore.js 中定义的 throttle 和使用使用方法。throttle 方法也是有第三个参数 options 对象这个对象中有两个属性: leading trailing:

如果你想禁用第一次首先执行的话,传递{leading: false},
还有如果你想禁用最后一次执行的话,传递{trailing: false}
两个参数可以同时传递,但是只能有三种情况
第一次会输出,最后一次不会被调用 {leading: true, trailing: false}
第一次不会输出, 最后一次会被调用 {leading: false, trailing: true}
第一次会输出, 最后一次会被调用 {leading: true, trailing: true}
都为 false 的时候 trailing 会失效

带着上边的问题来实现:
第一版:利用时间间隔来实现第一次执行,最后一次不执行
判断条件就是,声明一个旧的时间戳默认值是0 每次触发时拿到当前的时间戳,
如果当前时间戳 减去旧的时间戳 大于 传入的时间间隔就立即执行,然后把 当前时间戳的值赋值给 旧的时间戳

代码如下

function throttle (func, wait) {
  let context, args, old = 0;
  return function () {
    context = this
    args = arguments
    let now = Date.now()
    // 利用时间间隔,实现第一次执行,最后一次不执行
    if (now - old > wait) {
      func.apply(context, args)
      old = now
    }
  }
}

第二版:第一次不会触发,最后一次会触发,利用定时器延迟实现,第一次不会触发,最后一次会触发

function throttle (func, wait) {
  let context, args, timeout;
  return function () {
    context = this
    args = arguments
    // 利用定时器延迟执行,可以实现第一次不执行,最后一次执行
    if (!timeout) {
      timeout = setTimeout(() => {
        func.apply(context, args)
        clearTimeout(timeout)
        timeout = null
      }, wait)
    }
  }
}

第三版:第一次会执行,最后一次也会执行

function throttle (func, wait) {
  let context, args, timeout, old = 0;
  return function () {
    context = this
    args = arguments
    let now = Date.now()
    // 第一次触发会走这里
    if (now - old > wait) {
      if (timeout) {
        clearTimeout(timeout)
        timeout = null
      }
      func.apply(context, args)
      old = now
    // 事件停止触发,最后一次走这里
    } else if (!timeout) {
      timeout = setTimeout(() => {
        // 这里给 old 重新赋值, 不然会因为上边的 now-old > wait 的误差 出现bug
        old = Date.now()
        timeout = null
        func.apply(context, args)
      }, wait)
    }
  }
}

第四版:完整版,有参数版本

function throttle (func, wait, options) {
  let context, args, timeout, old = 0;
  if (!options) options = {}
  let throttled = function () {
    context = this
    args = arguments
    let now = Date.now()
     // 第一次不会触发的时候,这里直接把 now 赋值给 old, 下边判断条件就会失效第一次不会执行
    if (options.leading === false && !old) {
      old = now
    }
    // 第一次触发会走这里
    if (now - old > wait) {
      if (timeout) {
        clearTimeout(timeout)
        timeout = null
      }
      func.apply(context, args)
      old = now
    // 事件停止触发,最后一次走这里,当前没有在执行的 定时器 并且 允许最后一次执行
    } else if (!timeout && options.trailing !== false) {
      timeout = setTimeout(() => {
        // 这里给 old 重新赋值, 不然会因为上边的 now-old > wait 的误差 出现bug
        old = Date.now()
        timeout = null
        func.apply(context, args)
      }, wait)
    }
  }
  // 取消方法
  throttled.cancel = function () {
    clearTimeout(timeout)
    timeout = null
  }
  return throttled
}




let count = 0
let container = document.querySelector('#container')
let btn = document.querySelector('#btn')


function doSomeThing (e) {
  console.log(e)
  container.innerHTML = count++
  console.log(this)
}

let doSome = throttle(doSomeThing, 1000)

btn.onclick = function () {
  doSome.cancel()
}

// container.onmousemove = _.throttle(doSomeThing, 1000, {leading: true ,trailing: true})
// container.onmousemove = throttle(doSomeThing, 2000)
// container.onmousemove = throttle(doSomeThing, 2000, {leading: true, trailing: false})
// container.onmousemove = throttle(doSomeThing, 2000, {leading: false, trailing: true})
// container.onmousemove = throttle(doSomeThing, 2000, {leading: true, trailing: true})

container.onmousemove = doSome

由于节流函数实现步骤比较详细,这里的实现原理就不过多叙述了,从版本一 到版本四 一步步看下来理解会快一点。

节流使用场景:

  1. DOM 元素的拖拽功能实现
  2. 射击游戏
  3. 计算鼠标移动的距离
  4. 监听 scroll 滚动事件,规定时间内计算滚动高度等。