Blog
Blog copied to clipboard
JavaScript专题之跟着 underscore 学节流
前言
在《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,对作者也是一种鼓励。
沙发!!!
第三版 if (remaining <= 0 || remaining > wait) { 这里的remaining好像不会出现大于wait的情况吧 毕竟是基于wait去减的
var now = +new Date(); 请问这里为什么要有个加号?
@lindazhang102 这个是转时间戳的方法
这是隐式转换的玄学。
我们先看看ECMAScript规范对一元运算符的规范:
一元+ 运算符 一元+运算符将其操作数转换为Number类型并反转其正负。注意负的+0产生-0,负的-0产生+0。
产生式 UnaryExpression : - UnaryExpression 按照下面的过程执行 :
- 令 expr 为解释执行 UnaryExpression 的结果 .
- 令 oldValue 为 ToNumber(GetValue(expr)).
- 如果 oldValue is NaN ,return NaN.
- 返回 oldValue 取负(即,算出一个数字相同但是符号相反的值)的结果。
+new Date()相当于 ToNumber(new Date())
我们再来看看ECMAScript规范对ToNumber的定义:
我们知道new Date()是个对象,满足上面的ToPrimitive(),所以进而成了ToPrimitive(new Date())
接着我们再来看看ECMAScript规范对ToPrimitive的定义,一层一层来,抽丝剥茧。
这个ToPrimitive可能不太好懂,我给你解释一下吧:
ToPrimitive(obj,preferredType)
JS引擎内部转换为原始值ToPrimitive(obj,preferredType)函数接受两个参数,第一个obj为被转换的对象,第二个
preferredType为希望转换成的类型(默认为空,接受的值为Number或String)
在执行ToPrimitive(obj,preferredType)时如果第二个参数为空并且obj为Date的事例时,此时preferredType会
被设置为String,其他情况下preferredType都会被设置为Number如果preferredType为Number,ToPrimitive执
行过程如
下:
1. 如果obj为原始值,直接返回;
2. 否则调用 obj.valueOf(),如果执行结果是原始值,返回之;
3. 否则调用 obj.toString(),如果执行结果是原始值,返回之;
4. 否则抛异常。
如果preferredType为String,将上面的第2步和第3步调换,即:
1. 如果obj为原始值,直接返回;
2. 否则调用 obj.toString(),如果执行结果是原始值,返回之;
3. 否则调用 obj.valueOf(),如果执行结果是原始值,返回之;
4. 否则抛异常。
首先我们要明白 obj.valueOf() 和 obj.toString() 还有原始值分别是什么意思,这是弄懂上面描述的前提之一:
toString用来返回对象的字符串表示。
var obj = {};
console.log(obj.toString());//[object Object]
var arr2 = [];
console.log(arr2.toString());//""空字符串
var date = new Date();
console.log(date.toString());//Sun Feb 28 2016 13:40:36 GMT+0800 (中国标准时间)
valueOf方法返回对象的原始值,可能是字符串、数值或bool值等,看具体的对象。
var obj = {
name: "obj"
};
console.log(obj.valueOf());//Object {name: "obj"}
var arr1 = [1];
console.log(arr1.valueOf());//[1]
var date = new Date();
console.log(date.valueOf());//1456638436303
如代码所示,三个不同的对象实例调用valueOf返回不同的数据
原始值指的是['Null','Undefined','String','Boolean','Number','Symbol']6种基本数据类型之一
最后分解一下其中的过程: +new Date():
- 运算符new的优先级高于一元运算符+,所以过程可以分解为: var time=new Date(); +time
2.根据上面提到的规则相当于:ToNumber(time)
3.time是个日期对象,根据ToNumber的转换规则,所以相当于:ToNumber(ToPrimitive(time))
4.根据ToPrimitive的转换规则:ToNumber(time.valueOf()),time.valueOf()就是 原始值 得到的是个时间戳,假设time.valueOf()=1503479124652
5.所以ToNumber(1503479124652)返回值是1503479124652这个数字。
6.分析完毕,从原理得出结果,而不是从浏览器输出的结果来解释结果。用结论解释结论,会忽略很多细节,装个逼,逃,233333
@lindazhang102 @WittyBob
@WittyBob 如果你修改了系统时间,就会产生 remaining > wait 的情况……
@lindazhang102 正如 @jawil 所说,是利用隐式类型转换将时间对象转换为时间戳,类似的还有:
+'1' // 1 (转数字)
1 + '' // '1' (转字符串)
!!1 // true (转布尔值)
@Awzp 置为空是为了 js 的垃圾回收,不过 later 函数中的 timeout 判断其实没有必要,估计是 underscore 在多次修改后忽略了这个问题~
厉害了我的哥。。。
you are really something my brother!
timeout = null;
是为了清楚闭包产生的变量常驻内存问题是吧?除了将变量重新赋值为null可以清楚使内存回收机制清楚变量占用的变量,赋值为undefined能清楚内存占用吗?
@ishowman 可以,但是我个人认为 undefined 是一个值,而 null 表示无,所以赋值为 undefined 其实是将值改成一个非常小的占用内存的值,效果上跟赋值为 null 还是差了一点……
那为什么第二种置为空进行垃圾回收,时间戳的第一种就没有置为空呀,真心请教,疑惑
所以timeout = null主要是为了使其为空然后下次好接着执行?
@zhangruinian 正是如此,使用定时器时置为空的主要目的并不是垃圾回收,主要是为了方便下次执行定时器
@ClausClaus @dbfterrific 这应该是一个意思吧~ 😂
节流和去抖如何区分
@xue1234jun 防抖是虽然事件持续触发,但只有等事件停止触发后 n 秒才执行函数,节流是持续触发的时候,每 n 秒执行一次函数
@mqyqingfeng 非常感谢
我们要注意 underscore 的实现中有这样一个问题: 那就是 leading:false 和 trailing: false 不能同时设置。 如果同时设置的话,比如当你将鼠标移出的时候,因为 trailing 设置为 false,停止触发的时候不会设置定时器,所以只要再过了设置的时间,再移入的话,就会立刻执行,就违反了 leading: false,bug 就出来了
其一: underscore 怎么没有考虑 leading = false
和 trailing = false
同时为 false 的情况,这应该不难实现吧,关键就是 previous 置为初始值 0。
其二:
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);
}
};
remaining > wait
情况的发生是 now - previous
为负值的时候,也就是获取的当前时间小于先前获取的当前时间,在事件触发的过程中更改当前时间吗(这种概率是有多小)
@savoygu 关于第一个问题,其实有人提过这个问题,这是当时的一个核心贡献者的回答:
这个开发者认为两者必须要有一个为 true。
除了这个 issue 外,如果要知道 underscore 是否考虑了 leading = false 和 trailing = false 同时为 false 的情况,其实看测试用例就可以了,这是地址 https://github.com/jashkenas/underscore/blob/08361d41590ff35be44ec6b757361ac37f6fa7c7/test/functions.js
搜索 leading ,其实没有两个都为 false 的测试用例。
关于是否容易实现这个效果,如果我们在已有的代码上进行修改,比如 previous 设置为 0,那什么时候设置这个 previous 呢?欢迎补充 demo 哈~
@mqyqingfeng 感谢
要是有代码的思路就更好了~~~~~~~~~~~~~~~~~
@zjp6049 嗯嗯,这个日后补充~
// 第二版
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)
// 添加
func.apply(context, args)
}
}
}
这样就可以立刻执行了。
感觉later函数里的这里判断没啥意义啊,求指教 previous = options.leading === false ? 0 : new Date().getTime(); 直接previous = +new Date()不行吗
@dowinweb 肯定有意义啊。首先如果不在回调里设置(针对leading:false的情况) previous = options.leading === false ? 0 : new Date().getTime();而是 直接previous = +new Date() 那么第一次以外的if (!previous && options.leading === false) previous = now;就不会执行, 这里的now是下次执行的new Date(),而回调里的是timeout执行时的new Date()。 也就是说按你的说话来执行的话,首先移动鼠标,wait秒后变为1没毛病,然后你别动鼠标,等待大于wait秒后再移动鼠标,这时候就会马上变为2而达不到禁用第一次执行的效果。 因为这时候的var remaining = wait - (now - previous); now是现在执行的时间,previos还是你很久前timeout执行的时间,所以remaining应该是负数走了 触发时间到了的函数(立即执行,1变为2)
@zjp6049 嗯嗯,明白啦,谢谢
@jxZhangLi 嗯嗯,感谢补充,这样确实可以做到立即执行~