节流和防抖本身是一件非常简单的事,但是翻遍大部分博客,很少能找见,对节流和防抖做了相关优化的。所以本篇主要讲述一下为什么要做优化,以及具体如何实现优化。
首先,为还没学过节流和防抖的猿同胞解释一下最基本的概念和实现:
防抖
假设页面中有一个 div,为这个 div 绑定一个监听鼠标移动的事件,比如这样。
let index = 0;
document
.querySelector("div")
.addEventListener("mousemove", () => console.log(++index));
那当鼠标在该 div 上移动时,鼠标移动事件会被疯狂执行,在很短时间内,输出的 index 就可能会飙到一百多,类似的高频操作还包括用户不断下拉刷新、监听浏览器滚动等。而在实际情况下,回调事件的代码可能是非常复杂的,包括但不仅限于不断向服务端发送请求。所以为了应对这种情况,可以利用防抖,减少回调事件的执行内容。
防抖(debounce):在函数需要频繁触发时,只有当有足够空闲的时间时,才执行一次。即在第一次触发事件时,不立即执行函数,而是给出一个延迟时间,如果在延迟时间内没有再次触发事件,那么就执行函数;如果在延迟时间内再次触发事件,那么当前的计时取消,重新开始计时等待执行。
效果:如果短时间内大量触发同一事件,只会执行一次函数。
实现:只需定义一个计时器,每次回调事件触发时,清除计时器再重新设置即可。同时为了保证回调函数传入的参数不丢失,这里采用扩展运算符,将参数全部传入最终需要执行的事件中。
const debounce = (callback, delay) => {
let timer = null;
return (...params) => {
clearTimeout(timer);
timer = setTimeout(() => callback(...params), delay);
};
};
let index = 0;
document.querySelector("div").addEventListener(
"mousemove",
debounce(() => console.log(++index), 3000)
);
这里延迟 3s 是为了方便展示效果,通过这种方式,index 就不会被高频输出了,只有当事件监听不再触发的 3s 之后,才会输出一次。
我看到很多博客到这里就结束了,但这里其实还有个小问题。也许在实际情况中,需要执行的事件确实远比 clearTimeout、setTimeout 复杂和很多,但其实短时间内数百次地疯狂清除再设置计时器,也是一件非常消耗性能的事,为了再解决这一点就需要优化一下实现思路。
优化实现:既然不希望高频地清除重置计时器,那么就只好不清除,让当前设置的计时器执行,但是计时器的回调触发后,为了不让最终需要执行的事件被触发,就需要有个时间戳,判断当前时间下是否该执行。如果该执行,就清掉计时器并执行;如果不该执行,就单纯清掉计时器,重新设置计时器,这样就能大幅减少计时器的清除和设置。而每次移动事件被触发,只需要判断当前是否有计时器,没有就设置,有则不需要做任何事。
const debounce = (callback, delay) => {
let timer = null,
stop = null;
const runner = (...params) => {
clearTimeout(timer);
const start = Date.now();
timer = setTimeout(() => {
const now = Date.now();
if (now >= stop) {
clearTimeout(timer);
timer = null;
callback(...params);
} else runner(...params);
}, stop - start);
};
return (...params) => {
stop = Date.now() + delay;
if (!timer) runner(...params);
};
};
let index = 0;
document.querySelector("div").addEventListener(
"mousemove",
debounce(() => console.log(++index), 3000)
);
虽然代码看起来复杂了,但对于浏览器来说,高频执行的只有 stop 复制和判断语句,远比清除重置计时器要节省性能。
节流
同样是假设对于绑定了 mousemove 事件的 div,换一种思路。当鼠标在 div 上移动时,不等到最后一次结束后再等待延迟时间后执行,而是每固定一个时间段就执行一次。比如每 1s 执行依次,那如果鼠标在 div 上滑动时间大于 9s 而小于 10s,就执行 10 次。
节流(throttle):预定一个函数只有在大于等于执行周期时才执行,周期内调用不执行。而最终触发时,用的是周期内最后一次监听所产生的参数。
效果:如果短时间内大量触发同一事件,那么在函数执行一次之后,该函数在指定的时间期限内不再工作,直至过了这段时间才重新生效。
实现:只需定义一个计时器,每次回调事件触发时,判断计时器是否已经存在,而当到达一个周期执行后,清除定时器和存储参数即可。
const throttle = (callback, wait) => {
let timer = null,
save = undefined;
return (...params) => {
save = params;
if (!timer)
timer = setTimeout(() => {
clearTimeout(timer);
callback(...save);
timer = null;
save = undefined;
}, wait);
};
};
let index = 0;
document.querySelector("div").addEventListener(
"mousemove",
throttle(() => console.log(++index), 3000)
);
对于节流,因为没有高频的定时器的清除和重置,就不需要做什么优化了。这里想提出一个有关异步的节流问题:假设需要调用 100 个 API 获取数据,并且需要越快越好。如果使用 Promise.all,100 个请求会同时到达服务器,如果服务器性能很低的话,这就会是个负担。那么就需要节流 API 请求,假设任何时刻最多只能有 5 个请求在服务器正在进行中,需要实现一个 throttlePromise 函数,接受一个 promise 函数组成的数组,和一个同时刻可进行的 API 最大数量,返回结果达成和 Promise.all 完全相同。
放一个我搜罗诸多博客后,自认为最佳的解决办法:先直接发送 max 个请求,当其中有任意一个请求返回后,立即补充发送下一个请求,也就是第 max + 1 个,这样服务器中就又同时存在 max 个请求,然后以此类推。
const throttlePromise = (promises, max) => {
const result = [];
const runner = async (iterator) => {
for (let [index, item] of iterator) {
result[index] = await item();
}
};
const iterator = Array.from(promises).entries();
const workers = Array(max).fill(iterator).map(runner);
return Promise.all(workers).then(() => result);
};
如果对迭代器了解不充分的话,这个方法的实现逻辑就不好理解了,可以先学习一下迭代器相关知识。
首先这里对 promises 做了一层拷贝后,通过 entries 方法获取了数组的一个迭代器,这个迭代器每次调用 next 方法,返回的对象的 value 属性,是由原数组的 index 和 value 组成的数组。再创建一个长度为 max 的数组,每个元素都指向这个迭代器,由于迭代器是个复杂对象,所以数组的每一项都是同一个迭代器(严格来讲其实是数组每一项存入了这个遍历器的地址),即若 const iterators = Array(max).fill(iterator),则有 iterators[i] = iterators[j],对任意 i 和 j 小于 max 成立,这样再调用 map 时,max 次循环都会把同一个迭代器传给 runner 函数,由于 runner 时 async 函数,返回值自然回是一个 Promise,因此 workers 就可以托给 Promise.all 监听。
在每个 runner 函数内部,for of 循环每遍历一次 iterator,就相当于调用一次 iterator 的 next 方法,将该遍历器的指针指向下一个,当 iterator 指针指向最后一个之后,也就是再调用 next 方法会返回{ value: undefined, done: true }时,for of 循环就会结束,整个 runner 函数也就执行完毕了。但因为 map 调用了 max 次 runner 函数,每次传入的都是同一个遍历器,那么只要当其中一次调用的 runner 函数将遍历器的指针移向了下一项,max 次调用的 runner 函数的遍历器指针也都会随之指向了下一项,同时只要当其中一次移过了最后一项,max 次就都会结束 for of 循环。
因此,可以保证服务端永远都会有 max 个请求,而且 promises 也只遍历了一次,当最后一个请求返回时,workers 中的 max 个 Promise 全部结束(非同时结束,因为每次调用的 runner 中遍历到的 API 是完全不同的),然后将 result 返回给外界即可。