在前端开发的过程中,我们经常会需要绑定一些持续触发的事件,如 resize、scroll、mousemove 等等,但有些时候我们并不希望在事件持续触发的过程中那么频繁地去执行函数。
一般来讲,防抖和节流是比较好的解决方案。
在事件持续触发的过程中频繁执行函数是怎样的一种情况:
1 | <div id="content" style="height:150px;line-height:150px;text-align:center; color: #fff;background-color:#ccc;font-size:80px;"></div> |
可以看到,在没有通过其它操作的情况下,函数被频繁地执行导致页面上数据变化特别快。所以,接下来让我们来看看防抖和节流是如何去解决这个问题的。
节流(throttle)
定义
函数节流指的是某个函数在一定时间间隔内(例如 3 秒)只执行一次,在这 3 秒内 无视后来产生的函数调用请求,也不会延长时间间隔。3 秒间隔结束后第一次遇到新的函数调用会触发执行,然后在这新的 3 秒内依旧无视后来产生的函数调用请求,以此类推。
如果你还无法理解,看下面这张图就清晰多了,另外点击 这个页面 查看节流和防抖的可视化比较。其中 Regular 是不做任何处理的情况,throttle 是函数节流之后的结果,debounce 是函数防抖之后的结果。
应用场景
需要间隔一定时间触发回调来控制函数调用频率:
- DOM 元素的拖拽功能实现(mousemove)
- 搜索联想(keyup)
- 计算鼠标移动的距离(mousemove)
- Canvas 模拟画板功能(mousemove)
- 射击游戏的 mousedown/keydown 事件(单位时间只能发射一颗子弹)
- 监听滚动事件判断是否到页面底部自动加载更多:给 scroll 加了 debounce 后,只有用户停止滚动后,才会判断是否到了页面底部;如果是 throttle 的话,只要页面滚动就会间隔一段时间判断一次
实现
实现方案有以下两种
- 第一种是用时间戳来判断是否已到执行时间,记录上次执行的时间戳,然后每次触发事件执行回调,回调中判断当前时间戳距离上次执行时间戳的间隔是否已经达到时间差(Xms) ,如果是则执行,并更新上次执行的时间戳,如此循环。
- 第二种方法是使用定时器,比如当 scroll 事件刚触发时,打印一个 hello world,然后设置个 1000ms 的定时器,此后每次触发 scroll 事件触发回调,如果已经存在定时器,则回调不执行方法,直到定时器触发,handler 被清除,然后重新设置定时器。
-
时间戳方案:通过闭包保存一个 previous 变量,每次触发 throttle 函数时判断当前时间和 previous 的时间差,如果这段时间差小于等待时间,那就忽略本次事件触发。如果大于等待时间就把 previous 设置为当前时间并执行函数 fn。
我们来一步步实现,首先实现用闭包保存 previous 变量。
1
2
3
4
5
6
7
8const throttle = (fn, wait) => {
// 上一次执行该函数的时间
let previous = 0
return function(...args) {
console.log(previous)
...
}
}执行 throttle 函数后会返回一个新的 function,我们命名为 betterFn。
1
2
3
4const betterFn = function(...args) {
console.log(previous)
...
}betterFn 函数中可以获取到 previous 变量值也可以修改,在回调监听或事件触发时就会执行 betterFn,即
betterFn()
,所以在这个新函数内判断当前时间和 previous 的时间差即可。1
2
3
4
5
6
7
8const betterFn = function(...args) {
let now = +new Date();
if (now - previous > wait) {
previous = now
// 执行 fn 函数
fn.apply(this, args)
}
}结合上面两段代码就实现了节流函数,所以完整的实现如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// fn 是需要执行的函数
// wait 是时间间隔
const throttle = (fn, wait = 50) => {
// 上一次执行 fn 的时间
let previous = 0
// 将 throttle 处理结果当作函数返回
return function(...args) {
// 获取当前时间,转换成时间戳,单位毫秒
let now = +new Date()
// 将当前时间和上一次执行函数的时间进行对比
// 大于等待时间就把 previous 设置为当前时间并执行函数 fn
if (now - previous > wait) {
previous = now
fn.apply(this, args)
}
}
}
// DEMO 使用上述绑定 mousemove 事件的例子
// 执行 throttle 函数返回新函数
content.onmousemove = throttle(count,1000);效果如下
可以看到,在持续触发事件的过程中,函数会立即执行,并且每 1s 执行一次。
-
定时器方案:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18function throttle(func, wait = 50) {
let timeout;
return function() {
let context = this;
let args = arguments;
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
func.apply(context, args)
}, wait)
}
}
}
// DEMO 使用上述绑定 mousemove 事件的例子
// 执行 throttle 函数返回新函数
content.onmousemove = throttle(count,1000);效果如下
可以看到,在持续触发事件的过程中,函数不会立即执行,并且每 1s 执行一次,在停止触发事件后,函数还会再执行一次。
防抖(debounce)
定义
防抖函数 debounce 指的是某个函数在某段时间内,无论触发了多少次回调,都只执行最后一次。假如我们设置了一个等待时间 3 秒的函数,在这 3 秒内如果遇到函数调用请求就重新计时 3 秒,直至新的 3 秒内没有函数调用请求,此时执行函数,不然就以此类推重新计时。
应用场景
对于连续的事件响应我们只需要执行一次回调:
- 每次 resize/scroll 触发统计事件
- 文本输入的验证(连续输入文字后发送 AJAX 请求进行验证,验证一次就好)
实现
实现原理就是利用定时器,函数第一次执行时设定一个定时器,之后调用时发现已经设定过定时器就清空之前的定时器,并重新设定一个新的定时器,如果存在没有被清空的定时器,当定时器计时结束后触发函数执行。
-
非立即执行版:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// 实现 1
// fn 是需要防抖处理的函数
// wait 是时间间隔
function debounce(fn, wait = 50) {
// 通过闭包缓存一个定时器 id
let timer = null
// 将 debounce 处理结果当作函数返回
// 触发事件回调时执行这个返回函数
return function(...args) {
// 如果已经设定过定时器就清空上一次的定时器
if (timer) clearTimeout(timer)
// 开始设定一个新的定时器,定时器结束后执行传入的函数 fn
timer = setTimeout(() => {
fn.apply(this, args)
}, wait)
}
}
// DEMO
// 执行 debounce 函数返回新函数
const betterFn = debounce(() => console.log('fn 防抖执行了'), 1000)
// 停止滑动 1 秒后执行函数 () => console.log('fn 防抖执行了')
document.addEventListener('scroll', betterFn)非立即执行版的意思是触发事件后函数不会立即执行,而是在 n 秒后执行,如果在 n 秒内又触发了事件,则会重新计算函数执行时间。
我们依旧使用上述绑定 mousemove 事件的例子,通过上面的防抖函数,我们可以这么使用
1
content.onmousemove = debounce(count,1000);
效果如下
-
立即执行版:
实现原理比较简单,判断传入的 immediate 是否为 true,另外需要额外判断是否是第一次执行防抖函数,判断依旧就是 timer 是否为空,所以只要
immediate && !timer
返回 true 就执行 fn 函数,即fn.apply(this, args)
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// immediate 表示第一次是否立即执行
function debounce(fn, wait = 50, immediate) {
let timer = null
return function(...args) {
if (timer) clearTimeout(timer)
// ------ 新增部分 start ------
// immediate 为 true 表示第一次触发后执行
// timer 为空表示首次触发
if (immediate && !timer) {
fn.apply(this, args)
}
// ------ 新增部分 end ------
timer = setTimeout(() => {
fn.apply(this, args)
}, wait)
}
}立即执行版的意思是触发事件后函数会立即执行,然后 n 秒内不触发事件才能继续执行函数的效果。