上次介绍了前端性能优化之防抖-debounce,这次来聊聊它的兄弟-节流。
再拿乘电梯的例子来说:坐过电梯的都知道,在电梯关门但未上升或下降的一小段时间内,如果有人从外面按开门按钮,电梯是会再开门的。要是电梯空间没有限制的话,那里面的人就一直在等。。。后来电梯工程师收到了好多投诉,于是他们就改变了方案,设定每隔一定时间,比如30秒,电梯就会关门,下一节电梯会继续等待30秒。
专业术语概括就是:每隔一定时间,执行一次函数。
最简易版的代码实现:
function throttle(fn, delay) {
let timer = null;
return function() {
const context = this;
const args = arguments;
if (!timer) {
timer = setTimeout(() => {
fn.apply(context, args);
timer = null;
}, delay);
}
};
}
很好理解,返回一个匿名函数形成闭包,并维护了一个局部变量timer。只有在timer不为null才开启定时器,而timer为null的时机则是定时器执行完毕。
除了定时器,还可以用时间戳实现:
function throttle(fn, delay) {
let last = 0;
return function() {
const context = this;
const args = arguments;
const now = +new Date();
const offset = now - last;
if (offset > delay) {
last = now;
fn.apply(context, args);
}
};
}
last代表上次执行fn的时刻,每次执行匿名函数都会计算当前时刻与last的间隔,是否比我们设定的时间间隔大,若大于,则执行fn,并更新last的值。
比较上述两种实现方式,其实是有区别的:
定时器方式,第一次触发并不会执行fn,但停止触发之后,还会再次执行一次fn
时间戳方式,第一次触发会执行fn,停止触发后,不会再次执行一次fn
两种方式是可以互补的,可以将其结合起来,即能第一次触发会执行fn,又能在停止触发后,再次执行一次fn:
function throttle(fn, delay) {
let last = 0;
let timer = null;
return function() {
const context = this;
const args = arguments;
const now = +new Date();
const offset = now - last;
if (offset > delay) {
if (timer) {
clearTimeout(timer);
timer = null;
}
last = now;
fn.apply(context, args);
}
else if (!timer) {
timer = setTimeout(() => {
last = +new Date();
timer = null;
fn.apply(context, args);
}, delay - offset);
}
};
}
匿名函数内有个if...else,第一个是判断时间戳,第二个是判断定时器,对比下前面两种实现方式。
首先是时间戳方式的简易版:
if (offset > delay) {
last = now;
fn.apply(context, args);
}
混合版:
if (offset > delay) {
if (timer) { // 注意这里
clearTimeout(timer);
timer = null;
}
last = now;
fn.apply(context, args);
}
可以发现,混合版比简易版多了对timer不为null的判断,并清除了定时器、将timer置为null。
再是定时器实现方式的简易版:
if (!timer) {
timer = setTimeout(() => {
fn.apply(context, args);
timer = null;
}, delay);
}
混合版:
else if (!timer) {
timer = setTimeout(() => {
last = +new Date(); // 注意这里
timer = null;
fn.apply(context, args);
}, delay - offset);
}
可以看到,混合版比简易版多了对last变量的重置,而last变量是时间戳实现方式中判断的重要因素。这里要注意下,因为是在定时器的回调中,所以last的重置值要重新获取当前时间戳,而不能使用变量now。
通过以上对比,我们可以发现,混合版是综合了两种不同实现方式的作用,但除去开始和结束阶段的不同,两者的共同作用是一致的--执行fn函数。所以,同一个时刻,执行fn函数的语句只能存在一个!在混合版的实现中,时间戳判断里,去除了定时器的影响,定时器判断里,去除了时间戳的影响。
对于立即执行和停止触发后的再次执行,我们可以通过参数来控制,适应需求的变化。
假设规定{ immediate: false } 阻止立即执行,{ trailing: false } 阻止停止触发后的再次触发:
function throttle(fn, delay, options = {}) {
let timer = null;
let last = 0;
return function() {
const context = this;
const args = arguments;
const now = +new Date();
if (last === 0 && options.immediate === false) { // 这个条件语句是新增的
last = now;
}
const offset = now - last;
if (offset > delay) {
if (timer) {
clearTimeout(timer);
timer = null;
}
last = now;
fn.apply(context, args);
}
else if (!timer && options.trailing !== false) { // options.trailing !== false 是新增的
timer = setTimeout(() => {
last = options.immediate === false ? 0 : +new Date();;
timer = null;
fn.apply(context, args);
}, delay - offset);
}
};
}
相对于混合版,除了新增了一个参数options,其它不同之处已在代码中标明。
思考下,立即执行是时间戳方式实现的,那么想要阻止立即执行的话,只要阻止第一次触发时,offset > delay 条件的成立就行了!如何判断是第一次触发?last变量只有初始化时,值才会是0,再加上我们手动传入的参数,阻止立即执行的条件就满足了:
if (last === 0 && options.immediate === false) {
last = now;
}
条件满足后,我们重置last变量的初始值为当前时间戳,那么第一次 offset > delay 就不会成立了!
然后想阻止停止触发后的再次执行,仔细一想,要是不需要这个功能的话,时间戳的实现不就可以满足了?对!我们只要变相地去除定时器就好了:
!timer && options.trailing !== false
如果我们不手动传入{ trailing: false } ,这个条件是永远不会成立的,即定时器永远不会开启。
不过有个问题在于,immediate和trailing不能同时设置为false,原因在于,{ trailing: false } 的话,停止触发后不会再次执行,然后关键的last变量也就不会被重置为0,下一次再次触发又会立即执行,这样就有冲突了。