JavaScript防抖节流函数
pino 人气:0最近在看红楼梦,看的诗词多了,时不时的也想来一句...
这几天刚看看到了underscore.js
的防抖和节流的部分,正好又去复习了这部分内容,于是又重新整理一下相关的知识点。
在开发中我们经常会遇到一些高频操作,比如:鼠标移动,滑动窗口,键盘输入等等,节流和防抖就是对此类事件进行优化,降低触发的频率,以达到提高性能的目的。
可以看到短短的几秒钟,触发的事件的次数是非常惊人的。
防抖
简单来说防抖就是无论触发多少次事件,但是我一定在事件触发后 n 秒后才执行,也就是最后一次触发完毕 n 秒后才执行,如果在 n 秒前又触发了,那么以新的事件的时间为准,重新开始计算时间。
那么如何实现一个基本的防抖函数呢?
基本实现
根据防抖的原理可知,我们可以设置一个定时器,当每次触发事件但是没有到达设置的时间时,都会重新设置定时器。
const debounce = function(func, wait) { let timeout return function() { // 再次触发事件则删除上一个定时器,重新设置 clearTimeout(timeout) timeout = setTimeout(func, wait); } }
这样我们就写出了一个最基本版的防抖函数。可以看到触发次数已经大大降低。
this & arguments
尽管上面已经实现了一个基本的防抖函数,但是依然是不完善的,比如在setTimeout
中的this
指向是无法正确的获取的,setTimeout
中的this
指向 Window
对象!
我们可以在执行定时器之前进行重置this
:
const debounce = function(func, wait) { let timeout return function() { // 保存this let context = this // 新增 clearTimeout(timeout) timeout = setTimeout(function() { func.apply(context) // 新增 }, wait); } }
再比如我们如何在自定义的函数进行传参呢,如果我们想在func
函数中传递event
对象,目前的实现显然是无法正确进行获取参数的,再来修改一下:
const debounce = function(func, wait) { let timeout return function() { let context = this // 新增 // 保存参数 let args = arguments // 新增 clearTimeout(timeout) timeout = setTimeout(function() { func.apply(context, args) // 修改 }, wait); } }
至此一个基本的防抖函数就已经实现了,这个函数已经很是非常完善了。
立即执行
接下来再增加一个功能,如果我们不希望非要等到事件停止触发后才执行,希望立刻执行函数,然后等到停止触发 n 秒后,才重新触发执行。
那么这个功能怎么做呢,其实可以这样想,我们可以传入一个参数immediate
,代表是否想要立即执行,如果传递了immediate
,则立即执行一次函数,然后设置一个定时器,时间截止后将定时器设置为null
,下次进入函数时先判断定时器是否为null
,然后决定是否再次执行。
const debounce = function(func, wait, immediate) { let res, timeout, context, args; const debounced = function() { context = this args = arguments // 如果已经设置了setTimeout,则重新进行设置 if(timeout) clearTimeout(timeout) // 判断是否为立即执行 if(immediate) { let runNow = !timeout // 设置定时器,指定时间后设置为null timeout = setTimeout(function() { timeout = null }, wait) // 如果timeout已经为null(已到期),则执行函数 // 保存执行结果,用于函数返回 if(runNow) res = func.apply(context, args) } else { // 如果没有设置立即执行,则设置定时器 timeout = setTimeout(function() { func.apply(context, args) }, wait) } return res } return debounced }
其实上面的实现是两种完全不同的触发方式,先来看一下流程图:
黑色箭头为触发动作,红色箭头为执行动作。
非立即执行
立即执行
来看一下执行流程: 首先如果immediate
为true的情况:
第一次执行:timeout
为null
,则runNow
为true
,然后设置一个定时器,在指定的时间后设置timeout
为null
,这也就代表设置执行的间隔时间,最后判断runNow
是否执行函数。
第二次执行:
- 情况一:已超过设置时间:如果第二次触发执行已经超过设置的时间,此时
timeout
已经被定时器设置为null
,那么进入debounced
函数后,runNow
为true
,重新设置定时器,然后执行函数。 - 情况二:未超过设置时间:因为没有超过设置时间,所以
timeout
并未被定时器设置为null
,那么runNow
为false
,由于timeout
的定时器已经被清除,所以重置定时器,不会执行函数。
再来看一下immediate
为false
的情况:
其实这种情况和我们之前设置的是一样的,没有超过设置时间,则重置定时器,定时器在到达指定时间后自动执行一次函数。
两者之间最大的区别是:立即执行的功能会在第一次触发函数的时候执行一次,下次触发如果已到达设置时间,则直接执行一次。而非立即执行的功能第一次触发函数时只会设置一个定时器,时间到达后自动执行,如果在设置时间内触发只会重置定时器,永远不会立即执行函数。
取消
再增加一个需求:如果想要取消debounce
函数怎么办,比如 debounce
的时间间隔是 10 秒钟,immediate
为 true
,这样只有等 10 秒后才能重新触发事件,如果有一个取消功能,点击后取消防抖,再去触发,就可以立刻执行了。
debounced.cancel = function() { // 删除定时器 clearTimeout(timeout); // 设置timeout为null timeout = null; };
只需要将定时器清除,设置timeout
为null
即可,因为如果immediate
为 true
会直接执行一次函数,然后重新设置定时器
完整实现
最后完整的防抖函数如下:
function debounce(func, wait, immediate) { let res, timeout, context, args; const debounced = function () { context = this; args = arguments; if (timeout) clearTimeout(timeout); if (immediate) { var runNow = !timeout; timeout = setTimeout(function(){ timeout = null; }, wait) if (runNow) res = func.apply(context, args) } else { timeout = setTimeout(function(){ func.apply(context, args) }, wait); } return res; }; debounced.cancel = function() { clearTimeout(timeout); timeout = null; }; return debounced; }
节流
节流也是用于减少触发执行的手段之一,但是思路和防抖是完全不一样的,
如果持续触发事件,每隔一段时间,只执行一次事件。也就是只按照设置的时间作为时间段,到达指定的时间后触发函数就会执行。没有到达指定的时间,无论如何触发函数都不会执行。
也就是没到点,无论你怎么撩,我都岿然不动
目前有两种实现方式:使用时间戳和设置定时器。
时间戳
当触发函数的时候,使用当前的时间戳与上一次触发函数所保存的时间戳相减,然后对比设置定时器的时间,决定是否执行函数。
const throttle = function(func, wait) { let previous = 0, context, args; return function() { context = this args = arguments // 获取当前时间戳 let now = +new Date() // 判断当前时间戳与上一次触发的时间差值是否大于等于指定时间 if((now - previous) >= wait) { func.apply(context, args) // 更新时间戳 previous = now } } }
值得注意的是:js中可以在某个元素前使用 '+' 号,这个操作是将该元素转换成Number
类型,如果转换失败,那么将得到 NaN
。
+new Date()
将会调用 Date.prototype
上的 valueOf()
方法,根据MDN,Date.prototype.value
方法等同于Date.prototype.getTime()
。
console.log(+new Date('2022-08-17')); console.log(new Date('2022-08-17').getTime()); console.log(new Date('2022-08-17').valueOf()); console.log(new Date('2022-08-17') * 1); // 结果都是相同的
设置定时器
设置定时器的实现思路是:在第一次触发时设置一个定时器,在指定时间之后设置变量为null
,下次触发函数判断变量是否为null
,来决定是否执行函数。
const throttle = function(func, wait) { let timeout, context, args; return function() { context = this args = arguments // 允许执行 if(!timeout) { // 设置定时器,到达时间后设置timeout为null timeout = setTimeout(function() { timeout = null func.apply(context, args) }, wait) } } }
以上两种方式均可以满足一个基本的节流函数的写法,但是两种写法还是有一定的区别的:
- 第一种事件会立刻执行,第二种事件会在 n 秒后第一次执行
- 第一种事件停止触发后不会再执行事件,第二种事件停止触发后依然会再执行一次事件
既然执行时的行为不同,那么有没有办法将两者结合呢?
两者结合
将两者结合起来是要实现一个既能开始时执行一次函数,又能结束时再执行一次函数!
思路是这样的:如果触发函数时没有到达指定时间,则设置定时器,如果已经到达设置的时间,则直接进行执行。
function throttle(func, wait) { let timeout, context, args, previous = 0; const later = function() { // 定时器执行时更新时间戳 previous = +new Date(); timeout = null; // 执行函数 func.apply(context, args) }; const throttled = function() { let now = +new Date(); //下次触发 func 剩余的时间 let remaining = wait - (now - previous); context = this; args = arguments; // 如果没有剩余的时间了或者更改了系统时间 if (remaining <= 0 || remaining > wait) { // 清空定时器及timeout if (timeout) { clearTimeout(timeout); timeout = null; } // 更新时间戳变量 previous = now; func.apply(context, args); } else if (!timeout) { // 处理还没有到达指定时间的触发行为 // 此处设置定时器时间要设置剩余的时间,与上文中防抖函数中有区别 timeout = setTimeout(later, remaining); } }; return throttled; }
还是依旧缕一下思路:
第一次触发 throttled
时,因为 previous
为 0 ,所以remaining <= 0
这个条件成立,执行func
函数,并且重置定时器及变量,最后将previous
跟更新为当前时间。
第二次触发:
- 未到达指定时间:如果没有到达指定时间,那么
remaining
为正数,所以不会进入remaining <= 0
这个执行语句,而是会设置定时器。不会执行函数。 - 到达指定时间:
remaining
为负数,执行函数,同第一次触发。
同样在定时器执行时,也会更新previous
和timeout
的值。
其实核心在于remaining
这个变量的运算。
控制执行时机
又又又来了一个需求,如果希望能够控制首次和末次要不要执行怎么办?
可以传递第三个参数:
leading:false
表示禁用第一次执行trailing: false
表示禁用停止触发的回调
function throttle(func, wait, options = {}) { //修改 let timeout, context, args, previous = 0; const later = function() { previous = options.leading === false ? 0 : +new Date(); //修改 timeout = null; func.apply(context, args); // 清空作用域及参数变量 if (!timeout) context = args = null; //修改 }; const throttled = function() { let now = +new Date(); // 如果是首次触发,并且设置首次不执行函数。那么将previous与now进行同步 // now 与 previous 相减不小于0,则不会执行函数 if (!previous && options.leading === false) previous = now; // 新增 let remaining = wait - (now - previous); context = this; args = arguments; if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout); timeout = null; } previous = now; func.apply(context, args); // 清空作用域及参数变量 if (!timeout) context = args = null; //修改 } else if (!timeout && options.trailing !== false) { // 修改 timeout = setTimeout(later, remaining); } }; return throttled; }
我们要注意的是实现中有这样一个问题:
那就是 leading:false
和 trailing: false
不能同时设置。因为如果同时设置,那么就是既不开始触发也不结束时触发,那么函数将不会正常执行。
其实核心还是关于时间戳的加减法,无非就是根据功能来设置时间戳而已。
取消
与防抖函数的取消功能基本相同,重置各个作用变量:
throttled.cancel = function() { clearTimeout(timeout); previous = 0; timeout = null; }
完整实现
function throttle(func, wait, options = {}) { let timeout, context, args, previous = 0; const later = function() { previous = options.leading === false ? 0 : +new Date(); timeout = null; func.apply(context, args); if (!timeout) context = args = null; }; const throttled = function() { let now = +new Date(); if (!previous && options.leading === false) previous = now; let remaining = wait - (now - previous); context = this; args = arguments; if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout); timeout = null; } previous = now; func.apply(context, args); if (!timeout) context = args = null; } else if (!timeout && options.trailing !== false) { timeout = setTimeout(later, remaining); } throttled.cancel = function() { clearTimeout(timeout); previous = 0; timeout = null; } }; return throttled; }
这也是underscore.js
中节流的实现方式。
加载全部内容