如何寫好倒數計時

echeverra發表於2021-11-17

引言

本文講解倒數計時為什麼建議使用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一致,且不會出現丟幀的現象。

如何選擇

通過setTimeoutsetInterval兩個函式的執行機制來看,setInterval存在兩個問題:

  1. 丟幀,如果JS佇列中已經有一個它的例項,就不會向佇列中新增事件,所以這次的事件執行就會丟失。
  2. 兩次的事件執行時間間隔變小甚至無間隔,當前事件執行完後,馬上就會執行佇列中已新增的事件。

所以,使用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 協議》,轉載必須註明作者和本文連結

相關文章