Gahing's blog Gahing's blog
首页
知识体系
  • 前端基础
  • 应用框架
  • 工程能力
  • 应用基础
  • 专业领域
  • 业务场景
  • 前端晋升 (opens new window)
  • Git
  • 网络基础
  • 算法
  • 数据结构
  • 编程范式
  • 编解码
  • Linux
  • AIGC
  • 其他领域

    • 客户端
    • 服务端
    • 产品设计
软素质
  • 面试经验
  • 人生总结
  • 个人简历
  • 知识卡片
  • 灵感记录
  • 实用技巧
  • 知识科普
  • 友情链接
  • 美食推荐 (opens new window)
  • 收藏夹

    • 优质前端信息源 (opens new window)
关于
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

Gahing / francecil

To be best
首页
知识体系
  • 前端基础
  • 应用框架
  • 工程能力
  • 应用基础
  • 专业领域
  • 业务场景
  • 前端晋升 (opens new window)
  • Git
  • 网络基础
  • 算法
  • 数据结构
  • 编程范式
  • 编解码
  • Linux
  • AIGC
  • 其他领域

    • 客户端
    • 服务端
    • 产品设计
软素质
  • 面试经验
  • 人生总结
  • 个人简历
  • 知识卡片
  • 灵感记录
  • 实用技巧
  • 知识科普
  • 友情链接
  • 美食推荐 (opens new window)
  • 收藏夹

    • 优质前端信息源 (opens new window)
关于
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • 前端基础

    • 编程语言

      • CSS

      • HTML

      • JavaScript

        • ECMAScript6入门
        • JS 手写题

          • 实现 call, apply, bind
          • 实现 debounce 和 throttle
            • 前言
            • debounce
              • 概念
              • 实现
            • throttle
              • 概念
              • 实现
            • 区别
            • 常用的使用场景
            • 拓展阅读
          • 实现 instanceOf
          • 实现 new 操作符
        • JS技巧
        • JS 学习笔记
        • Promise then 原理分析
        • async 函数编译原理
        • 你不知道的JavaScript(上)
        • 再谈闭包
        • 浏览器剪切板协议
        • 前端实现相对路径转绝对路径的几种方法
        • 为什么 0.._ 等于 undefined
        • 前端项目中常用的位操作技巧
        • 如何利用前端剪切板实现文件上传
        • 快速理解 JS 装饰器
        • 趣味js-只用特殊字符生成任意字符串
        • 重学 JS 原型链
        • 面试官问:怎么避免函数调用栈溢出
      • Rust

      • TypeScript

      • WebAssembly

    • 开发工具

    • 前端调试

    • 浏览器原理

    • 浏览器生态

  • 应用框架

  • 工程能力

  • 应用基础

  • 专业领域

  • 业务场景

  • 大前端
  • 前端基础
  • 编程语言
  • JavaScript
  • JS 手写题
gahing
2019-10-24
目录

实现 debounce 和 throttle专题

# 前言

这两个函数网上已经有很多实现了, 一般项目中直接用 lodash 或 underscore 的实现

因此,写出一个完善的 debounce 和 throttle 不是本篇的目的

理解这两个方法的实现思路,清楚使用场景才是重点

# debounce

# 概念

防抖:你尽管触发(通知我要执行该函数),我执行算我输(误,,等你累了(离最后一次触发过了 wait 时间),我再执行

这里的 执行 表示函数的实际调用, 而 触发 仅仅是通知执行

以坐电梯为例,电梯运行表示函数执行,有人进电梯表示一次触发:通知电梯运行。一段时间内没人进电梯,那么电梯就开始运行。

PS: 没人进电梯那么电梯也不会运行

# 实现

还是以坐电梯为例,我们创建以下实体类

class Elevator {
  /**
   * @param {number} no 电梯编号
   */
  constructor(no) {
    this.no = no
  }
  run () {
    console.log(`${this.no}号电梯开始运行`)
  }
}
class People {
  constructor(no) {
    this.name = "员工" + no
  }
  into (elevator) {
    console.log(`${this.name} 进入${elevator.no}号电梯`)
    elevator.run()
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

运行

let elevator = new Elevator(0)
let index = 0
new People(index++).into(elevator)
// 员工0 进入0号电梯
// 0号电梯开始运行
new People(index++).into(elevator)
// 员工1 进入0号电梯
// 0号电梯开始运行
1
2
3
4
5
6
7
8

有人进就马上运行电梯,但现实 run 执行函数(电梯运行)成本是巨大的,员工1也不可能进入电梯。

因此我们不能轻易的运行电梯。并且上面的实现,用户应该是不能直接让电梯运行的,只能通知电梯有人进电梯了。

我们进行如下改造:

编写防抖函数

function debounce (func, wait) {
  let timer = null
  return function () {
    clearTimeout(timer)
    timer = setTimeout(()=>{
      console.log('防抖完毕..开始执行')
      func()
    }, wait);
  }
}
1
2
3
4
5
6
7
8
9
10

改造 Elevator 和 People

class Elevator {
  /**
   * @param {number} no 电梯编号
   */
  constructor(no) {
    this.no = no
    // 对外提供的接口,用户告知电梯该运行了
    this.notify = debounce(this._run, 3000)
  }
  // 假装是私有方法,只能我自己调用
  _run () {
    console.log(`${this.no}号电梯开始运行`)
  }
}
class People {
  constructor(no) {
    this.name = "员工" + no
  }
  into (elevator) {
    console.log(`${this.name} 进入${elevator.no}号电梯`)
    elevator.notify()
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

刚刚的例子再重新运行一次.

let elevator = new Elevator(0)
let index = 0
new People(index++).into(elevator)
new People(index++).into(elevator)
1
2
3
4

不出意外,报了 Uncaught TypeError: Cannot read property 'no' of undefined

很明显,_run 方法执行的时候里面的 this 值为 undefined

原因在于 setTimeout 中 func 的调用方为全局作用域,在严格模式 (class 中的代码处于严格模式)下函数的 this 为 undefined

解法有多种:

  1. this.notify = debounce(this._run.bind(this), 3000)

这样相当于对外部使用者进行了要求:必须进行 bind ,其实不太好

  1. func.call(this)

此处的 this 指向为 notify 的调用方
注意 setTimeout 用的是箭头函数,否则 setTimeout 内函数的 this 是 window

与此同时,如果对 elevator.notify 进行传参的话,func 调用时忽略掉了!

因此对 debounce 进行如下改造:

function debounce (func, wait) {
  let timer = null
  return function () {
    clearTimeout(timer)
    timer = setTimeout(()=>{
      console.log('防抖完毕..开始执行')
      func.apply(this,arguments)
    }, wait);
  }
}
1
2
3
4
5
6
7
8
9
10

其他代码调整了下输出:

class Elevator {
  /**
   * @param {number} no 电梯编号
   */
  constructor(no) {
    this.no = no
    // 对外提供的接口,用户告知电梯该运行了
    this.notify = debounce(this._run, 3000)
  }
  // 假装是私有方法,只能我自己调用
  _run (...args) {
    console.log("最后一次调用传入的参数为:",args)
    console.log(`${this.no}号电梯开始运行`)
  }

}
class People {
  constructor(no) {
    this.name = "员工" + no
  }
  into (elevator) {
    console.log(`${this.name} 进入${elevator.no}号电梯`)
    elevator.notify(this.name)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

测试输出

let elevator = new Elevator(0)
let index = 0
new People(index++).into(elevator)

new People(index++).into(elevator)

setTimeout(() => {
  new People(index++).into(elevator)
}, 1000);
new People(index++).into(elevator)

// 员工0 进入0号电梯
// 员工1 进入0号电梯
// 员工2 进入0号电梯

// ... 等待1s

// 员工3 进入0号电梯

// ... 等待3s

// 防抖完毕..开始执行
// 最后一次调用传入的参数为: ["员工3"]
// 0号电梯开始运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

至此,我们的实现就能达到基本需求了。 如果看了 underscore 等开源库的话,会发现它还实现了其他需求

  1. 立刻执行(leading=true):立即执行 func 方法,随后进行的每一次调用,只有超过 wait 时间没有再次调用,才会执行。

常用场景:初次点击搜索框控件,进行一次查询

  1. 禁用结束后的回调(trailing=false):超过 wait 时间没有再次调用,不进行执行,但允许下次立即执行(配置 leading=true 的话)。
  2. 返回值:当采用立即执行模式时,需要获取函数执行的返回值

初次查询获取到完整列表

  1. 取消防抖: 为了立刻执行模式时快速执行,避免还需要等待 wait 时间;非立刻执行模式下相当于清空原来的状态

还是以上电梯为例,这里为了方便理解,我们所说的电梯执行,是快速把人送到又回来等待别人进入。

  • leading=false;trailing=true【lodash 默认设置】: 上文的例子,第一个人进不会马上运行,超过 wait 时间都没人进电梯,那电梯就开始运行
  • leading=true;trailing=true: 第一个人进电梯后,电梯马上运行。如果第二个人在 wait 时间内进入电梯,那么开始防抖处理,超过 wait 时间都没人进电梯,那电梯才开始运行;如果第二个人距离第一个人进电梯的时间大于 wait (比如比较晚来,比如电梯执行比较慢等因素) 那么他和第一人一样,直接进入电梯并让电梯执行。
  • leading=true;trailing=false: 第一个人进电梯后,电梯马上运行。如果第二个人在 wait 时间内进入电梯,那么开始防抖处理,超过 wait 时间都没人进电梯,电梯会做个判断,下次再有一个人进,电梯马上开走,之后进电梯的人就和当前的第二个人同样处理;如果如果第二个人距离第一个人进电梯的时间大于 wait ,那么他和第一人一样,直接进入电梯并让电梯执行。
  • leading=false;trailing=false: 电梯永远不运行。。。

取消防抖:电梯运行前要等 wait 时间,这时候电梯有个功能,按了某个按钮后,不用等 wait 时间,只要有新的人进电梯电梯立马运行(leading=true),或者重新开始防抖处理(leading=false)

根据需求进行配置,可以看出来,我们比较常用的是第一种,这也是 lodash 的默认设置

当然,上面这些需求的实现不是本文的重点,感兴趣的话可以直接看开源库源码和文章底部的拓展阅读,其实不会很难~

  1. lodash-debounce 使用文档 (opens new window)
  2. underscore-debounce github (opens new window)
  3. lodash-debounce github (opens new window)

# throttle

# 概念

节流:顾名思义,用来减少函数的执行次数的,固定过一段时间后才会执行。

以和产品撕逼为例,做需求表示函数执行,提需求表示一次触发:通知你做需求。产品初次给你提了一个需求,可是你很忙(你觉得有坑),你让TA理清了再来,过段时间你再做(你是有原则的,从第一次提需求开始固定时间后你一定去做),这段时间产品可以对需求进行变更优化 ~ 。 然后产品又给你提了一个需求……

PS: 如果产品没提需求,那自然也不用做了

试想一下,这里如果用防抖的场景会如何?

是不是就像产品时不时的给你改需求,你每次都得重新设计方案- -。直到很久没改需求了,你才开始处理需求。

# 实现

还是以做需求为例,我们创建以下实体类

/**
 * 研发
 */
class RD {
  /**
   * @param {number} no 研发编号
   */
  constructor(no) {
    this.name = `研发` + no
    // 用于需求方通知开发处理需求
    this.notify = this._processing
  }
  // 假装是私有方法,只能研发自己调用
  _processing (...args) {
    console.log("需求文档:", args)
    console.log(`${this.name}开始处理需求`)
  }

}
/**
 * 产品经理
 */
class PM {
  constructor(no) {
    this.name = "产品经理" + no
  }
  request (rd, requirement) {
    console.log(`${this.name} 请求 ${rd.name} 实现 ${requirement}`)
    rd.notify(requirement)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

在不进行节流的情况下,场景如下

let rd = new RD(0)
let pm = new PM(0)

pm.request(rd,"微信APP")
pm.request(rd,"抖音APP")

// 产品经理0 请求 研发0 实现 微信APP
// 需求文档: ["微信APP"]
// 研发0开始处理需求
// 产品经理0 请求 研发0 实现 抖音APP
// 需求文档: ["抖音APP"]
// 研发0开始处理需求
1
2
3
4
5
6
7
8
9
10
11
12

研发估计得累死...

进行防抖的话呢?

// RD 中进行如下修改
this.notify = debounce(this._processing,5000)

function debounce (func, wait) {
  let timer = null
  return function () {
    console.log('研发收到需求:',arguments)
    clearTimeout(timer)
    timer = setTimeout(()=>{
      func.apply(this,arguments)
    }, wait);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

效果如下:

let rd = new RD(0)
let pm = new PM(0)

pm.request(rd,"微信APP")
pm.request(rd,"抖音APP")

// 产品经理0 请求 研发0 实现 微信APP
// 研发收到需求: ["微信APP"]
// 产品经理0 请求 研发0 实现 抖音APP
// 研发收到需求: ["抖音APP"]

// 过了5s...

// 需求文档: ["抖音APP"]
// 研发0开始处理需求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

虽然还没开始处理,但不断被告知修改需求,也累的够呛

那换成节流呢?

// RD 中进行如下修改
// 表示初次接收到需求后,5s后开发一定会去做
this.notify = throttle(this._processing,5000)

function throttle (func, wait) {
  let timer = null
  return function () {
    if (!timer) {
      console.log('研发收到需求:', arguments)
      timer = setTimeout(() => {
        func.apply(this, arguments)
        timer = null
      }, wait);
    }

  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

操作如下

let rd = new RD(0)
let pm = new PM(0)

pm.request(rd, "微信APP")
pm.request(rd, "抖音APP")

/*** 第0s ***/

// 产品经理0 请求 研发0 实现 微信APP
// 研发收到需求:["微信APP"]
// 产品经理0 请求 研发0 实现 抖音APP

/*** 第5s ***/

// 需求文档: ["微信APP"]
// 研发0开始处理需求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

好像有哪里不对?研发做的怎么是 微信APP 的需求,说明 func.apply(this, arguments) 传递的参数 arguments 不对

原因在于箭头函数没有自己的 this 和 arguments ,所以该函数内这两个的值是拿的上层作用域 function 函数中的值,最关键的是,这个值是声明时确定而不是执行时确定的。

由于该箭头函数只在第一次 timer 为空的时候被声明,因此箭头函数里面的 arguments 的值就没有再改过了

我们做个改造,将 arguments 提到上层作用域中

function throttle (func, wait) {
  let timer = null
  let args = []
  return function () {
    args = arguments
    if (!timer) {
      console.log('研发收到需求:', args)
      timer = setTimeout(() => {
        func.apply(this, args)
        timer = null
      }, wait);
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let rd = new RD(0)
let pm = new PM(0)

pm.request(rd, "微信APP")
pm.request(rd, "抖音APP")
setTimeout(()=>{
  pm.request(rd, "今日头条APP")
},2000)
setTimeout(()=>{
  pm.request(rd, "chrome app")
},6000)

/*** 第0s ***/

// 产品经理0 请求 研发0 实现 微信APP
// 研发收到需求:["微信APP"]
// 产品经理0 请求 研发0 实现 抖音APP

/*** 第2s ***/

// 产品经理0 请求 研发0 实现 今日头条APP

/*** 第5s ***/

// 需求文档: ["今日头条APP"]
// 研发0开始处理需求

/*** 第6s ***/

// 产品经理0 请求 研发0 实现 chrome app
// 研发收到需求: ["chrome app"]

/*** 第11s ***/

// 需求文档: ["chrome app"]
// 研发0开始处理需求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

至此,节流的基本功能就开发完成了。对比下开源实现,我们还缺的功能有:

  1. 立即执行 options.leading=true: 我们上面的实现就是 options.leading=false 的效果
  2. 禁用结束后的回调 options.trailing=false: 我们上面的实现就是 options.trailing=true 的效果,即时间一到就会进行函数的执行。禁用后时间一到不会再执行一次
  3. 返回结果:立即执行模式时可以获取到结果
  4. 取消节流:当采用立即执行模式时,要过一段时间才能重新触发函数执行,取消节流后就函数触发就会马上执行

以做需求为例,

  • leading=false,trailing=true: 上文的例子,研发固定过段时间才开始做需求
  • leading=true,trailing=false: 你只做第一版的需求,在一段时间内产品改需求你都不理会TA
  • leading=true,trailing=true【lodash 默认设置】: 你先做了第一版的需求,在一段时间内产品不断改需求,你最后会再做一次需求
  • leading=false,trailing=false: 你啥也不做~嘻嘻

所以,一般情况下我们不能同时设置 leading 和 trailing 为 false。

返回结果 的意思就是:产品要求你做的第一版需求马上出效果

取消节流 的意思就是:产品告诉你领导你在偷懒,下次你马上就收到产品的需求了,如果 leading=true ,那下一次需求马上解决,否则还是等待 wait 再做

相关的开源库源码可以参考:

  1. underscore-throttle github (opens new window)
  2. lodash-throttle github (opens new window)

# 区别

接下来说下两者的区别吧,其实可以用一句话概括,最终何时执行取决于发起方还是执行方

取决于发起方那么是 debounce ,取决于执行方那么是 throttle

还是用做需求为例,debounce 的情况,研发偏向产品一段时间后不改需求才开始做需求,如果产品不断的改需求,那研发做需求的时间是不能控制的

而 throttle 的情况,研发偏向固定时间段后才做需求,这个时间段中,产品该不该需求都不影响我什么时候做需求

# 常用的使用场景

以下几个场景,使用哪种策略更好,以及对应的配置项

  1. 搜索框的筛选
  2. 抢票按钮
  3. 发送短信验证码
  4. 元素拖拽
  5. 窗口 resize,调整布局

这里就不给出答案了,欢迎评论~

# 拓展阅读

  1. JavaScript专题之跟着 underscore 学防抖 (opens new window)
  2. JavaScript专题之跟着 underscore 学节流 (opens new window)
编辑 (opens new window)
上次更新: 2024/09/01, 23:56:56
实现 call, apply, bind
实现 instanceOf

← 实现 call, apply, bind 实现 instanceOf→

最近更新
01
浅谈代码质量与量化指标
08-27
02
快速理解 JS 装饰器
08-26
03
Vue 项目中的 data-v-xxx 是怎么生成的
09-19
更多文章>
Theme by Vdoing | Copyright © 2016-2024 Gahing | 闽ICP备19024221号-1
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式