前言
在日常开发或者面试中,防抖与节流应该都是属于高频出现的点。这篇文章主要是基于冴羽(后续用他代称)大神的两篇文章 防抖 与 节流来写的。因为自己在看他文章的时候也对其中的代码产生了一些困惑,有一些卡住的地方,所以想把自己遇到的问题都抛出来,一步步的去理解。 文中具体的场景demo以他的为例,就不单独在举场景例子了。
防抖与节流的定义
- 防抖:事件持续触发,但只有当事件停止触发后n秒才执行函数。
- 节流:事件持续触发时,每n秒执行一次函数。
防抖
持续触发事件不执行,等到事件停止触发后n秒才去执行函数。
// 第一版
const debounce = function(func, delay) {
let timeout;
return function () {
const context = this;
const args = arguments;
clearTimeout(timeout)
timeout = setTimeout(() => {
func.apply(context, args)
}, delay);
}
}
第一版没什么难点,当用户持续触发就一直清除计时器,当他最后一次触发后,会生成一个计时器,同时计时器中的方法将在delay
秒执行。
新增需求:不等到事件停止触发后才执行,希望立即执行函数。然后等到停止触发n秒后,才重新触发执行。
先来拆分需求:
- 立即执行函数
- 停止触发n秒后,才重新触发
立即执行函数很容易实现func.apply(context, args)
即可。但是不可能当用户持续触发的时候一直去调用func
这个函数,所以这里想到需要一个字段来判断何时能够去执行func
函数。
// 第二版
const debounce = function (func, delay) {
let timer,
callNow = true; // 是否立即执行函数的标识
return function () {
const context = this;
const args = arguments;
if (timer) clearTimeout(timer);
if(callNow) {
func.apply(context, args); // 触发事件立即执行
callNow = false; // 将标识设置为false,保证后续在delay秒内触发事件都无法执行函数。
} else {
timer = setTimeout(() => {
callNow = true; // 过delay秒后才能再次触发函数执行。
}, delay)
}
}
}
新增需求:加个immediate
参数来判断是否立刻执行。
其实通过上面那个简化版,这次加个参数字段来区分就很好实现了。
const debounce2 = function (func, delay, immediate = false) {
let timer,
callNow = true;
return function () {
const context = this;
const args = arguments;
if (timer) clearTimeout(timer);
if (immediate) {
if(callNow) func.apply(context, args); // 触发事件立即执行
callNow = false;
timer = setTimeout(() => {
callNow = true; // 过n秒后才能再次触发函数执行。
}, delay)
} else {
timer = setTimeout(() => {
func.apply(context, args);
}, delay)
}
}
}
返回值
getUserAction
函数可能是有返回值的,所以这里也需要返回函数的结果。但当immediate
为false
的时候,因为setTimeout
的缘故,在最后return
的时候值会一直是undefined
。所以只在immediate
为true
的时候返回函数的执行结果。
const getUserAction = function(e) {
this.innerHTML = count++;
return 'Function Value';
}
const debounce = function (func, delay, immediate = false) {
let timer,
result,
callNow = true;
return function () {
const context = this;
const args = arguments;
if (timer) clearTimeout(timer);
if (immediate) {
if(callNow) result = func.apply(context, args);
callNow = false;
timer = setTimeout(() => {
callNow = true; // 过n秒后才能再次触发函数执行。
}, delay)
} else {
timer = setTimeout(() => {
func.apply(context, args);
}, delay)
}
return result;
}
}
// demo test
const setUseAction = debounce(getUserAction, 2000, true);
// 展示函数返回值
box.addEventListener('mousemove', function (e) {
const result = setUseAction.call(this, e);
console.log('result', result);
})
取消
希望能够取消
debounce
函数,可以让用户执行此方法(cancel)后,取消防抖,当用户再次去触发时,就可以又立刻执行了。
需求思考:取消防抖,其实说白了就是清除掉之前存在的计时器。这样当用户再次触发的时候就能立刻执行函数啦。嘿嘿😝是不是很简单啊!
const debounce = function (func, delay, immediate = false) {
let timer,
result,
callNow = true;
const debounced = function () {
const context = this;
const args = arguments;
if (timer) clearTimeout(timer);
if (immediate) {
if(callNow) result = func.apply(context, args);
callNow = false;
timer = setTimeout(() => {
callNow = true; // 过n秒后才能再次触发函数执行。
}, delay)
} else {
timer = setTimeout(() => {
func.apply(context, args);
}, delay)
}
return result;
};
debounced.cancel = function(){
clearTimeout(timer);
timer = null;
}
}
经过这样的一系列拆分是不是顿时觉得防抖也就那么回事嘛,并没有多难~
节流
节流的两种主流实现方式:1.时间戳; 2.设置定时器。
时间戳
触发事件时,取出当前的时间戳,然后减去之前的时间戳(最开始设置为0)。若大于设置的时间周期,则执行函数,同时更新时间戳为当前的时间戳。若小于,则不执行。
const throttle = function(func, delay) {
let prev = 0; // 将初始的时间戳设为0,保证第一次触发就一定执行函数
return function(){
const context = this;
const args = arguments;
const now = +new Date();
if (now - prev > delay) {
func.apply(context, args);
prev = now;
}
}
}
存在的问题
每过delay
秒会执行一次函数,但是当最后一次触发的时间少于delay
,则now - prev < delay
,导致最后一次触发并没有执行函数。
定时器
触发事件时,设置一个定时器。当再次触发事件时,若定时器存在就不执行;直到定时器内部方法执行完,然后清空定时器,设置下一个定时器。
const throttle = function(func, delay){
let timer;
return function(){
const context = this;
const args = arguments;
if (!timer) {
timer = setTimeout(() => {
timer = null; // delay秒重置timer值为null,为了重新设置一个新的定时器。
func.apply(context, args);
}, delay);
}
}
}
存在的问题
当首次触发事件的时候不会执行函数。
双剑合璧
这版要实现两个需求:
- 首次触发事件立即执行
- 停止触发事件后依然再执行一次事件
这里先贴下他的代码。
说实话刚看到这段代码的时候我自己也是懵的,后面仔细思考了一会儿才完全想通。这边我将自己如何理解这段代码的思路写下来,帮助大家层层实现这个需求。
先看第二个需求(停止触发事件后依然再执行一次事件),其实说白了就是延迟执行事件,此时我就会先想到这块要用上setTimeout
。但是有一个问题在于setTimeout
的第二个参数延迟多少秒后触发呢?假设每3s执行一次函数,执行了3次,我在第9.5的时候停止触发事件。那么后续将要过多少秒才能执行这最后一次触发对应的事件呢?(12 - 9.5 = 2.5s)
// 伪代码片段如下
const throttle1 = function(func, delay){
let timer,
prev = 0;
return function(){
const context = this;
const args = arguments;
const now = +new Date();
const remaining = delay - (now - prev); // 关键点:剩余时间
// 设置!timer条件是为了防止在已有定时器的情况下,再次触发事件又去生成一个新的定时器。
if (remaining > 0 && !timer) {
timer = setTimeout(() => {
prev = +new Date();
timer = null;
func.apply(context, args);
}, remaining)
}
}
}
再来看第一个需求(首次触发事件立即执行),想要首次触发只需要将prev
设为0,这样就能确保在第一次的时候delay - (now - prev)
的值一定是小于0的。
// 伪代码片段如下
const throttle2 = function(func, delay){
let timer,
prev = 0;
return function(){
const context = this;
const args = arguments;
const now = +new Date();
const remaining = delay - (now - prev); // 关键点:下次触发 func 剩余时间
// 设置!timer条件是为了在已有定时器的情况下,再次触发事件又去新生成了一个定时器。
if (remaining <= 0) {
// 这段代码的实际意义?
if (timer) {
clearTimeout(timer);
timer = null;
}
prev = now;
func.apply(context, args);
}
}
}
完整版本
const throttle = function(func, delay) {
let timer,
prev = 0;
return function(){
const context = this;
const args = arguments;
const now = +new Date();
const remaining = delay - (now - prev);
if (remaining <= 0) {
prev = now;
func.apply(context, args);
} else if(!timer) {
timer = setTimeout(() => {
prev = +new Date();
timer = null;
func.apply(context, args);
}, remaining)
}
}
}
现在基于上面两段代码来模拟操作下(假设delay值为3):
- 首次触发:
remaining
值小于0,直接执行func
函数同时更新prev
的值(prev = now
)。 - 过1s后触发:
remaining
值为2且timer
值为undefined
。此时会设置一个定时器(2s后执行),定时器中的代码将会在2s后执行(更新prev
值;执行func
函数;重置timer
的值)。 - 过2s后触发:
remaining
值为1且timer
有值,此时不会走进任何分支,即不会发生任何事情。 - 过3s后触发:
remaining
值为0且timer
值为null,此时更新prev
的值,将timer
设置为null且执行func
函数。 - 过4s后触发:
remaining
值为1且timer
值为null,这个时候又会重复上面 过1s后触发 的步骤,生成一个新的定时器,定时器中的代码将在2s后执行。 - 过9.2s后触发(停止触发后还能再执行一次):
remaining
值为2.8且timer
值为null,生成一个新的定时器,并且定时器中的代码将在2.8s后执行。
不知道大家会不会有这样的疑问,我9.2s时停止触发了,然后我10s的时候又再次触发那会不会多产生新的定时器呢? 其实这个操作和上面的第二步与第三步类似,当10s再次触发的时候,虽然remaining
的值为2,但是此时timer
是有值的,所以并不会进入任何一条分支,即不会发生任何事。
不知道经过我这一拆分讲解,各位观众老爷有没有对上面截图的代码更清晰了一点呢😊?
优化版本
有时候希望无头有尾或者有尾无头。通过设置options作为第三个参数,然后根据传的值进行判断想要的效果。leading:false 表示禁用第一次执行; trailing:false 表示禁用停止触发的回调。
老规矩先看下他的代码,当初刚看这版代码的时候我产生了如下几点疑问:。
- 为什么
later
函数中,不直接写previous = new Date().getTime()
,而写成previous =options.leading === false ? 0 : new Date().getTime()
呢?; - 为什么要有
if (!timeout) context = args = null
这段代码呢? - 下面这段代码的意义?可能会走到这里吗?
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
先将需求拆分下,先来看看设置leading = false
如何实现禁用第一次执行的。这里可以想到导致首次触发就执行的关键就在于remaining
的值小于0,那么其实只要想办法在首次触发的时候保证remaining
的值大于0就好啦!(将prev的初始值设置等于now的值即可)
const throttle = function(func, delay, option = {}) {
let timer,
prev = 0;
return function(){
const context = this;
const args = arguments;
const now = +new Date();
// 首次触发时将prev值设置等于now值,禁止首次触发执行函数
if (!prev && option.leading === false) {
prev = now; // 确保首次触发时remaining的值大于0.
}
const remaining = delay - (now - prev);
if (remaining <= 0) {
prev = now;
func.apply(context, args);
} else if(!timer) {
timer = setTimeout(() => {
prev = option.leading === false ? 0 : +new Date(); // 这里为什么这样做,下面会解释到。
timer = null;
func.apply(context, args);
}, remaining)
}
}
}
再看trailing = false
是如何禁用停止触发的回调。同样思考下导致停止触发后还会再一次执行的原因在哪?其实就在于remaining
的值是大于0,当它大于0时,就会去产生一个计时器,从而导致就算停止了触发仍然能在remaining
秒后执行函数。所以只需要在产生计时器代码的条件判断上加上option.trailing !== false
就可以禁止停止触发的回调啦。
const throttle = function(func, delay, option = {}) {
let timer,
prev = 0;
return function(){
const context = this;
const args = arguments;
const now = +new Date();
if (!prev && option.leading === false) {
prev = now;
}
const remaining = delay - (now - prev);
if (remaining <= 0) {
prev = now;
func.apply(context, args);
// 当option.trailing值被设置为false时,永远走不进这条分支,也就不会产生计时器。
} else if(!timer && option.trailing !== false) {
timer = setTimeout(() => {
prev = option.leading === false ? 0 : +new Date();
timer = null;
func.apply(context, args);
}, remaining)
}
}
}
解释疑问1
为什么要将prev = option.leading === false ? 0 : +new Date()
,而不是prev = +new Date()
。其实关键点在于当prev = 0
时,触发事件时就一定会执行if(!pre && option.leading === false) prev = now
这段代码,进而能够确保remaining
的值恒大于0,即用户不管下一次是什么时候再次触发事件时,都能保证代码走到else if
这条分支。举个场景解释下(delay为3s)~
- 用户首次触发滑动事件,
remaining
值大于0,所以会产生一个定时器且3秒后执行定时器内部代码。 - 此时假设用户并没有持续3s都在触发事件,而是在第2s的时候就离开了可滑动的区域,再过1s后,计时器中的对应函数仍会照常执行。这时分水岭就出来了,若直接将
prev = +new Date()
,同时假设用户过了10s后再次去触发事件,因为现在prev
有值,且deay - (now - prev)
少于0(因为这时now-prev的值为10,大于3),所以会走入if(remaining <= 0)
分支,这个时候就会立即执行func
函数。这样就不符合需求所说的首次触发(注意这里的首次触发并不只是指第一次触发,如果后续离开了触发区域,过段时间再去触发,也还是被当作了首次触发。这个点一定要明白)不执行函数啦。 - 再来看看
prev = option.leading === false ? 0 : +new Date()
,过10s后prev
的值早已经为0,这时用户再次去触发事件,会执行prev = now
这段代码,所以此时能确保remaining
的值大于0,这样就能够保证用户再次首次触发事件时不会执行函数啦。而是生成一个定时器,3s后执行定时器中的方法。
解释疑问2
将context = args = null
主要是为了释放内存,因为JavaScript
有自动垃圾收集机制,会找出那些不再继续使用的值,然后释放掉其占用的内存。垃圾收集器每隔固定的时间段就会执行一次释放操作。
解释疑问3
其实这一点我到现在也不是很确定。个人猜想这样做是为了防止定时器中的代码timeout = null
并没有在指定时间内立刻执行(即timeout仍有值),感觉这段代码就是处理这种极端状况下的,能够确保timeout
的值一定会被置为null。
结语
以上就是我对于防抖与节流的理解。接下来会出一篇 防抖与节流实战篇。 希望大家能在评论区中一起讨论起来,有任何好的idea也可以抛出来哦😬~