上次介紹了前端效能優化之防抖-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,下一次再次觸發又會立即執行,這樣就有衝突了。