再谈防抖/节流
对于一名合格的前端工程师,
没有不知道防抖与节流的吧。
我并不是一名真正的前端,
正处于成为合格前端的路上。
在前端的学习过程中,关于[防抖/节流]
也听老师讲过,也看过一些大佬写的指南,
虽然当时明白,过后即忘;
关键在于理解和思路,今天整理一下,
写一篇不懵圈指北分享给大家。
防抖
什么是防抖
我有两个儿子,大宝跟二仁。
这二人都是吃货。一天,
场景1
我 :大宝,爸爸今天升级加薪
迎娶白富 美了,给你买好吃的,
想吃点什么水果,选一个?
大宝:我想吃,西瓜,
不对,是哈密瓜,
不对,是芒果,
不对,是荔枝,
不对,是草莓。
我 :好的,草莓是吧,这就去买。
这就是【防抖】,永远只响应最新的(最后一次)请求。
防抖的JS实现
实现思路
实现一个防抖函数,作为包装器
接收两个参数:
实际要执行的函数(回调)
要延迟的时间限制
利用延时定时器,创造异步执行
如果已有定时器,则清空定时器
设置定时器,到执行时,清空当前定时器重新定时
返回包装后的响应函数
代码
Talk is cheap,show you the code
/**
* 简单的`debounce`函数实现
*
* @param {function} cb 要执行的回调函数
* @param {number} delay 要等待的防抖延迟时间
* @returns {function}
*/
const debounce = function(cb, delay) {
// 参数检查
// cb:function
// delay:number
if (!cb || toString.call(cb) !== '[object Function]') {
throw new Error(`${cb} is not a function.`)
}
// 没有传 delay 参数时(包括等于0)
if (!delay) {
delay = 500 // 设置默认延时
}
// delay 参数须为正整数
else if (!Number.isInteger(delay) || delay < 0) {
throw new Error(`${delay} is invalid as optional parameter [delay]`)
}
// 定时器
let timer = null
// 返回debounce包装过的执行函数
return function(...args) {
// 如果存在定时器
if (timer) {
// 清除定时器,
// 即:忽略之前的触发
clearTimeout(timer)
}
// 设置定时器
timer = setTimeout(() => {
// 当到了设定的时间:
// 清除本次定时器,
// 并执行函数
clearTimeout(timer)
timer = null
cb.call(null, ...args)
}, delay);
}
}
防抖典型的应用场景:
输入框的提示或搜索功能
如果随着输入实时检索,
将白费很多次请求;
这样利用防抖函数,可以设定500毫秒的延迟,当用户输入时不进行实时检索,超过500毫秒没有输入(停顿了)时,再发起检索请求,减少无谓的请求数量,节省网络和服务器资源。
放个窗口滚动的防抖示例,如下:
创建一个debounce_sample.html
,
用 chrome 浏览器打开,甩起你的滚动轮并查看 console,自己感受一下。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>debounce sample</title>
</head>
<body>
<h3>
<ul>
<li>打开console</li>
<li>快速滚动鼠标滚动轮</li>
</ul>
</h3>
<div style="height: 1500px;"></div>
<script>
/**
* 简单的`debounce`函数实现
*
* @param {function} cb 要执行的回调函数
* @param {number} delay 要等待的防抖延迟时间
* @returns {function}
*/
const debounce = function (cb, delay) {
// ...此处省略以节省篇幅...
// ...函数内容请参照上文...
}
// scroll in debounce
const realEventHandler = function() {
console.log('scroll in debounce')
}
const debouncedScrollHandler = debounce(realEventHandler, 500)
window.addEventListener('scroll', debouncedScrollHandler)
</script>
</body>
</html>
节流
什么是节流
场景2
[第一天]
二仁:靶鼻,我好想吃冰淇淋,
给我买一个好吗?
我 :好吧,那就买一个,别告诉你哥。
[第二天]
二仁:靶鼻,我今天还想吃冰淇淋,
再给我买一个好吗?
我 :想啥呢二仁。。。
哪能天天吃?!!
咱家经济状况你又不是不知道,
再加上你出生后,更揭不开锅了,
你也知道,
你现在喝的一段奶粉是最贵的,
你哥幼儿园一个月两千多,
你爸我一个月才几千工资啊,
还得养活全家,还房贷交房租少吗,
balabala。。。
(总之没买)
二仁 :好吧。。。
[第三天]
二仁:靶鼻,我有个小心愿,
不是当讲不当讲。。。
我 :说来听听
二仁:就昨天那事儿,
我还是想吃冰淇淋,
再给我买一个好吗?
我 :看你可怜的样,今天我就答应你。
回去可得让我尝尝,另外,
悄悄地吃别让你哥看见。
[第四天]
二仁:靶鼻,我想吃冰淇淋。。。
我 :不行。
昨天刚吃了。
明天再说。
这就是【节流】,
一定时间内,只响应一次请求。
经过该给定时间间隔之后,才能再次响应。
节流的JS实现
实现思路
为了更方便理解,我们可以参考游戏里放大招,眼看敌人残血了,把握好机会立马放大准备收人头,说正事儿,放完大招,要等一个冷却时间过了,才能再次使用。
节流throttle
看起来也是这么回事儿,请看下面的代码实现(包括注释):
代码
/**
* `throttle`简单的节流函数实现
*
* @param {function} cb 要执行的回调函数
* @param {number} wait 要设置的节流时间间隔
* @returns {function}
*/
const throttle = function (cb, wait) {
// 参数检查
// cb:function
// wait:number
if (!cb || toString.call(cb) !== '[object Function]') {
throw new Error(`${cb} is not a function.`)
}
// 没有传 wait 参数时(包括等于0)
if (!wait) {
wait = 500 // 设置默认延时
}
// wait 参数须为正整数
else if (!Number.isInteger(wait) || wait < 0) {
throw new Error(`${wait} is invalid as optional parameter [wait]`)
}
// 用来记录上次执行的时刻
let lasttime = Date.now()
return function (...args) {
const now = Date.now()
// 两次执行的时间间隔
const timespan = now - lasttime
// 当间隔小于等待时间即处于冷却中
const isCoolingDown = timespan < wait
console.log(timespan, isCoolingDown ? 'is cooling down' : 'execute')
// 如果还没冷却好,就等待
if (isCoolingDown) return
// 记录本次执行的时刻
lasttime = Date.now()
// 冷却好了
cb.apply(null, args)
}
}
节流用在resize或者鼠标拖动之类的事件上是合适的,因为如果没有节流,体验会变得很糟糕。
下面我们创建一个throttle_sample.html
来体验一下效果。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>throttle sample</title>
</head>
<body>
<h3>
<ul>
<li>打开console</li>
<li>快速滚动鼠标滚动轮</li>
</ul>
</h3>
<div style="height: 1500px;"></div>
<script src="throttle.js"></script>
<script>
const realEventHandler = function() {
console.log('scroll in throttle')
}
const scrollHandler = throttle(realEventHandler, 2000) // 两秒钟内只响应一次(打印一次log)
window.addEventListener('scroll', scrollHandler)
</script>
</body>
</html>
防抖+节流
如果你亲自体验了上面防抖的示例,可能会发现这样一个问题:
当我一直滚动鼠标滚动轮不松手时,那就一直不会触发事件,耗一年也不会。
这,是不是问题?
在实际场景中,这,确实是个问题。
解决场景1存在的问题
场景1问题
我 :大宝,爸爸今天升级加薪
迎娶白富 美了,给你买好吃的,
想吃点什么水果,选一个?
大宝:我想吃,西瓜,
不对,是哈密瓜,
不对,是樱桃,
不对,是葡萄,
不对,是橙子,
不对,是香蕉,
不对,是芒果,
不对,是荔枝,
。。。
(一个小时过去了)
。。。
不对,是草莓,
我 :先停,谁家爸爸这么有耐心,
都听你你说一个小时了,
就按最后一个也就是第1024个:
【草莓】,这就去买。
解决办法:防抖 + 节流
这就是【防抖 + 节流】,
在规定的时间内,
只响应最后一次请求,之前的都忽略
只响应最后一次请求,
规定的时间内,必须要响应一次
说人话:
防抖:只响应最后一次请求
节流:单位时间间隔内只响应一次
–> 只响应单位间隔期间里最后一次请求
实现一个【防抖 + 节流】
这里有一点不太好理解的地方,
就是需要两个定时器,
分别记录 防抖/节流。
如果没有节流函数定时器,且超出规定的时间间隔,说明用户操作没有中断,此时需要强制执行一次回调函数响应
其他情况,则按照防抖函数处理
注意执行回调函数响应时,两个定时器都要清空
/**
* 防抖+节流的组合实现
*
* @param {function} cb 要执行的回调函数
* @param {number} wait 要设置的防抖节流时间
* @returns {function}
*
* 思路:
* 1. 第一次触发或者一直触发,考虑 throttle,定时响应一次(强制响应)
* 2. 如果没有一直触发,则使用 debounce,响应最后一次请求
*/
const combinedDebounceThrottle = function(cb, wait) {
// TODO:参数检查
// cb:function
// wait:number
// 略(参照 debounce.js 或 throttle.js 部分)
let lasttime = 0
let timerDebounce = null
let timerThrottle = null
// 执行一次回调响应的处理部分
function executeCb(...args) {
clearTimeout(timerDebounce)
clearTimeout(timerThrottle)
timerDebounce = null
timerThrottle = null
lasttime = Date.now()
cb.apply(null, args)
}
return function(...args) {
const now = Date.now()
const timespan = now - lasttime
const isCoolingDown = timespan < wait
clearTimeout(timerDebounce)
// 如果一直 debounce 而没有执行响应,
// 且,超过冷却时间,则强制执行一次
if (!timerThrottle && !isCoolingDown) {
timerThrottle = setTimeout(executeCb, wait, ...args)
}
// 如果不是一直触发,则在延迟时间后做一次响应(使用debounce)
else {
timerDebounce = setTimeout(executeCb, wait, ...args)
}
}
}
还是按照惯例,创建一个combined_sample.html
文件体验效果:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>throttle sample</title>
</head>
<body>
<h3>
<ul>
<li>打开console</li>
<li>快速滚动鼠标滚动轮</li>
</ul>
</h3>
<div style="height: 1500px;"></div>
<script src="combinedDebounceThrottle.js"></script>
<script>
const realEventHandler = function(e) {
console.log('scroll in debounce-throttle combined mode', e)
}
const scrollHandler = combinedDebounceThrottle(realEventHandler, 2000) // 两秒钟内只响应一次(打印一次log)
window.addEventListener('scroll', scrollHandler)
</script>
</body>
</html>
可以看到,即便一直滚动,也会在2秒后打印出控制台信息。
这样就实现了防抖+节流的组合模式。
白话了半天,读者朋友们是不是都理解了?
其他补充
underscore.js
中为我们提供了功能更为丰富的防抖与节流函数。
是不是在想:你怎么不早说?!
实现得越复杂就越不容易理解嘛~现在看了我的讲解,再去看 underscore.js 库里封装的优秀方法,也就能更好地理解了。
underscore 里的防抖与节流,为我们提供了更为丰富的配置选项和自定义的可能,能够应对更多的业务场景,下面就来探究一下 underscore 中是怎样的吧。
防抖debounce
_.debounce(func, wait, [immediate])
参数 | 说明 |
---|---|
function | 处理函数 |
wait | 指定的毫秒数间隔 |
immediate | 立即执行Flag 可选。 默认:false |
功能解析:
前两个参数与前面介绍的throttle
是一样的,第三个参数,
immediate
指定第一次触发或没有等待中的时候可以立即执行。
知道了原理,我们来简单写一下代码实现。
// debounce 防抖:
// 用户停止输入&wait毫秒后,响应,
// 或 immediate = true 时,没有等待的回调的话立即执行
// 立即执行并不影响去设定时器延迟执行
_.debounce = function(func, wait, immediate){
var timer, result
var later = function(...args){
clearTimeout(timer)
timer = null
result = func.apply(null, args)
}
return function(...args){
// 因为防抖是响应最新一次操作,所以清空之前的定时器
if(timer) clearTimeout(timer)
// 如果配置了 immediate = true
if(immediate){
// 没有定时函数等待执行才可以立即执行
var callNow = !timer
// 是否立即执行,并不影响设定时器的延迟执行
timer = setTimeout(later, wait, ...args)
if(callNow){
result = func.apply(null, args)
}
}
else{
timer = setTimeout(later, wait, ...args)
}
return result
}
}
节流throttle
_.throttle(function, wait, [options])
参数 | 说明 |
---|---|
function | 处理函数 |
wait | 指定的毫秒数间隔 |
options | 配置 |
可选。 默认: { leading: false, trailing: false } |
针对第一次触发,
leading : true 相当于先执行,再等待wait
毫秒之后才可再次触发
trailing : true 相当于先等待wait
毫秒,后执行
默认:
leading : false => 阻止第一次触发时立即执行,等待wait
毫秒才可触发
trailing : false => 阻止第一次触发时的延迟执行,经过延迟的wait
毫秒之后才可触发
可能的配置方式:
(区别在首次执行和先执行还是先等待)
配置 | 结果 |
---|---|
{ |
第一次触发不执行,后面同普通throttle ,执行 + 间隔wait 毫秒 |
{ leading: true, trailing: false } |
第一次触发立即执行,后面同普通throttle ,执行 + 间隔wait 毫秒 |
{ leading: false, trailing: true } |
每次触发延迟执行,每次执行间隔wait 毫秒 |
{ leading: true, trailing: true } |
每一次有效触发都会执行两次,先立即执行一次,后延时wait 毫秒执行一次 |
知道了原理,我们来简单写一下代码实现。
_.now = Date.now
_.throttle = function(func, wait, options){
var lastTime = 0
var timeOut = null
var result
if(!options){
options = { leading: false, trailing: false }
}
return function(...args){ // 节流函数
var now = _.now()
// 首次执行看是否配置了 leading = false = 默认,阻止立即执行
if(!lastTime && options.leading === false){
lastTime = now
}
// 配置了 leading = true 时,初始值 lastTime = 0,即可以立即执行
var remaining = lastTime + wait - now
// > 0 即间隔内
// < 0 即超出间隔时间
// 超出间隔时间,或首次的立即执行
if(remaining <= 0){ // trailing=false
if(timeOut){
// 如果不是首次执行的情况,需要清空定时器
clearTimeout(timeOut)
timeOut = null
}
lastTime = now // #
result = func.apply(null, args)
}
else if(!timeOut && options.trailing !== false){ // leading
// 没超出间隔时间,但配置了 leading=fasle 阻止了立即执行,
// 即需要执行一次却还未执行,等待中,且配置了 trailing=true
// 那就要在剩余等待毫秒时间后触发
timeOut = setTimeout(()=>{
lastTime = options.leading === false ? 0 : _.now() // # !lastTime 的判断中需要此处重置为0
timeOut = null
result = func.apply(null, args)
}, remaining);
}
return result
}
}
除了上文介绍的配置,还加入了可取消功能(cancel)(from 1.9.0)
小结
throttle
和debounce
是解决请求和响应速度不匹配问题的两个方案
二者的差异在于选择不同的策略
debounce
的关注点是空闲的间隔时间,
throttle
的关注点是连续的执行间隔时间。
应用场景
游戏设计,keydown
事件
文本输入、自动完成,keyup
事件
鼠标移动mousemove
事件
DOM
元素动态定位,window
对象的resize
、scroll
事件
总结比较
对于我们最开始的简单防抖/节流实现
相同
debounce
防抖与throttle
节流都实现了单位时间内,函数只执行一次
不同
debounce
防抖:
单位时间内,忽略前面的,响应最新的,并在延迟wait
毫秒后执行
throttle
节流:
响应第一次的,单位时间内,不再响应,直到wait
毫秒后才再响应
以上。再说不懂防抖节流算我输。
最后,感谢您的阅读和支持~