引言
本文講解倒計時為什么建議使用setTimeout而不使用setInterval,倒計時為什么存在誤差,以及如何解決。
倒計時器
在前端開發(fā)中,倒計時器功能比較常見,比如活動倒計時,假定只有10秒,比較常見的兩種寫法如下:
//setTimeout實現(xiàn)方式
var countdownTime = 10; //倒計時秒數(shù)
var countdown = function() {
var setTimeoutHandler = setTimeout(function () {
countdownTime -- ;
console.log('倒計時:' + countdownTime + ' 秒');
if(countdownTime === 0) {
console.log('倒計時結(jié)束!');
clearTimeout(setTimeoutHandler);
}else {
countdown();
}
}, 1000)
};
countdown();
//setInterval實現(xiàn)方式
var countdownTime = 10; //倒計時秒數(shù)
var countdown = function() {
var setIntervalHandler = setInterval(function () {
countdownTime -- ;
console.log('倒計時:' + countdownTime + ' 秒');
if(countdownTime === 0) {
console.log('倒計時結(jié)束!');
clearInterval(setIntervalHandler);
}
}, 1000)
};
countdown();
控制臺打印都是一樣的:
分析上面的兩種寫法,第一種使用setTimeout方式,countdown遞歸函數(shù)調(diào)用,第二種使用setInterval方式。
setInterval 方法可按照指定的周期(以毫秒計)來調(diào)用函數(shù)或計算表達(dá)式。
setTimeout 方法用于在指定的毫秒數(shù)后調(diào)用函數(shù)或計算表達(dá)式。
相信大家對這兩個函數(shù)的用法都是比較了解的,都可以實現(xiàn)倒計時功能,且setInterval函數(shù)的周期調(diào)用特性更符合倒計時的業(yè)務(wù)場景,但事實真的是這樣么?
setTimeout與setInterval
那么問題來了,是使用setTimeout還是setInterval,還是兩個都可以?
setInterval執(zhí)行機制
JavaScript高級程序設(shè)計(第三版)關(guān)于時間間隔描述:
設(shè)定一個 150ms 后執(zhí)行的定時器不代表到了 150ms 代碼就立刻執(zhí)行,它表示代碼會在 150ms 后被加入到隊列中。如果在這個時間點上,隊列中沒有其他東西,那么這段代碼就會被執(zhí)行。
帶著這段描述,我們設(shè)定執(zhí)行代碼setInterval(func, interval),func函數(shù)執(zhí)行時間為1s,interval時間間隔為0.5s,那么這段代碼的執(zhí)行流程圖如下:
0s時,setInterval函數(shù)觸發(fā),等待0.5s后,func第1次加入到事件隊列中,并在0.5-1.5s期間執(zhí)行了1s。
因為時間間隔為0.5s,所以在1s時func第2次加入到隊列中,但此時JS引擎處理方式是:當(dāng)使用setInterval時,僅當(dāng)沒有該定時器的任何其他代碼實例時,才將定時器代碼添加到隊列中。 因為在1s時,第1次加入隊列的func還在執(zhí)行,所以無法成功將func加入隊列中,這就出現(xiàn)了丟幀現(xiàn)象。
時間又過了0.5s,在1.5s時,func第3次加入到隊列中,此時第1次加入到隊列中func剛執(zhí)行完畢,第3次func可成功加入到隊列中并開始執(zhí)行。此時暴露出setInterval另一個問題,兩次func執(zhí)行的時間間隔遠(yuǎn)小于0.5s,代碼的執(zhí)行間隔比設(shè)定的間隔要小。
setTimeout執(zhí)行機制
那么同樣的功能,使用setTimeout又會是什么現(xiàn)象呢,代碼片段:
setTimeout(function(){
//do something
//arguments.callee 獲取對當(dāng)前執(zhí)行的函數(shù)的引用,在ES5嚴(yán)格模式中已廢棄。
setTimeout(arguments.callee, interval);
},interval)
func函數(shù)執(zhí)行時間為1s,interval時間間隔為0.5s,代碼的執(zhí)行流程圖如下:
0s時,setTimeout函數(shù)觸發(fā),等待0.5s后,func第1次加入到事件隊列中,并在0.5-1.5s期間執(zhí)行了1s。
1.5s時func執(zhí)行結(jié)束,第二個setTimeout函數(shù)被觸發(fā),等待0.5s后,func第2次加入到隊列中,并在2s - 2.5s期間執(zhí)行了1s。
兩次func執(zhí)行間隔與設(shè)定的interval 0.5s一致,且不會出現(xiàn)丟幀的現(xiàn)象。
如何選擇
通過setTimeout和setInterval兩個函數(shù)的執(zhí)行機制來看,setInterval存在兩個問題:
- 丟幀,如果JS隊列中已經(jīng)有一個它的實例,就不會向隊列中添加事件,所以這次的事件執(zhí)行就會丟失。
- 兩次的事件執(zhí)行時間間隔變小甚至無間隔,當(dāng)前事件執(zhí)行完后,馬上就會執(zhí)行隊列中已添加的事件。
所以,使用setTimeout,而不使用setInterval。
倒計時誤差
倒計時器是存在誤差的,我們做個測試,一看便知:
var countIndex = 1; //倒計時任務(wù)執(zhí)行次數(shù)
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 ++ ;
//執(zhí)行下一次倒計時
countdown(timeout);
}, interval)
}
控制臺打?。?/p>
這段代碼的作用是,計算出每次定時器結(jié)束時間和開始時間加上總輪詢的時間的差值,也就是累計的誤差??梢詮目刂婆_打印信息看出,平均每秒存在2ms的誤差值。雖然每次誤差值都不大,但是如果倒計時10分鐘,最后就會差1.2秒,這在搶購秒殺的業(yè)務(wù)場景下是致命的BUG了。
如果你將瀏覽器切換Tab或者最小化一段時間后,再切回打開控制臺看又會看到神奇的一幕:
打印第5次瀏覽器最小化,第10次時瀏覽器恢復(fù),可以看到從第6次到第9次瀏覽器最小化期間,每次偏差值是1000ms左右,等第11次瀏覽器恢復(fù)后,每次偏差值又變回2ms左右。驚不驚喜,意不意外!
為什么會存在誤差
存在2ms的誤差是因為JS是單線程的,執(zhí)行了setTimeout中的代碼塊耗時2ms左右,例子中的代碼塊沒有復(fù)雜邏輯就花費了2ms,可想而知在實際業(yè)務(wù)中肯定要消耗更長時間,而且會隨著計時器執(zhí)行次數(shù)疊加,造成更大的誤差。
而瀏覽器最小化后每次1000ms的誤差是因為瀏覽器性能優(yōu)化的一種機制。參考MDN中關(guān)于setTimeout的一段描述:
未被激活的tabs的定時最小延遲>=1000ms
為了優(yōu)化后臺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也能完全不被加載。
如何解決誤差
倒計時器的誤差是不可避免的,但是我們可以通過誤差值去調(diào)整每次執(zhí)行的時間間隔:
var countIndex = 1; //倒計時任務(wù)執(zhí)行次數(shù)
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 ++ ;
//執(zhí)行下一次倒計時,去除誤差的影響
countdown(timeout - deviation);
}, interval)
}
執(zhí)行下一次倒計時,去除誤差的影響countdown(timeout - deviation),這里我們通過對下一次任務(wù)的調(diào)用時間做了調(diào)整,前面延遲了多少毫秒,那么我們下一個任務(wù)執(zhí)行就加快多少毫秒,這就是處理倒計時誤差的基本思路。
還有一種解決辦法就是通過獲取后臺服務(wù)器的時間去校準(zhǔn)倒計時,獲取本地時間實際上是不嚴(yán)謹(jǐn)?shù)模?code>new Date()獲取到的時間是本機系統(tǒng)的時間,用戶可以通過調(diào)整系統(tǒng)時間欺騙瀏覽器。所以通過獲取服務(wù)器時間校對是比較靠譜的一種做法。
對于切換Tab瀏覽器倒計時器產(chǎn)生的大誤差,解決思路是切回瀏覽器界面后,通過監(jiān)聽頁面可見或被隱藏visibilitychange事件,獲取最新的時間,這樣用戶看到的就是沒有誤差的倒計時了。
document.addEventListener('visibilityChange', function() {
if (!document.hidden) {
// get newest time
}
});
你學(xué)“廢”了么?
文章首發(fā)于我的博客 echeverra.cn,原創(chuàng)文章,轉(zhuǎn)載請注明出處。
歡迎關(guān)注我的微信公眾號 echeverra,一起學(xué)習(xí)進(jìn)步!不定時會有資源和福利相送哦!