背景
scroll 事件,resize 事件、鼠标事件(mousemove、mouseover 等)、键盘事件(keyup、keydown 等)都存在被频繁触发的风险。
而频繁触发回调导致的大量计算会引发页面的抖动甚至卡顿。为了规避这种情况,我们需要一些手段来控制事件被触发的频率。
- throttle(事件节流)
- debounce(事件防抖)

(图引自冴羽的学防抖一文)
节流的本质
防抖和节流都以闭包的形式存在。
它们通过对事件对应的回调函数进行包裹、以自由变量的形式缓存时间信息,最后用 setTimeout 来控制事件的触发频率。
Throttle
throttle 的中心思想在于:在某段时间内,不管你触发了多少次回调,我都只认第一次,并在计时结束时给予响应。**每一段时间,只执行一次事件。**
大巴司机打开了计时器,在这十分钟内,后面下飞机的乘客都只能乘这一辆大巴,十分钟过去后,不管后面还有多少没挤上车的乘客,这班车都必须发走。
- 司机,是节流阀,他控制发车的时机;
- 乘客,是因频繁操作事件而不断涌入的回调任务,它需要接受“司机”的安排;
- 计时器,是以自由变量形式存在的时间信息,它是“司机”决定发车的依据;
- 发车,这个动作,就对应到回调函数的执行。
节流:通过在一段时间内无视后来产生的回调请求来实现的。只要一位客人叫了车,司机就会为他开启计时器,一定的时间内,后面需要乘车的客人都得排队上这一辆车,谁也无法让司机重新计时。
实现:
1.使用时间戳
// fn是我们需要包装的事件回调, interval是时间间隔的阈值function throttle(fn, interval) {// last为上一次触发回调的时间let last = 0// 将throttle处理结果当作函数返回return function () {// 保留调用时的this上下文let context = this// 保留调用时传入的参数let args = arguments// 记录本次触发回调的时间let now = +new Date()// 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值if (now - last >= interval) {// 如果时间间隔大于我们设定的时间间隔阈值,则执行回调last = now;fn.apply(context, args);}}}function getUserAction(e){...}; // 全局作用域中定义// 用throttle来包装scroll的回调const better_move = throttle(getUserAction, 1000);document.addEventListener('mousemove', better_move)
当鼠标移入时,事件立刻执行,每过 1s 会执行一次,如果在 4.2s 停止触发,以后不会再执行事件。
this
在上述代码的第19行,若不使用fn.apply(context, args) ;而直接使用fn(args) ,
此时因为fn是在全局上下文中定义的,其this值会指向Window对象,而不会指向正确的调用对象— DOM元素。
所以这里需要保存this的值,当然,这里不使用context,直接使用this的效果也是一样。
简要分析一下,this的指向问题:
throttle函数的返回值赋给了better_move,所以这里保存的this及arguments都是better_move的调用者(DOM元素)传入的。
args
JavaScript 在事件处理函数中会提供事件对象 event;如果不使用 throttle函数,这里会打印 MouseEvent 对象:
但是在 throttle函数中,如果不传入args,即:fn.apply(context)却只会打印 undefined
2.使用定时器
- 当触发事件的时候,我们设置一个定时器;
- 再触发事件的时候,如果定时器存在,就不执行;
- 直到定时器执行,然后执行函数,清空定时器,这样就可以设置下个定时器。
可以看到:当鼠标移入的时候,事件不会立刻执行,3s 后执行了一次,此后每 3s 执行一次,当数字显示为 3 的时候,立刻移出鼠标,相当于大约 9.2s 的时候停止触发,但是依然会在第 12s 的时候再执行一次事件。function throttle(func, wait) {var timeout;var last = 0;return function() {context = this;args = arguments;if (!timeout) {timeout = setTimeout(function(){timeout = null;func.apply(context, args)}, wait)}}}

比较
- 时间戳实现:
- 事件会立刻执行;
- 事件停止触发后没有办法再执行事件
- 定时器实现:
- 事件会在 n 秒后第一次执行
- 事件停止触发后依然会再执行一次事件
3.合并
有头有尾
鼠标移入能立刻执行,停止触发的时候还能再执行一次
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;}
这里注意上述代码的第21行:当没有剩余时间时,需要做两件事:
- 调用函数;
- 取消定时器,可以说:时间戳的权重要大于定时器;
可以看到:鼠标移入,事件立刻执行; 晃了 3s后,事件再一次执行,当数字变成 3 (第6s) 的时候,移出鼠标,停止触发事件,9s 的时候,依然会再执行一次事件。
停止触发仍执行
这里解释一下:在第9s时,为什么会在执行一次事件?
因为:在第6s移除鼠标时,仍触发一次事件;但因为没有到达时间戳,所以第19行的判断体不执行, 而执行第26行的判断体,最后添加了一个计时器,保证停止后仍能触发一次事件。
4.underscore的节流实现
options参数
可以设置 options 作为第三个参数:
leading:false 表示禁用第一次执行trailing: false 表示禁用停止触发的回调
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();// previous为0,代表最开始那次执行;// 设置为now表示,则后续的时间戳会未到达,禁止第一次回调执行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) {// 此判断题根据trailing的值,来确定是否执行最后一次回调timeout = setTimeout(later, remaining);}};return throttled;}
取消
throttled.cancel = function() {clearTimeout(timeout);previous = 0;timeout = null;}

