JavaScript 专题之跟着 underscore 学节流
为什么要限制事件的频繁触发,以及如何做限制:
- debounce 防抖
- throttle 节流
今天重点讲讲节流的实现。
节流
节流的原理很简单:如果你持续触发事件,每隔一段时间,只执行一次事件。根据首次是否执行以及结束后是否执行,效果有所不同,实现的方式也有所不同。我们用 leading 代表首次是否执行,trailing 代表结束后是否再执行一次。
关于节流的实现,有两种主流的实现方式,一种是使用时间戳,一种是设置定时器。
使用时间戳
让我们来看第一种方法:使用时间戳,当触发事件的时候,我们取出当前的时间戳,然后减去之前的时间戳(最一开始值设为 0 ),如果大于设置的时间周期,就执行函数,然后更新时间戳为当前的时间戳,如果小于,就不执行。
看了这个表述,是不是感觉已经可以写出代码了…… 让我们来写第一版的代码:
// 第一版 function throttle(func, wait) { var context, args; var previous = 0; return function() { var now = +new Date(); context = this; args = arguments; if (now - previous > wait) { func.apply(context, args); previous = now; } } }
例子依然是用讲 debounce 中的例子,如果你要使用:
container.onmousemove = throttle(getUserAction, 1000);
效果演示如下:
我们可以看到:当鼠标移入的时候,事件立刻执行,每过 1s 会执行一次,如果在 4.2s 停止触发,以后不会再执行事件。
使用定时器
接下来,我们讲讲第二种实现方式,使用定时器。当触发事件的时候,我们设置一个定时器,再触发事件的时候,如果定时器存在,就不执行,直到定时器执行,然后执行函数,清空定时器,这样就可以设置下个定时器。
// 第二版 function throttle(func, wait) { var timeout; var previous = 0; return function() { context = this; args = arguments; if (!timeout) { timeout = setTimeout(function(){ timeout = null; func.apply(context, args) }, wait) } } }
为了让效果更加明显,我们设置 wait 的时间为 3s,效果演示如下:
我们可以看到:当鼠标移入的时候,事件不会立刻执行,晃了 3s 后终于执行了一次,此后每 3s 执行一次,当数字显示为 3 的时候,立刻移出鼠标,相当于大约 9.2s 的时候停止触发,但是依然会在第 12s 的时候执行一次事件。
所以比较两个方法:
- 第一种事件会立刻执行,第二种事件会在 n 秒后第一次执行
- 第一种事件停止触发后没有办法再执行事件,第二种事件停止触发后依然会再执行一次事件
双剑合璧
那我们想要一个什么样的呢?
有人就说了:我想要一个有头有尾的!就是鼠标移入能立刻执行,停止触发的时候还能再执行一次!
所以我们综合两者的优势,然后双剑合璧,写一版代码:
// 第三版 function throttle(func, wait) { var timeout, context, args, result; var previous = 0; var later = function() { previous = +new Date(); timeout = null; func.apply(context, args) }; var throttled = function() { var now = +new Date(); //下次触发 func 剩余的时间 var 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); } else if (!timeout) { timeout = setTimeout(later, remaining); } }; return throttled; }
效果演示如下:
我们可以看到:鼠标移入,事件立刻执行,晃了 3s,事件再一次执行,当数字变成 3 的时候,也就是 6s 后,我们立刻移出鼠标,停止触发事件,9s 的时候,依然会再执行一次事件。
优化
但是我有时也希望无头有尾,或者有头无尾,这个咋办?
那我们设置个 options 作为第三个参数,然后根据传的值判断到底哪种效果,我们约定:
leading:false 表示禁用第一次执行
trailing: false 表示禁用停止触发的回调
我们来改一下代码:
// 第四版 function throttle(func, wait, options) { var timeout, context, args, result; var previous = 0; if (!options) options = {}; var later = function() { previous = options.leading === false ? 0 : new Date().getTime(); timeout = null; func.apply(context, args); if (!timeout) context = args = null; }; var throttled = function() { var now = new Date().getTime(); if (!previous && options.leading === false) previous = now; var 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; }
取消
在 debounce 的实现中,我们加了一个 cancel 方法,throttle 我们也加个 cancel 方法:
// 第五版 非完整代码,完整代码请查看最后的演示代码链接 ... throttled.cancel = function() { clearTimeout(timeout); previous = 0; timeout = null; } ...
注意
我们要注意 underscore 的实现中有这样一个问题:那就是 leading:false
和 trailing: false
不能同时设置。
如果同时设置的话,比如当你将鼠标移出的时候,因为 trailing 设置为 false,停止触发的时候不会设置定时器,所以只要再过了设置的时间,再移入的话,就会立刻执行,就违反了 leading: false,bug 就出来了,所以,这个 throttle 只有三种用法:
container.onmousemove = throttle(getUserAction, 1000); container.onmousemove = throttle(getUserAction, 1000, { leading: false }); container.onmousemove = throttle(getUserAction, 1000, { trailing: false });
至此我们已经完整实现了一个 underscore 中的 throttle 函数!示例代码:https://www.wenjiangs.com/wp-content/uploads/2022/04/throttle.zip
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: JavaScript 专题之数组去重
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论
评论(16)
@zhangruinian 正是如此,使用定时器时置为空的主要目的并不是垃圾回收,主要是为了方便下次执行定时器
@ClausClaus @dbfterrific 这应该是一个意思吧~
那为什么第二种置为空进行垃圾回收,时间戳的第一种就没有置为空呀,真心请教,疑惑
所以timeout = null主要是为了使其为空然后下次好接着执行?
@ishowman 可以,但是我个人认为 undefined 是一个值,而 null 表示无,所以赋值为 undefined 其实是将值改成一个非常小的占用内存的值,效果上跟赋值为 null 还是差了一点……
timeout = null;
是为了清楚闭包产生的变量常驻内存问题是吧?除了将变量重新赋值为null可以清楚使内存回收机制清楚变量占用的变量,赋值为undefined能清楚内存占用吗?you are really something my brother!
厉害了我的哥。。。
@Awzp 置为空是为了 js 的垃圾回收,不过 later 函数中的 timeout 判断其实没有必要,估计是 underscore 在多次修改后忽略了这个问题~
@lindazhang102 正如 @jawil 所说,是利用隐式类型转换将时间对象转换为时间戳,类似的还有:
@WittyBob 如果你修改了系统时间,就会产生 remaining > wait 的情况……
这是隐式转换的玄学。
我们先看看ECMAScript规范对一元运算符的规范:
一元+ 运算符
一元+运算符将其操作数转换为Number类型并反转其正负。注意负的+0产生-0,负的-0产生+0。
产生式 UnaryExpression : - UnaryExpression 按照下面的过程执行 :
+new Date()相当于 ToNumber(new Date())
我们再来看看ECMAScript规范对ToNumber的定义:
我们知道new Date()是个对象,满足上面的ToPrimitive(),所以进而成了ToPrimitive(new Date())
接着我们再来看看ECMAScript规范对ToPrimitive的定义,一层一层来,抽丝剥茧。
这个ToPrimitive可能不太好懂,我给你解释一下吧:
首先我们要明白 obj.valueOf() 和 obj.toString() 还有原始值分别是什么意思,这是弄懂上面描述的前提之一:
toString用来返回对象的字符串表示。
valueOf方法返回对象的原始值,可能是字符串、数值或bool值等,看具体的对象。
原始值指的是['Null','Undefined','String','Boolean','Number','Symbol']6种基本数据类型之一
最后分解一下其中的过程:
+new Date():
@lindazhang102 @WittyBob
@lindazhang102 这个是转时间戳的方法
var now = +new Date(); 请问这里为什么要有个加号?
第三版
if (remaining <= 0 || remaining > wait) {
这里的remaining好像不会出现大于wait的情况吧
毕竟是基于wait去减的
沙发!!!