throttle
背景
在前端开发中会遇到一些频繁的事件触发,比如:
- scroll 页面滚动
- mousemove 鼠标移动
但有的时候,我们不想这些事件绑定的回调被频繁执行,否则可能出现页面卡顿等情况降低用户体验感。为了解决这个问题,我们可以给事件的回调加上节流 throttle
throttle
原理
💡 一个事件持续触发,每隔一段时间只会执行一次事件的回调。
现实生活的例子就是饮料机的按钮。当顾客按按钮时,饮料机会出一杯饮料,出完便自动停止。如果在出饮料的时候,顾客又按了按钮,饮料机也不会多出饮料(不响应本次事件),等一杯饮料出完后,顾客再次按按钮,才会出下一杯饮料。
throttle
源码
关于节流的实现,有两种主流的实现方式,一种是使用时间戳,一种是设置定时器。
时间戳版本
使用时间戳,当触发事件的时候,我们记录本次事件触发的时间戳,然后减去之前的时间戳,如果大于等于设置的时间周期,就执行函数,并更新时间戳。注意 func
的 this
绑定和入参 args
这两个小细节。
function throttle(func, wait) {
// 记录 func 上次执行的时间戳
let previous = 0; // 初始化为 0 代表从未执行过 func
function throttled(...args) {
// 记录本次事件触发的时间戳
const now = Date.now();
if (now - previous >= wait) {
// 更新 func 执行的时间戳
previous = now;
return func.call(this, ...args);
}
}
return throttled;
}
定时器版本
当触发事件的时候,我们设置一个定时器,再触发事件的时候,如果定时器存在,就不执行任何逻辑,直到定时器回调执行,执行 func
,清空定时器,这样就可以设置下个定时器。
function throttle(func, wait) {
let timeout = null; // 值为 null 代表当前没有计时器
function throttled(...args) {
if (!timeout) { // 没有定时器,代表当前事件为连续事件的第一个事件
timeout = setTimeout(() => {
timeout = null;
func.call(this, ...args);
}, wait);
}
}
return throttled;
}
双剑合璧
可以观察到:时间戳版本会在第一个事件触发时立即执行 func
,定时器版本则相反,在第一个事件触发后 n 秒后才执行 func
。
用 leading
代表首次是否执行,trailing
代表结束后是否再执行一次。我们可以实现一个有头有尾的 throttle
。如下图所示:
回调会在首尾都被调用(此图源自www.npmjs.com/package/@os…)
我们综合时间戳和定时器两者的优势,写出如下代码:
function throttle(func, wait) {
let timeout;
let previous = 0;
function throttled(...args) {
const now = Date.now();
// 下次触发 func 剩余的时间
const remaining = wait - (now - previous);
// 如果没有剩余的时间了或者系统时间发生变化
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
return func.call(this, ...args);
} else if (!timeout) {
timeout = setTimeout(() => {
previous = Date.now();
timeout = null;
func.call(this, ...args);
}, remaining);
}
}
return throttled;
}
主要思想是让时间戳部分负责连续事件的第一个事件的回调执行,后续触发的事件归计时器负责。
上述代码综合了定时器和时间戳,且需要两者相互配合,代码逻辑有点复杂,读者可以试着连续触发三次事件,手动过一下代码到底是如何执行的,来加深理解。
支持配置
但是我们有时也希望无头有尾,或者有头无尾,这个咋办?
那我们设置个 options 作为第三个参数,然后根据传的值判断到底哪种效果,我们约定:
leading
为false
表示禁用第一次执行trailing
为false
表示禁用停止触发的回调
function throttle(func, wait, options = {}) {
let timeout;
let previous = 0;
function throttled(...args) {
const now = Date.now();
// 该事件为连续事件的第一个事件 且 leading 为 false
if (!previous && options.leading === false) previous = now;
const remaining = wait - (now - previous);
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
return func.call(this, ...args);
} else if (!timeout && options.trailing !== false) { // trailing 为 false 则直接放弃计时器逻辑
timeout = setTimeout(() => {
previous = options.leading === false ? 0 : Date.now();
timeout = null;
func.call(this, ...args);
}, remaining);
}
}
return throttled;
}
我们要注意 underscore 的实现中有这样一个问题:那就是 leading: false
和 trailing: false
不能同时设置。
支持取消
underscore 中的 throttle
还支持取消。只需要给 throttle
挂一个 cancel
函数即可:
function throttle(func, wait, options) {
let timeout = null;
let previous = 0;
function throttled(...args) { ... }
throttled.cancel = function () {
clearTimeout(timeout);
timeout = null;
previous = 0;
};
return throttled;
}