引言
本文講解倒數計時為什麼建議使用setTimeout
而不使用setInterval
,倒數計時為什麼存在誤差,以及如何解決。
倒數計時器
在前端開發中,倒數計時器功能比較常見,比如活動倒數計時,假定只有10秒,比較常見的兩種寫法如下:
//setTimeout實現方式
var countdownTime = 10; //倒數計時秒數
var countdown = function() {
var setTimeoutHandler = setTimeout(function () {
countdownTime -- ;
console.log('倒數計時:' + countdownTime + ' 秒');
if(countdownTime === 0) {
console.log('倒數計時結束!');
clearTimeout(setTimeoutHandler);
}else {
countdown();
}
}, 1000)
};
countdown();
//setInterval實現方式
var countdownTime = 10; //倒數計時秒數
var countdown = function() {
var setIntervalHandler = setInterval(function () {
countdownTime -- ;
console.log('倒數計時:' + countdownTime + ' 秒');
if(countdownTime === 0) {
console.log('倒數計時結束!');
clearInterval(setIntervalHandler);
}
}, 1000)
};
countdown();
控制檯列印都是一樣的:
分析上面的兩種寫法,第一種使用setTimeout
方式,countdown
遞迴函式呼叫,第二種使用setInterval
方式。
setInterval
方法可按照指定的週期(以毫秒計)來呼叫函式或計算表示式。
setTimeout
方法用於在指定的毫秒數後呼叫函式或計算表示式。
相信大家對這兩個函式的用法都是比較瞭解的,都可以實現倒數計時功能,且setInterval
函式的週期呼叫特性更符合倒數計時的業務場景,但事實真的是這樣麼?
setTimeout與setInterval
那麼問題來了,是使用setTimeout
還是setInterval
,還是兩個都可以?
setInterval執行機制
JavaScript高階程式設計(第三版)關於時間間隔描述:
設定一個 150ms 後執行的定時器不代表到了 150ms 程式碼就立刻執行,它表示程式碼會在 150ms 後被加入到佇列中。如果在這個時間點上,佇列中沒有其他東西,那麼這段程式碼就會被執行。
帶著這段描述,我們設定執行程式碼setInterval(func, interval)
,func
函式執行時間為1s,interval
時間間隔為0.5s,那麼這段程式碼的執行流程圖如下:
0s時,setInterval
函式觸發,等待0.5s後,func
第1次加入到事件佇列中,並在0.5-1.5s期間執行了1s。
因為時間間隔為0.5s,所以在1s時func
第2次加入到佇列中,但此時JS引擎處理方式是:當使用setInterval
時,僅當沒有該定時器的任何其他程式碼例項時,才將定時器程式碼新增到佇列中。因為在1s時,第1次加入佇列的func
還在執行,所以無法成功將func
加入佇列中,這就出現了丟幀現象。
時間又過了0.5s,在1.5s時,func
第3次加入到佇列中,此時第1次加入到佇列中func
剛執行完畢,第3次func
可成功加入到佇列中並開始執行。此時暴露出setInterval
另一個問題,兩次func
執行的時間間隔遠小於0.5s,程式碼的執行間隔比設定的間隔要小。
setTimeout執行機制
那麼同樣的功能,使用setTimeout
又會是什麼現象呢,程式碼片段:
setTimeout(function(){
//do something
//arguments.callee 獲取對當前執行的函式的引用,在ES5嚴格模式中已廢棄。
setTimeout(arguments.callee, interval);
},interval)
func
函式執行時間為1s,interval
時間間隔為0.5s,程式碼的執行流程圖如下:
0s時,setTimeout
函式觸發,等待0.5s後,func
第1次加入到事件佇列中,並在0.5-1.5s期間執行了1s。
1.5s時func
執行結束,第二個setTimeout
函式被觸發,等待0.5s後,func第2次加入到佇列中,並在2s - 2.5s期間執行了1s。
兩次func
執行間隔與設定的interval
0.5s一致,且不會出現丟幀的現象。
如何選擇
通過setTimeout
和setInterval
兩個函式的執行機制來看,setInterval
存在兩個問題:
- 丟幀,如果JS佇列中已經有一個它的例項,就不會向佇列中新增事件,所以這次的事件執行就會丟失。
- 兩次的事件執行時間間隔變小甚至無間隔,當前事件執行完後,馬上就會執行佇列中已新增的事件。
所以,使用setTimeout
,而不使用setInterval
。
倒數計時誤差
倒數計時器是存在誤差的,我們做個測試,一看便知:
var countIndex = 1; //倒數計時任務執行次數
const timeout = 1000; //時間間隔1秒
const startTime = new Date().getTime();
countdown(timeout);
function countdown(interval) {
setTimeout(function () {
const endTime = new Date().getTime();
//誤差
const deviation = endTime - (startTime + countIndex * timeout);
console.log('第'+ countIndex +'次:累計誤差 '+ deviation + ' ms');
countIndex ++ ;
//執行下一次倒數計時
countdown(timeout);
}, interval)
}
控制檯列印:
這段程式碼的作用是,計算出每次定時器結束時間和開始時間加上總輪詢的時間的差值,也就是累計的誤差。可以從控制檯列印資訊看出,平均每秒存在2ms的誤差值。雖然每次誤差值都不大,但是如果倒數計時10分鐘,最後就會差1.2秒,這在搶購秒殺的業務場景下是致命的BUG了。
如果你將瀏覽器切換Tab或者最小化一段時間後,再切回開啟控制檯看又會看到神奇的一幕:
列印第5次瀏覽器最小化,第10次時瀏覽器恢復,可以看到從第6次到第9次瀏覽器最小化期間,每次偏差值是1000ms左右,等第11次瀏覽器恢復後,每次偏差值又變回2ms左右。驚不驚喜,意不意外!
為什麼會存在誤差
存在2ms的誤差是因為JS是單執行緒的,執行了setTimeout
中的程式碼塊耗時2ms左右,例子中的程式碼塊沒有複雜邏輯就花費了2ms,可想而知在實際業務中肯定要消耗更長時間,而且會隨著計時器執行次數疊加,造成更大的誤差。
而瀏覽器最小化後每次1000ms的誤差是因為瀏覽器效能優化的一種機制。參考MDN中關於setTimeout
的一段描述:
未被啟用的tabs的定時最小延遲>=1000ms
為了優化後臺tab的載入損耗(以及降低耗電量),在未被啟用的tab中定時器的最小延時限制為1S(1000ms)。
Firefox 從version 5 (see bug 633421開始採取這種機制,1000ms的間隔值可以通過
dom.min_background_timeout_value
改變。Chrome 從 version 11 (crbug.com/66078)開始採用。
Android 版的Firefox對未被啟用的後臺tabs的使用了15min的最小延遲間隔時間 ,並且這些tabs也能完全不被載入。
如何解決誤差
倒數計時器的誤差是不可避免的,但是我們可以通過誤差值去調整每次執行的時間間隔:
var countIndex = 1; //倒數計時任務執行次數
const timeout = 1000; //時間間隔1秒
const startTime = new Date().getTime();
countdown(timeout);
function countdown(interval) {
setTimeout(function () {
const endTime = new Date().getTime();
//誤差
const deviation = endTime - (startTime + countIndex * timeout);
countIndex ++ ;
//執行下一次倒數計時,去除誤差的影響
countdown(timeout - deviation);
}, interval)
}
執行下一次倒數計時,去除誤差的影響countdown(timeout - deviation)
,這裡我們通過對下一次任務的呼叫時間做了調整,前面延遲了多少毫秒,那麼我們下一個任務執行就加快多少毫秒,這就是處理倒數計時誤差的基本思路。
還有一種解決辦法就是通過獲取後臺伺服器的時間去校準倒數計時,獲取本地時間實際上是不嚴謹的,new Date()
獲取到的時間是本機系統的時間,使用者可以通過調整系統時間欺騙瀏覽器。所以通過獲取伺服器時間校對是比較靠譜的一種做法。
對於切換Tab瀏覽器倒數計時器產生的大誤差,解決思路是切回瀏覽器介面後,通過監聽頁面可見或被隱藏visibilitychange
事件,獲取最新的時間,這樣使用者看到的就是沒有誤差的倒數計時了。
document.addEventListener('visibilityChange', function() {
if (!document.hidden) {
// get newest time
}
});
你學“廢”了麼?
文章首發於我的部落格 echeverra.cn/countdown,原創文章,轉載請註明出處。
歡迎關注我的微信公眾號 echeverra,一起學習進步!不定時會有資源和福利相送哦!
本作品採用《CC 協議》,轉載必須註明作者和本文連結