背景
前端頁面倒計時功能在很多場景中會用到,如運營活動開始倒計時和活動結(jié)束倒計時鳍悠,又如購物網(wǎng)站的秒殺倒計時税娜,搶購倒計時,還有我們手Q春節(jié)搶紅包倒計時等等……. 最近的話費代付項目中藏研,也涉及倒計時功能敬矩,但在開發(fā)過程中遇到一些麻煩和坑點,下面和大家分享一下最后是如何解決的蠢挡。
坑點
手Q春節(jié)搶明星紅包活動弧岳,就有產(chǎn)品吐槽兩個手機在不同時間點打開同一個活動顯示的開搶倒計時不一樣,誤差大的甚至相差幾分鐘业踏,導(dǎo)致某些用戶在活動顯示還未開始紅包就已被搶完了禽炬。為什么誤差會這么大呢?
對于這個問題堡称,前臺開發(fā)同學(xué)一般會猜測是這個原因:倒計時讀取了客戶端時間造成的瞎抛,因為客戶端時間和服務(wù)端時間有誤差,應(yīng)該讀取服務(wù)器時間却紧。
是的桐臊,倒計時不應(yīng)該讀取客戶端時間,客戶端時間用戶可以隨時調(diào)整晓殊,會造成不一致断凶,應(yīng)讀取服務(wù)器返回時間。但實踐證明巫俺,做了這一步還未夠认烁,頁面運行時間長了,新開的頁面和原打開頁面還是存在誤差。
京東團購也存在這個問題:
造成誤差的原因主要有幾種可能:
1. 沒有考慮js凍結(jié)運行耗費時間却嗡;(特別是移動端容易出現(xiàn)舶沛,下滑頁面時倒計時不動了)
2. 沒有考慮頁面渲染和函數(shù)運行累積時間;(京東的誤差貌似屬于這種)
3. 其他代碼邏輯問題(這種情況就復(fù)雜了窗价,這里不討論)如庭;
計時器原理
倒計時功能離不開setTimeout或setInterval這兩個函數(shù),要用好這兩個函數(shù)必先了解好Javascript解釋器的工作原理撼港,前端界大牛John.Resig (jQuery作者) 有篇文章很好講解了Javascript解釋器工作原理 — 《How JavaScript Timers Work》坪它。
前端開發(fā)同學(xué)都知道,javascript是單線程的(web worker除外)帝牡,更好理解的解釋是javascript解釋器是單線程工作往毡,它不能在處理一個ajax的callback的同時去處理click event的callback,而是必須按照先后隊列順序執(zhí)行靶溜。
這圖包含信息量很大开瞭,這里按照自己的理解描述一下:
這圖從上往下看,垂直方向是時間罩息,以ms為單位惩阶,藍色模塊是執(zhí)行代碼所占的時間段,如第一個代碼模塊執(zhí)行js占用了約18ms, 第二個模塊執(zhí)行js占用了約11ms扣汪,其他模塊類似。由于js是單線程執(zhí)行锨匆,同一時間只能執(zhí)行一個js代碼(同一時間其他異步事件執(zhí)行會被阻塞 ) , 當(dāng)異步事件發(fā)生時崭别,它會進入代碼執(zhí)行隊列,執(zhí)行線程空閑時依照隊列順序依次執(zhí)行代碼恐锣。
第一個模塊初始化了兩個定時器茅主,一個10ms延遲的setTimeout和10ms的setInterval。這些定時器可能會在我們第一個代碼塊執(zhí)行結(jié)束之前就觸發(fā)土榴,這取決于定時器在第一個代碼塊中啟動的位置和時間诀姚。注意,定時器雖然觸發(fā)了玷禽,但是并不會立即執(zhí)行赫段,它只是把需要延遲執(zhí)行的函數(shù)按時間先后加入了執(zhí)行隊列,在線程的某一個空閑的時間點矢赁,這個函數(shù)就能夠得到執(zhí)行糯笙。
按照第一個模塊事件觸發(fā)的順序(Mouse Click Occurs -. 10ms Timer Fires),第一個模塊代碼執(zhí)行結(jié)束后撩银,按照隊列中等待的先后順序執(zhí)行事件给涕,先執(zhí)行Mouse Click CallBack再執(zhí)行Timer。在執(zhí)行Mouse Click CallBack模塊時,Interval第一次觸發(fā)未執(zhí)行加入隊列够庙。在執(zhí)行Timer模塊時恭应,Interval第二次觸發(fā)未執(zhí)行加入隊列。待Mouse Click CallBack和Timer模塊都執(zhí)行完畢后耘眨,再依次執(zhí)行隊列中已觸發(fā)的Interval事件昼榛。后面模塊由于沒有阻塞的事件了,所以按照既定10ms執(zhí)行Interval事件毅桃。
倒計時問題
如果上面Javascript計時器原理理解了褒纲,就很好明白倒計時功能存在問題的隱患。
先看一段測試代碼:
var start = new Date().getTime();
var count = 0;
//定時器測試
setInterval(function(){
count++;
console.log( new Date().getTime() - (start + count * 1000));
},1000);
目測代碼就知道運行結(jié)果钥飞,定時器每秒執(zhí)行一次莺掠,每次輸出應(yīng)該是0 。
實際輸出:
結(jié)論:由于代碼執(zhí)行占用時間和其他事件阻塞原因读宙,導(dǎo)致有些事件執(zhí)行延遲了幾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);
實際輸出:
結(jié)論:由于加了很占線程的阻塞事件结闸,導(dǎo)致定時器事件每次執(zhí)行延遲越來越嚴(yán)重唇兑。
由于實際項目中,執(zhí)行計時器的同時桦锄,會有很多其他異步阻塞事件扎附,會導(dǎo)致倒計時功能不精確。
解決思路
這里先分析一下從獲取服務(wù)器時間到前端顯示倒計時的過程:
1. 客戶端http請求服務(wù)器時間结耀;
2. 服務(wù)器響應(yīng)完成留夜;
3. 服務(wù)器通過網(wǎng)絡(luò)傳輸時間數(shù)據(jù)到客戶端;
4. 客戶端根據(jù)活動開始時間和服務(wù)器時間差做倒計時顯示图甜;
服務(wù)器響應(yīng)完成的時間其實就是服務(wù)器時間碍粥,但經(jīng)過網(wǎng)絡(luò)傳輸這一步,就會產(chǎn)生誤差了黑毅,誤差大小視網(wǎng)絡(luò)環(huán)境而異嚼摩,這部分時間前端也沒有什么好辦法計算出來,一般是幾十ms以內(nèi)矿瘦,大的可能有幾百ms枕面。
可以得出:當(dāng)前服務(wù)器時間 = 服務(wù)器系統(tǒng)返回時間 + 網(wǎng)絡(luò)傳輸時間 + 前端渲染時間 + 常量(可選),這里重點是說要考慮前端渲染的時間匪凡,避免不同瀏覽器渲染快慢差異造成明顯的時間不同步膊畴,這是第一點。(網(wǎng)絡(luò)傳輸時間忽略或加個常量唄)
獲得服務(wù)器時間后病游,前端進入倒計時計算和計時器顯示唇跨,這步就要考慮js代碼凍結(jié)和線程阻塞造成計時器延時問題了稠通,我的思路是通過引入計數(shù)器,判斷計時器延遲執(zhí)行的時間來調(diào)整买猖,盡量讓誤差縮小改橘,不同瀏覽器不同時間段打開頁面倒計時誤差可控制在1s以內(nèi)。
關(guān)鍵實現(xiàn)代碼如下:
//繼續(xù)線程占用
setInterval(function(){
var j = 0;
while(j++ < 100000000);
}, 0);
//倒計時
var interval = 1000,
ms = 50000, //從服務(wù)器和活動開始時間計算出的時間差玉控,這里測試用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飞主,下一次執(zhí)行:" + nextTime + "ms后,離活動開始還有:" + ms + "ms");
if(ms < 0){
clearTimeout(timeCounter);
}else{
timeCounter = setTimeout(countDownStart,nextTime);
}
}
運行結(jié)果:
結(jié)論:由于線程阻塞延遲問題高诺,做了setTimeout執(zhí)行時間的誤差修正碌识,保證setTimeout執(zhí)行時間一致。若凍結(jié)時間特別長的虱而,還要做特殊處理筏餐。