-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Description
前言
在《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 函数,恭喜,撒花!
演示代码
相关的代码可以在 Github 博客仓库 中找到
专题系列
JavaScript专题系列目录地址:https://github.com/mqyqingfeng/Blog。
JavaScript专题系列预计写二十篇左右,主要研究日常开发中一些功能点的实现,比如防抖、节流、去重、类型判断、拷贝、最值、扁平、柯里、递归、乱序、排序等,特点是研(chao)究(xi) underscore 和 jQuery 的实现方式。
如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。
Activity
liuxinqiong commentedon Jul 12, 2017
沙发!!!
JJL-SH commentedon Aug 23, 2017
第三版
if (remaining <= 0 || remaining > wait) {
这里的remaining好像不会出现大于wait的情况吧
毕竟是基于wait去减的
lindazhang102 commentedon Aug 23, 2017
var now = +new Date(); 请问这里为什么要有个加号?
JJL-SH commentedon Aug 23, 2017
@lindazhang102 这个是转时间戳的方法
jawil commentedon Aug 23, 2017
这是隐式转换的玄学。
我们先看看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
mqyqingfeng commentedon Aug 24, 2017
@wittybob 如果你修改了系统时间,就会产生 remaining > wait 的情况……
mqyqingfeng commentedon Aug 24, 2017
@lindazhang102 正如 @jawil 所说,是利用隐式类型转换将时间对象转换为时间戳,类似的还有:
mqyqingfeng commentedon Oct 25, 2017
@Awzp 置为空是为了 js 的垃圾回收,不过 later 函数中的 timeout 判断其实没有必要,估计是 underscore 在多次修改后忽略了这个问题~
ClausClaus commentedon Nov 6, 2017
厉害了我的哥。。。
dbfterrific commentedon Nov 15, 2017
you are really something my brother!
ishowman commentedon Nov 15, 2017
timeout = null;
是为了清楚闭包产生的变量常驻内存问题是吧?除了将变量重新赋值为null可以清楚使内存回收机制清楚变量占用的变量,赋值为undefined能清楚内存占用吗?mqyqingfeng commentedon Nov 15, 2017
@ishowman 可以,但是我个人认为 undefined 是一个值,而 null 表示无,所以赋值为 undefined 其实是将值改成一个非常小的占用内存的值,效果上跟赋值为 null 还是差了一点……
zhangruinian commentedon Nov 16, 2017
那为什么第二种置为空进行垃圾回收,时间戳的第一种就没有置为空呀,真心请教,疑惑
143 remaining items
xiaojingming commentedon Dec 21, 2021
@mqyqingfeng 冴羽大大,请问一下第三版的代码中,当鼠标移入元素时,先触发
remaining<=0
的判断,在移动的过程中(移动时间小于remaining)触发第一次延时器,到停下的过程中,事件不是被触发了两次吗🤔还有一个问题,为什么使用延时器的时候用remaining作为时间呢,和使用await的区别是什么呢🤔
wanglun-echo-zz commentedon May 1, 2022
第一个问题:每次立即执行时,都会清除你之前没执行的定时器,如果某个定时器能执行,说明之后没有立即执行的动作了,不然它肯定被清除了。
第二个问题:我们假定时间间隔是
300ms
,now - pre
是200ms
,这时候因为不能立即执行,所以走了定时器路线,如果用wait
,那定时器还得再等300ms
执行,而正确的预期是结合已经等过的200ms,只用再等100ms就应该执行,所以用remaining
很明显更符合预期。LiuWeiMr commentedon Jun 21, 2022
请教下,如果多个按钮都调用节流方法。如何把多个按钮互相独立节流。如让他们互相影响呀。
leslie555 commentedon Jul 11, 2022
@proc07 你这样最后停止触发的时候不会调用函数
Vsnoy commentedon Jul 24, 2022
我觉得没什么问题啊。羽哥写的是先冷却后执行,proc07写的是先执行后冷却。
节流本质就是一段时间内只执行一次,两者都符合要求。只不过一个先一个后罢了。
我不太理解你说的最后触发时不会调用函数是什么意思。你要响应最后一次触发,那这两种方法都做不到。
你在节流冷却期内各种触发,都不会有响应。真要追求最后一次触发,为什么不用防抖?
Vsnoy commentedon Jul 24, 2022
最后一次触发
按羽哥的:先冷却后执行。
在最后一次实际有效触发后,进入冷却期,冷却期到了执行。期间各种触发都无效,包括最后一次触发也响应不了。
按proc07的:先执行后冷却。
在最后一次实际有效触发后,立即执行,然后进入冷却期。期间同样各种触发都无效,包括最后一次触发也响应不了。
有啥区别?没问题吧。
可能是在下愚钝,没转过弯来,还望不吝赐教。
Vsnoy commentedon Jul 24, 2022
第二版,要立即执行,我觉得可以这样写。
不太理解上面所说的最后一次触发的问题。
leslie555 commentedon Jul 25, 2022
如你所说, 两个肯定都能达到节流的作用, 博主写的是到点执行一次(期间至少触发过一次函数),而上面改写的是立即执行,之后再等待时间后再执行。 他的改写我以为的是遵循博主的那一版相同的行为, 所以当时觉得不妥吧
MikuDeramory commentedon Sep 3, 2022
result
好像没有使用到,猜测应该是获取回调函数的返回值吧?YuFengjie97 commentedon Oct 15, 2022
加了option的控制,是真绕啊
ryan6015 commentedon Apr 28, 2023
第二版的实现里面previous 好像没有用到。
CodeGetters commentedon Apr 22, 2024
对,我也疑惑,而且 context args也没有定义
YuFengjie97 commentedon Dec 14, 2024
我也写了一个