携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情
防抖
前端开发中,面对一些高频操作的场景,比如:
- window 的 resize、scroll
- mousedown、mousemove
- keyup、keydown
从开始操作到结束就会产生大量的计算,从而带来不必要的开销
又或者请求接口的操作,每点击一次就会触发一次ajax请求,对性能来说速第一种浪费,甚至会带来一些意象不到的bug。
为了解决这个问题,一般有两种实现:
- debounce 防抖
- throttle 节流
本节讲解防抖函数的实现
原理:
防抖的原理是:一段时间内多次触发事件,只会执行一次,执行第一次或最后一次
只执行最后一次
根据上述描述,先实现一个简单版本
function debounce(fn, wait){
let timer
wait = wait || 300
return function (){
timer && timer = null
timer = setTimeout(fn, wait)
}
}
代码中使用
<body>
<p id="p">1</p>
<button id="btn">按钮</button>
</body>
<script>
let count = 1
const p = document.querySelector('#p')
const btn = document.querySelector('#btn')
function add(){
p.innerHTML = count++
}
btn.addEventListener('click', debounce(add, 300))
</script>
此时,300ms内,不管点击多少次按钮,永远只会触发一次
this绑定
如果我们在add函数中console.log(this), 在不使用debounce函数的情况下,this会指向触发事件的按钮👇🏻
<button id="btn">按钮</button>
但是使用了debounce函数后,this会指向全局对象window。
因此我们需要将this绑定找回来
修改代码如下:
function debounce(fn, wait){
let timer
wait = wait || 300
return function (){
let context = this
timer && timer = null
timer = setTimeout(function(){
fu.apply(context)
}, wait);
}
}
此时,this就会指向触发事件的对象
event对象
JavaScript 在事件处理函数中会提供事件对象 event,在使用了debounce函数之后,我们打印event对象会变成undefined, 因为我们在debounce函数中没有将 event对象传递下去,导致找不到了
再来修改下代码
function debounce(fn, wait){
let timer
wait = wait || 300
return function (){
const context = this
const args = arguments
timer && timer = null
timer = setTimeout(function(){
fu.apply(context,args)
}, wait);
}
}
至此, 我们为debounce返回的函数绑定了this, 同时找回了event对象, 但是仍存在一个问题,如果我们的事件处理函数fn传递了参数,才如何执行呢?
fn传递参数
向下面这样👇🏻
<body>
<p id="p">1</p>
<button id="btn">按钮</button>
</body>
<script>
let count = 1
const p = document.querySelector('#p')
const btn = document.querySelector('#btn')
function add(payload){
p.innerHTML = count + payload
}
btn.addEventListener('click', debounce(add(12), 300))
</script>
直接这样写,会得到报错
debounce_.js:36 Uncaught TypeError: Cannot read properties of undefined (reading 'apply')
at HTMLButtonElement.proxy
大意是,用undefined调用了apply。
我们可以看到, add(12)是函数的返回结果, 而add函数并没有返回任何值,所以是undefined, 因此,修改一下调用方式,让add函数返回一个函数就好了
function add(payload){
return function (){
console.log(this)
//这里是闭包,可以访问到调用add(12)时传入的参数
console.log(payload)
// 这里含有event对象, debounce函数传来的
console.log(arguments)
p.innerHTML = count + payload
}
}
只执行第一次
我们知道,防抖函数可以只执行最后一次,也可以只执行第一次,上面实现了执行最后一次, 现在来实现一下执行第一次。
首先,给我们的debounce函数加一个参数 immediate, 表示是否立即执行
function debounce(fn, wait, immediate){
let timer;
wait = wait || 300
return function(){
let context = this
let args = arguments
timer && clearTimeout(timer)
//需要立即执行的
if(immediate){
//如果已经执行过,不再执行
let callNow = !timer
//如果timer一直有值,callNow就是false,就不会执行fn
timer = setTimeout(function(){
timer = null
}, wait)
if(callNow) fn.apply(context, args)
}else{ //不需要立即执行,就按最后一次执行来处理
timer = setTimeout(function(){
fn.apply(context, args)
}, wait)
}
}
}
取消防抖
最后又有一个小需求, 我希望能取消 debounce 函数,比如说我 debounce 的时间间隔是 10 秒钟,immediate 为 true,这样的话,我只有等 10 秒后才能重新触发事件,现在我希望有一个按钮,点击后,取消防抖,这样我再去触发,就可以又立刻执行啦,思考一下这个需求如何实现呢?
其实我们的防抖函数都是基于timer是否有值来实现的,如果timer有值,就不去触发。那么我们的取消函数,直接将timer置位null,fn就又可以跑起来了,最后改造一下代码
代码如下:
function debounce1(fn, wait, immediate){
let timer;
wait = wait || 300
function proxy (){
let context = this
let args = arguments
timer && clearTimeout(timer)
if(immediate){
let callNow = !timer
timer = setTimeout(function(){
timer = null
}, wait)
if(callNow) fn.apply(context, args)
}else{
timer = setTimeout(function(){
fn.apply(context, args)
}, wait)
}
}
proxy.cancel = function(){
clearTimeout(timer)
timer = null
}
return proxy
}
使用:
<body>
<p id="p">1</p>
<button id="btn">按钮</button>
<button id="btn_cancel">取消防抖</button>
</body>
<script>
let count = 1
const p = document.querySelector('#p')
const btn = document.querySelector('#btn')
const cancel = document.querySelector('#btn_cancel')
function add(payload){
return function (){
console.log(this)
console.log(payload)
console.log(arguments)
p.innerHTML = count++
}
}
let clickHandler = debounce1(add(12), 300,true)
btn.addEventListener('click', clickHandler)
cancel.addEventListener('click', () => {
clickHandler.cancel()
})
</script>
节流
节流也是为限制频繁触发事件而产生的,节流意味着尽管持续触发事件,但是会根据我们设定的wait,实现每隔一段时间,执行一次操作
原理:
节流同样有两种实现效果:
- 首次是否执行
- 结束后是否执行最后一次
示例:
比如说我们监听body的scroll事件
document.addEventListener('scroll',function(){
console.log('scrolll')
})
函数执行情况:
首次执行
要让事件首次触发就会执行,我们会使用时间戳,具体步骤如下:
- 触发事件时,取出当前的时间戳,减去之前的时间戳(一开始为0)
- 如果差值 大于 设置的时间周期,就执行函数,同时更新时间戳
- 如果 小于,就不执行
上代码:
function throttle(fn, wait){
wait = wait || 100
let context, args,
prev = 0
return function(){
let now = new Date().valueOf(),
context = this,
args = arguments
if(now - prev > wait){
console.log(now, 'now')
fn.apply(context, args)
prev = now
}
}
}
使用:
document.addEventListener('scroll',throttle(function(){
console.log('scrolll')
},1000))
此时函数执行情况:
我们设置的时间间隔为1S,可以看到此时函数执行的频率被大幅降低。
由于初始开始值为0,所以开始触发事件的时候,事件立即执行,并保持每1S执行一次的频率,如果我们在4.9S时停止触发,便不会有第五次触发
结束后仍执行最后一次
使用定时器
- 触发事件时,设置一个定时器
- 再次触发时,如果定时器存在就不执行
- 直到定时器执行,然后执行函数,并清空定时器
- 设置下一个定时器
代码如下:
function throttle(fn, wait){
wait = wait || 100
let timer
return function (){
let context = this,
args = arguments
if(!timer){
timer = setTimeout(function(){
timer = null
fn.apply(context, args)
}, wait)
}
}
}
解析:
同样是上一个例子中的监听scroll事件,假设我们设置的时间间隔为1S
使用定时器的执行逻辑是:
- 每过1S就向任务队列中放入一个setTimeout
- 首次触发,timer为undefined, 向任务队列放入setTimeout
- 1S 之内多次触发,timer不为空,什么都不会做
- 1S过后,setTimeout开始执行,将timer置为null,同时执行函数
- 下次进来,再次向任务队列放入setTimeout
- 此时,如果我们4.5S 后停止滚动,timer为null,依旧会向任务队列中放入SetTimeout,会执行最后一次
同时执行第一次与最后一次
第一次会立即执行,同时会触发最后一次
function throttle (fn,wait){
let context, args, timer,
prev = 0
function later(){
prev = new Date().valueOf()
timer = null
fn.apply(context, args)
}
const throttled = function (){
context = this,
args = arguments
let now = new Date().valueOf()
//下次触发fn剩余的时间
let remaining = wait - (now - prev)
// 1、 第一次触发肯定走进这里
//该执行了 || 用户修改了系统时间
if(remaining <= 0 || remaining > wait){
//如果还可以执行立即触发的动作,就清掉定时器
if(timer){
clearTimeout(timer)
timer = null
}
prev = now //更新prev
fn.apply(context, args)
}else if(!timer){
//不满足下一次触发时间,任务队列放入setTimeout,保证触发最后一次
timer = setTimeout(later, remaining)
}
}
return throttled
}
假设我们设定 Wait为 1000ms,那么该函数执行过程会是这样:
- 0s , 走进
if(remaining <= 0 || remaining > wait),立即执行一次,更新prev - 时间间隔在(0,1), 没到执行时间,向任务队列放入setTimeout,假使后续不再触发,保证最后一次执行
- 1s, 走进
if(remaining <= 0 || remaining > wait),立即执行一次,更新prev,同时有上次保存的定时器,为避免重复触发,清掉上次的定时器 - 循环执行1-3步,直到不再触发
\
可配置执行第一次或最后一次
为我们的函数添加一个参数options,其中包含两个参数 leading、trailing
- leading: false 表示禁用第一次触发
- trailing: fasle 表示禁用最后一次触发的回调
改造代码如下:
function throttle3(fn, wait, options) {
let timer, context, args
let prev = 0;
if (!options) options = {};
//只有触发最后一次回调时才会执行later
let later = function() {
prev = options.leading === false ? 0 : new Date().valueOf();
timer = null;
fn.apply(context, args);
if (!timer) context = args = null;
};
let throttled = function() {
let now = new Date().valueOf();
//不需要立即执行,只有第一次进来prev为0, !prev才成立。所以第一次进来就不会出现 remaining <= 0的情况
if (!prev && options.leading === false) prev = now;
let remaining = wait - (now - prev);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timer) {
cleartimer(timer);
timer = null;
}
prev = now;
fn.apply(context, args);
if (!timer) context = args = null;
} else if (!timer && options.trailing !== false) { //trailing:false表示不需要结束后的回调,因此不用向任务队列添加setTimeout
timer = settimer(later, remaining);
}
};
return throttled;
}
注意:
无法同时设置两个属性都为false
如果同时设置的话,比如当你开始滚动屏幕的时候,因为 trailing 设置为 false,停止触发的时候不会设置定时器,所以只要再过了设置的时间,再开始滚动的话,就会立刻执行,就违反了 leading: false,bug 就出来了,所以,这个 throttle 只有三种用法:
//同时执行第一次与最后一次回调
document.addEventListener('scroll',throttle(function(){
console.log('scrolll')
},3000))
//不执行第一次
document.addEventListener('scroll',throttle(function(){
console.log('scrolll')
},3000, {leading:false}))
//不执行最后一次回调
document.addEventListener('scroll',throttle(function(){
console.log('scrolll')
},3000, {trailing:false}))
总结:
总结下个人理解的防抖与节流的异同之处:
- 相同:
-
- 都可以用于处理需要频繁触发的事件
- 不同:
-
- 防抖:虽然像每n秒触发一次,但是n秒内多次触发的话,执行时间会向后推,最终结果是,停止触发后的n秒执行处理函数(对于第一次触发的情况,可以说是,n秒后才可以再次执行)
- 节流:持续触发的时候,会记录本次触发的时间戳与上次执行的时间戳,保证每n秒执行一次处理函数
参考文章: