背景
前端網頁倒計時是非常常見的應用,我們在各大購物網站的秒殺活動中總是能見到它的身影。但是在實際情況中,我們常常會發現當網頁不刷新、讓倒計時程序持續運行時,顯示時間相比實際時間會越來越慢,相信大家也有在秒殺時間即
將到來時不停刷新頁面的經歷。原因自然也不難理解:倒計時通常使用定時器(setTimeout
或者 setInterval
)實現,首先我們明白,因為JavaScript
是單線程的,在事件循環過程中,當前宏觀任務隊列中的微觀任務會阻塞下一個宏
觀任務隊列中任務的執行。所以會造成一種現象,定時器中的真實執行時間並不會精准的按照第2個參數所設定的數值執行。比如設置1000毫秒,如果到了1000毫秒,主線程被其他任務所占用了,那么就會等待其它任務的執行,等其它任
務執行完畢后,才會執行定時器的回調函數。也就是說,如下代碼代表的意思不是1秒后執行,而是最快1秒后執行。( JavaScript 的單線程特性使得主線程執行棧中出現阻塞時,任務隊列中的異步任務並不能及時執行,因此瀏覽器並不能保
證在定時器設置的時間結束后代碼總是被准時執行,這就造成了倒計時的偏差。)
setTimeout(() => {console.log('我是定時器!')},1000);
一般的解決方法是前端定時向服務器發送請求獲取最新的時間差來校准倒計時時間,主動(程序里設置定時請求)或被動的(F5 已被用戶按壞)區別而已。這個方法簡單但也有點粗暴。
計時器原理
倒計時功能離不開setTimeout或setInterval這兩個函數,要用好這兩個函數必先了解好Javascript解釋器的工作原理
前端開發同學都知道,javascript是單線程的(web worker除外),更好理解的解釋是javascript解釋器是單線程工作,它不能在處理一個ajax的callback的同時去處理click event的callback,而是必須按照先后隊列順序執行。
這圖從上往下看,垂直方向是時間,以ms為單位,藍色模塊是執行代碼所占的時間段,如第一個代碼模塊執行js占用了約18ms, 第二個模塊執行js占用了約11ms,其他模塊類似。由於js是單線程執行,同一時間只能執行一個js代碼(同一時間其他異步事件執行會被阻塞 ) , 當異步事件發生時,它會進入代碼執行隊列,執行線程空閑時依照隊列順序依次執行代碼。
第一個模塊初始化了兩個定時器,一個10ms延遲的setTimeout和10ms的setInterval。這些定時器可能會在我們第一個代碼塊執行結束之前就觸發,這取決於定時器在第一個代碼塊中啟動的位置和時間。注意,定時器雖然觸發了,但是並不會立即執行,它只是把需要延遲執行的函數按時間先后加入了執行隊列,在線程的某一個空閑的時間點,這個函數就能夠得到執行。
按照第一個模塊事件觸發的順序(Mouse Click Occurs -. 10ms Timer Fires),第一個模塊代碼執行結束后,按照隊列中等待的先后順序執行事件,先執行Mouse Click CallBack再執行Timer。在執行Mouse Click CallBack模塊時,Interval第一次觸發未執行加入隊列。在執行Timer模塊時,Interval第二次觸發未執行加入隊列。待Mouse Click CallBack和Timer模塊都執行完畢后,再依次執行隊列中已觸發的Interval事件。后面模塊由於沒有阻塞的事件了,所以按照既定10ms執行Interval事件。
倒計時實現原理
基本的一個倒計時的原理非常簡單了,使用setTimout或者setInterval來對一個函數進行遞歸或者重復調用,然后對DOM節點做對應的render處理,並對時間做倒計時的格式化處理。
現有存在的問題
參考一
嘗試執行如下代碼,會發現定時器的執行時間應該超過了1秒鍾,如果正常執行,你可以從循環條件后面加個0。電腦配置很差的就不要試了。
setTimeout(() => {console.log('我是定時器!')}, 1000); for (let i = 0; i<1000000000; i++) {}
碰到這種循環或者遞歸代碼時,回調函數的執行時間會根據不同的電腦運算速度決定。如果你的電腦配置夠強,比如小型機,高性能服務器等,能夠在1秒以內執行完邏輯,那么就不會影響定時器的正常執行。
要想做到時間相對准確,就必須解決這個問題,辦法有很多種,最常見也最有效的辦法,是在當前定時器的回調函數中校驗誤差並調整下一次定時器的發生時間,達到平均1秒的效果。(也就是下面介紹的解決思路實現)
參考二
用現有 mobi 手機端歡迎頁倒計時為例,以下是功能截圖。
代碼如下:
var second = 10; // 倒計時時間為 10 s
var timer; var timer_div = $('#timer_div'); var start = new Date().getTime(); var count = 0; clearInterval(timer); timer = setInterval(showTime, 1000); function showTime() { if (second === 0) { ... clearInterval(timer); return false; } count++; console.log(new Date().getTime() - (start + count * 1000)); // 這里代碼運行結果,定時器每秒執行一次,每次輸出應該是0 。
timer_div.html('<div>' + second + 's</div>'); second--; }
以上代碼實際輸出如下:
結論:由於代碼執行占用時間和其他事件阻塞原因,導致有些事件執行延遲了幾ms,但影響還不是很大。
下面加一段阻塞線程的代碼看看:
var start = new Date().getTime(); var count = 0; // 占用線程事件
setInterval(function () { var j = 0; while(j++ < 100000000); }, 0); //定時器測試
setInterval(function () { count++; console.log(new Date().getTime() - (start + count * 1000)); }, 1000);
以上代碼實際輸出如下:
結論:由於加了很占線程的阻塞事件,導致定時器事件每次執行延遲越來越嚴重。
以上的阻塞線程的代碼還不算很極端,假如在執行定時器的過程中有同步 ui 事件的代碼,同步代碼會立即執行。實際上在移動端的滾動頁面中是有可能出現這種情況的,以下是一個例子。
function runForSeconds(s) { var start = +new Date(); while (start + s * 1000 > (+new Date())) {} } document.body.addEventListener("click", function () { runForSeconds(10); }, false); setTimeout(function () { console.log("Done!"); }, 1000 * 3);
時間線對比:
等待 3 秒 |----1s----|----2s----|----3s----|--->console.log("Done!");
經過 2 秒 |----1s----|----2s----| ----------|-->console.log("Done!");
點擊 body 后
以為是這樣:|----1s----|----2s----|----3s----|--->console.log("Done!")--->|------------------10s----------------|
其實是這樣:|----1s----|----2s----|------------------10s----------------|--->console.log("Done!");
結論:如果有同步的 ui 事件代碼出現,實際功能的倒計時基本“失效”了,這時不同瀏覽器打開相同的倒計時頁面往往誤差非常大。
解決思路
分析一下從獲取服務器時間到前端顯示倒計時的過程:
-
客戶端 http 請求服務器時間;
-
服務器響應完成;
-
服務器通過網絡傳輸時間數據到客戶端;
-
客戶端根據活動開始時間和服務器時間差做倒計時顯示;
服務器響應完成的時間其實就是服務器時間,但經過網絡傳輸這一步,就會產生誤差了,誤差大小視網絡環境而異,這部分時間前端也沒有什么好辦法計算出來,一般是幾十 ms 以內,大的可能有幾百 ms 。
可以得出:當前服務器時間 = 服務器系統返回時間 + 網絡傳輸時間 + 前端渲染時間 + 常量(可選),這里重點是說要考慮前端渲染的時間,避免不同瀏覽器渲染快慢差異造成明顯的時間不同步,這是第一點。(網絡傳輸時間忽略或加個
常量),前端渲染時間可以在服務器返回當前時間和本地前端的時間的差值得出。
獲得服務器時間后,前端進入倒計時計算和計時器顯示,這步就要考慮 js 代碼凍結和線程阻塞造成計時器延時問題了,思路是通過引入計數器,判斷計時器延遲執行的時間來調整,盡量讓誤差縮小,不同瀏覽器不同時間段打開頁面倒計時
誤差可控制在 1s 以內。
// 繼續線程占用
setInterval(function () { var j = 0; while(j++ < 100000000); }, 0); //倒計時
var interval = 1000, ms = 50000, // 從服務器和活動開始時間計算出的時間差,這里測試用 50000ms
count = 0, startTime = new Date().getTime(); if (ms >= 0) { var timeCounter = setTimeout(countDownStart, interval); } function countDownStart() { count++; var offset = new Date().getTime() - (startTime + count * interval); var nextTime = interval - offset; var daytohour = 0; if (nextTime < 0) { nextTime = 0 }; ms -= interval; console.log("誤差:" + offset + "ms,下一次執行:" + nextTime + "ms后,離活動開始還有:" + ms + "ms"); if (ms < 0) { clearTimeout(timeCounter); } else { timeCounter = setTimeout(countDownStart, nextTime); } }
運行結果如下:
結論:由於線程阻塞延遲問題,做了 setTimeout 執行時間的誤差修正,保證 setTimeout 執行時間一致。若凍結時間特別長的,還要做特殊處理。
setTimeout
進行倒計時操作的執行。而每次執行函數時會維護一個 count 變量,用以記錄已經執行過的倒計時次數,使用代碼 A 處的公式可計算出當前執行倒計時的時間與實際應執行時間的偏
倒計時組件
組件:http://imgcache.gtimg.cn/club/common/lib/zero/widgets/date/Date.1.1.1.js
應用項目地址:http://m.vip.qq.com/clubact/2014/jdfl/index.html?_wv=1
總結
做100%精確的倒計時很難,但做到相對比較准確是可以的。
在倒計時功能開發中,有幾點總結:
1. 要了解好js單線程工作原理;
2. 清楚了解服務器系統時間傳送到前端的流程;
3. 了解前端渲染和線程阻塞造成的時間誤差;
參考
JS實現活動精確倒計時(推薦,與1相似,比1更全面)
前端如何實現一個倒計時組件?(推薦,react中倒計時組件使用和web worker使用)