背景
前端頁面倒計(jì)時(shí)功能在很多場(chǎng)景中會(huì)用到,如運(yùn)營(yíng)活動(dòng)開始倒計(jì)時(shí)和活動(dòng)結(jié)束倒計(jì)時(shí),又如購物網(wǎng)站的秒殺倒計(jì)時(shí),搶購倒計(jì)時(shí),還有我們手Q春節(jié)搶紅包倒計(jì)時(shí)等等……. 最近的話費(fèi)代付項(xiàng)目中,也涉及倒計(jì)時(shí)功能,但在開發(fā)過程中遇到一些麻煩和坑點(diǎn),下面和大家分享一下最后是如何解決的。
坑點(diǎn)
手Q春節(jié)搶明星紅包活動(dòng),就有產(chǎn)品吐槽兩個(gè)手機(jī)在不同時(shí)間點(diǎn)打開同一個(gè)活動(dòng)顯示的開搶倒計(jì)時(shí)不一樣,誤差大的甚至相差幾分鐘,導(dǎo)致某些用戶在活動(dòng)顯示還未開始紅包就已被搶完了。為什么誤差會(huì)這么大呢?
對(duì)于這個(gè)問題,前臺(tái)開發(fā)同學(xué)一般會(huì)猜測(cè)是這個(gè)原因:倒計(jì)時(shí)讀取了客戶端時(shí)間造成的,因?yàn)榭蛻舳藭r(shí)間和服務(wù)端時(shí)間有誤差,應(yīng)該讀取服務(wù)器時(shí)間。
是的,倒計(jì)時(shí)不應(yīng)該讀取客戶端時(shí)間,客戶端時(shí)間用戶可以隨時(shí)調(diào)整,會(huì)造成不一致,應(yīng)讀取服務(wù)器返回時(shí)間。但實(shí)踐證明,做了這一步還未夠,頁面運(yùn)行時(shí)間長(zhǎng)了,新開的頁面和原打開頁面還是存在誤差。
京東團(tuán)購也存在這個(gè)問題:
造成誤差的原因主要有幾種可能:
1. 沒有考慮js凍結(jié)運(yùn)行耗費(fèi)時(shí)間;(特別是移動(dòng)端容易出現(xiàn),下滑頁面時(shí)倒計(jì)時(shí)不動(dòng)了)
2. 沒有考慮頁面渲染和函數(shù)運(yùn)行累積時(shí)間;(京東的誤差貌似屬于這種)
3. 其他代碼邏輯問題(這種情況就復(fù)雜了,這里不討論);
計(jì)時(shí)器原理
倒計(jì)時(shí)功能離不開setTimeout或setInterval這兩個(gè)函數(shù),要用好這兩個(gè)函數(shù)必先了解好Javascript解釋器的工作原理,前端界大牛John.Resig (jQuery作者) 有篇文章很好講解了Javascript解釋器工作原理 — 《How JavaScript Timers Work》。
前端開發(fā)同學(xué)都知道,javascript是單線程的(web worker除外),更好理解的解釋是javascript解釋器是單線程工作,它不能在處理一個(gè)ajax的callback的同時(shí)去處理click event的callback,而是必須按照先后隊(duì)列順序執(zhí)行。
這圖包含信息量很大,這里按照自己的理解描述一下:
這圖從上往下看,垂直方向是時(shí)間,以ms為單位,藍(lán)色模塊是執(zhí)行代碼所占的時(shí)間段,如第一個(gè)代碼模塊執(zhí)行js占用了約18ms, 第二個(gè)模塊執(zhí)行js占用了約11ms,其他模塊類似。由于js是單線程執(zhí)行,同一時(shí)間只能執(zhí)行一個(gè)js代碼(同一時(shí)間其他異步事件執(zhí)行會(huì)被阻塞 ) , 當(dāng)異步事件發(fā)生時(shí),它會(huì)進(jìn)入代碼執(zhí)行隊(duì)列,執(zhí)行線程空閑時(shí)依照隊(duì)列順序依次執(zhí)行代碼。
第一個(gè)模塊初始化了兩個(gè)定時(shí)器,一個(gè)10ms延遲的setTimeout和10ms的setInterval。這些定時(shí)器可能會(huì)在我們第一個(gè)代碼塊執(zhí)行結(jié)束之前就觸發(fā),這取決于定時(shí)器在第一個(gè)代碼塊中啟動(dòng)的位置和時(shí)間。注意,定時(shí)器雖然觸發(fā)了,但是并不會(huì)立即執(zhí)行,它只是把需要延遲執(zhí)行的函數(shù)按時(shí)間先后加入了執(zhí)行隊(duì)列,在線程的某一個(gè)空閑的時(shí)間點(diǎn),這個(gè)函數(shù)就能夠得到執(zhí)行。
按照第一個(gè)模塊事件觸發(fā)的順序(Mouse Click Occurs -. 10ms Timer Fires),第一個(gè)模塊代碼執(zhí)行結(jié)束后,按照隊(duì)列中等待的先后順序執(zhí)行事件,先執(zhí)行Mouse Click CallBack再執(zhí)行Timer。在執(zhí)行Mouse Click CallBack模塊時(shí),Interval第一次觸發(fā)未執(zhí)行加入隊(duì)列。在執(zhí)行Timer模塊時(shí),Interval第二次觸發(fā)未執(zhí)行加入隊(duì)列。待Mouse Click CallBack和Timer模塊都執(zhí)行完畢后,再依次執(zhí)行隊(duì)列中已觸發(fā)的Interval事件。后面模塊由于沒有阻塞的事件了,所以按照既定10ms執(zhí)行Interval事件。
倒計(jì)時(shí)問題
如果上面Javascript計(jì)時(shí)器原理理解了,就很好明白倒計(jì)時(shí)功能存在問題的隱患。
先看一段測(cè)試代碼:
var start = new Date().getTime();
var count = 0;
//定時(shí)器測(cè)試
setInterval(function(){
count++;
console.log( new Date().getTime() - (start + count * 1000));
},1000);
目測(cè)代碼就知道運(yùn)行結(jié)果,定時(shí)器每秒執(zhí)行一次,每次輸出應(yīng)該是0 。
實(shí)際輸出:
結(jié)論:由于代碼執(zhí)行占用時(shí)間和其他事件阻塞原因,導(dǎo)致有些事件執(zhí)行延遲了幾ms,但影響很微。
下面加一段阻塞代碼看看:
var start = new Date().getTime();
var count = 0;
//占用線程事件
setInterval(function(){
var j = 0;
while(j++ < 100000000);
}, 0);
//定時(shí)器測(cè)試
setInterval(function(){
count++;
console.log( new Date().getTime() - (start + count * 1000));
},1000);
實(shí)際輸出:
結(jié)論:由于加了很占線程的阻塞事件,導(dǎo)致定時(shí)器事件每次執(zhí)行延遲越來越嚴(yán)重。
由于實(shí)際項(xiàng)目中,執(zhí)行計(jì)時(shí)器的同時(shí),會(huì)有很多其他異步阻塞事件,會(huì)導(dǎo)致倒計(jì)時(shí)功能不精確。
解決思路
這里先分析一下從獲取服務(wù)器時(shí)間到前端顯示倒計(jì)時(shí)的過程:
1. 客戶端http請(qǐng)求服務(wù)器時(shí)間;
2. 服務(wù)器響應(yīng)完成;
3. 服務(wù)器通過網(wǎng)絡(luò)傳輸時(shí)間數(shù)據(jù)到客戶端;
4. 客戶端根據(jù)活動(dòng)開始時(shí)間和服務(wù)器時(shí)間差做倒計(jì)時(shí)顯示;
服務(wù)器響應(yīng)完成的時(shí)間其實(shí)就是服務(wù)器時(shí)間,但經(jīng)過網(wǎng)絡(luò)傳輸這一步,就會(huì)產(chǎn)生誤差了,誤差大小視網(wǎng)絡(luò)環(huán)境而異,這部分時(shí)間前端也沒有什么好辦法計(jì)算出來,一般是幾十ms以內(nèi),大的可能有幾百ms。
可以得出:當(dāng)前服務(wù)器時(shí)間 = 服務(wù)器系統(tǒng)返回時(shí)間 + 網(wǎng)絡(luò)傳輸時(shí)間 + 前端渲染時(shí)間 + 常量(可選),這里重點(diǎn)是說要考慮前端渲染的時(shí)間,避免不同瀏覽器渲染快慢差異造成明顯的時(shí)間不同步,這是第一點(diǎn)。(網(wǎng)絡(luò)傳輸時(shí)間忽略或加個(gè)常量唄)
獲得服務(wù)器時(shí)間后,前端進(jìn)入倒計(jì)時(shí)計(jì)算和計(jì)時(shí)器顯示,這步就要考慮js代碼凍結(jié)和線程阻塞造成計(jì)時(shí)器延時(shí)問題了,我的思路是通過引入計(jì)數(shù)器,判斷計(jì)時(shí)器延遲執(zhí)行的時(shí)間來調(diào)整,盡量讓誤差縮小,不同瀏覽器不同時(shí)間段打開頁面倒計(jì)時(shí)誤差可控制在1s以內(nèi)。
關(guān)鍵實(shí)現(xiàn)代碼如下:
//繼續(xù)線程占用
setInterval(function(){
var j = 0;
while(j++ < 100000000);
}, 0);
//倒計(jì)時(shí)
var interval = 1000,
ms = 50000, //從服務(wù)器和活動(dòng)開始時(shí)間計(jì)算出的時(shí)間差,這里測(cè)試用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后,離活動(dòng)開始還有:" + ms + "ms");
if(ms < 0){
clearTimeout(timeCounter);
}else{
timeCounter = setTimeout(countDownStart,nextTime);
}
}
運(yùn)行結(jié)果:
結(jié)論:由于線程阻塞延遲問題,做了setTimeout執(zhí)行時(shí)間的誤差修正,保證setTimeout執(zhí)行時(shí)間一致。若凍結(jié)時(shí)間特別長(zhǎng)的,還要做特殊處理。