如何让秒杀、活动倒计时更“精确”?
背景
前端網(wǎng)頁(yè)倒計(jì)時(shí)是非常常見的應(yīng)用,我們?cè)诟鞔筚?gòu)物網(wǎng)站的秒殺活動(dòng)中總是能見到它的身影。但是在實(shí)際情況中,我們常常會(huì)發(fā)現(xiàn)當(dāng)網(wǎng)頁(yè)不刷新、讓倒計(jì)時(shí)程序持續(xù)運(yùn)行時(shí),顯示時(shí)間相比實(shí)際時(shí)間會(huì)越來(lái)越慢,相信大家也有在秒殺時(shí)間即
將到來(lái)時(shí)不停刷新頁(yè)面的經(jīng)歷。原因自然也不難理解:倒計(jì)時(shí)通常使用定時(shí)器(setTimeout或者setInterval)實(shí)現(xiàn),首先我們明白,因?yàn)?code>JavaScript是單線程的,在事件循環(huán)過程中,當(dāng)前宏觀任務(wù)隊(duì)列中的微觀任務(wù)會(huì)阻塞下一個(gè)宏
觀任務(wù)隊(duì)列中任務(wù)的執(zhí)行。所以會(huì)造成一種現(xiàn)象,定時(shí)器中的真實(shí)執(zhí)行時(shí)間并不會(huì)精準(zhǔn)的按照第2個(gè)參數(shù)所設(shè)定的數(shù)值執(zhí)行。比如設(shè)置1000毫秒,如果到了1000毫秒,主線程被其他任務(wù)所占用了,那么就會(huì)等待其它任務(wù)的執(zhí)行,等其它任
務(wù)執(zhí)行完畢后,才會(huì)執(zhí)行定時(shí)器的回調(diào)函數(shù)。也就是說(shuō),如下代碼代表的意思不是1秒后執(zhí)行,而是最快1秒后執(zhí)行。(JavaScript 的單線程特性使得主線程執(zhí)行棧中出現(xiàn)阻塞時(shí),任務(wù)隊(duì)列中的異步任務(wù)并不能及時(shí)執(zhí)行,因此瀏覽器并不能保
證在定時(shí)器設(shè)置的時(shí)間結(jié)束后代碼總是被準(zhǔn)時(shí)執(zhí)行,這就造成了倒計(jì)時(shí)的偏差。)
setTimeout(() => {console.log('我是定時(shí)器!')},1000);
一般的解決方法是前端定時(shí)向服務(wù)器發(fā)送請(qǐng)求獲取最新的時(shí)間差來(lái)校準(zhǔn)倒計(jì)時(shí)時(shí)間,主動(dòng)(程序里設(shè)置定時(shí)請(qǐng)求)或被動(dòng)的(F5 已被用戶按壞)區(qū)別而已。這個(gè)方法簡(jiǎn)單但也有點(diǎn)粗暴。
計(jì)時(shí)器原理
倒計(jì)時(shí)功能離不開setTimeout或setInterval這兩個(gè)函數(shù),要用好這兩個(gè)函數(shù)必先了解好Javascript解釋器的工作原理
前端開發(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í)實(shí)現(xiàn)原理
基本的一個(gè)倒計(jì)時(shí)的原理非常簡(jiǎn)單了,使用setTimout或者setInterval來(lái)對(duì)一個(gè)函數(shù)進(jìn)行遞歸或者重復(fù)調(diào)用,然后對(duì)DOM節(jié)點(diǎn)做對(duì)應(yīng)的render處理,并對(duì)時(shí)間做倒計(jì)時(shí)的格式化處理。
現(xiàn)有存在的問題
參考一
嘗試執(zhí)行如下代碼,會(huì)發(fā)現(xiàn)定時(shí)器的執(zhí)行時(shí)間應(yīng)該超過了1秒鐘,如果正常執(zhí)行,你可以從循環(huán)條件后面加個(gè)0。電腦配置很差的就不要試了。
setTimeout(() => {console.log('我是定時(shí)器!')}, 1000);
for (let i = 0; i<1000000000; i++) {}
碰到這種循環(huán)或者遞歸代碼時(shí),回調(diào)函數(shù)的執(zhí)行時(shí)間會(huì)根據(jù)不同的電腦運(yùn)算速度決定。如果你的電腦配置夠強(qiáng),比如小型機(jī),高性能服務(wù)器等,能夠在1秒以內(nèi)執(zhí)行完邏輯,那么就不會(huì)影響定時(shí)器的正常執(zhí)行。
要想做到時(shí)間相對(duì)準(zhǔn)確,就必須解決這個(gè)問題,辦法有很多種,最常見也最有效的辦法,是在當(dāng)前定時(shí)器的回調(diào)函數(shù)中校驗(yàn)誤差并調(diào)整下一次定時(shí)器的發(fā)生時(shí)間,達(dá)到平均1秒的效果。(也就是下面介紹的解決思路實(shí)現(xiàn))
參考二
用現(xiàn)有 mobi 手機(jī)端歡迎頁(yè)倒計(jì)時(shí)為例,以下是功能截圖。
代碼如下:
 var second = 10; // 倒計(jì)時(shí)時(shí)間為 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)); // 這里代碼運(yùn)行結(jié)果,定時(shí)器每秒執(zhí)行一次,每次輸出應(yīng)該是0 。
    timer_div.html('<div>' + second + 's</div>');
    second--;
  }
以上代碼實(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í)行延遲越來(lái)越嚴(yán)重。
以上的阻塞線程的代碼還不算很極端,假如在執(zhí)行定時(shí)器的過程中有同步 ui 事件的代碼,同步代碼會(huì)立即執(zhí)行。實(shí)際上在移動(dòng)端的滾動(dòng)頁(yè)面中是有可能出現(xiàn)這種情況的,以下是一個(gè)例子。
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);
時(shí)間線對(duì)比:
等待 3 秒 |----1s----|----2s----|----3s----|--->console.log("Done!");
經(jīng)過 2 秒 |----1s----|----2s----| ----------|-->console.log("Done!");
點(diǎn)擊 body 后
以為是這樣:|----1s----|----2s----|----3s----|--->console.log("Done!")--->|------------------10s----------------|
其實(shí)是這樣:|----1s----|----2s----|------------------10s----------------|--->console.log("Done!");
結(jié)論:如果有同步的 ui 事件代碼出現(xiàn),實(shí)際功能的倒計(jì)時(shí)基本“失效”了,這時(shí)不同瀏覽器打開相同的倒計(jì)時(shí)頁(yè)面往往誤差非常大。
解決思路
分析一下從獲取服務(wù)器時(shí)間到前端顯示倒計(jì)時(shí)的過程:
客戶端 http 請(qǐng)求服務(wù)器時(shí)間;
服務(wù)器響應(yīng)完成;
服務(wù)器通過網(wǎng)絡(luò)傳輸時(shí)間數(shù)據(jù)到客戶端;
客戶端根據(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ì)算出來(lái),一般是幾十 ms 以內(nèi),大的可能有幾百 ms 。
可以得出:當(dāng)前服務(wù)器時(shí)間 = 服務(wù)器系統(tǒng)返回時(shí)間 + 網(wǎng)絡(luò)傳輸時(shí)間 + 前端渲染時(shí)間 + 常量(可選),這里重點(diǎn)是說(shuō)要考慮前端渲染的時(shí)間,避免不同瀏覽器渲染快慢差異造成明顯的時(shí)間不同步,這是第一點(diǎn)。(網(wǎng)絡(luò)傳輸時(shí)間忽略或加個(gè)
常量),前端渲染時(shí)間可以在服務(wù)器返回當(dāng)前時(shí)間和本地前端的時(shí)間的差值得出。
獲得服務(wù)器時(shí)間后,前端進(jìn)入倒計(jì)時(shí)計(jì)算和計(jì)時(shí)器顯示,這步就要考慮 js 代碼凍結(jié)和線程阻塞造成計(jì)時(shí)器延時(shí)問題了,思路是通過引入計(jì)數(shù)器,判斷計(jì)時(shí)器延遲執(zhí)行的時(shí)間來(lái)調(diào)整,盡量讓誤差縮小,不同瀏覽器不同時(shí)間段打開頁(yè)面倒計(jì)時(shí)
誤差可控制在 1s 以內(nèi)。
// 繼續(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)的,還要做特殊處理。
代碼的基本原理并不復(fù)雜:通過遞歸調(diào)用setTimeout進(jìn)行倒計(jì)時(shí)操作的執(zhí)行。而每次執(zhí)行函數(shù)時(shí)會(huì)維護(hù)一個(gè) count 變量,用以記錄已經(jīng)執(zhí)行過的倒計(jì)時(shí)次數(shù),使用代碼 A 處的公式可計(jì)算出當(dāng)前執(zhí)行倒計(jì)時(shí)的時(shí)間與實(shí)際應(yīng)執(zhí)行時(shí)間的偏
差,進(jìn)而可以計(jì)算出下次執(zhí)行倒計(jì)時(shí)的時(shí)間。
倒計(jì)時(shí)組件
組件:http://imgcache.gtimg.cn/club/common/lib/zero/widgets/date/Date.1.1.1.js
文檔:http://docs.oa.com/p/zero_widgets
應(yīng)用項(xiàng)目地址:http://m.vip.qq.com/clubact/2014/jdfl/index.html?_wv=1
總結(jié)
做100%精確的倒計(jì)時(shí)很難,但做到相對(duì)比較準(zhǔn)確是可以的。
在倒計(jì)時(shí)功能開發(fā)中,有幾點(diǎn)總結(jié):
1. 要了解好js單線程工作原理;
2. 清楚了解服務(wù)器系統(tǒng)時(shí)間傳送到前端的流程;
3. 了解前端渲染和線程阻塞造成的時(shí)間誤差;
參考
如何讓秒殺、活動(dòng)倒計(jì)時(shí)更“精確”?(推薦)
JS實(shí)現(xiàn)活動(dòng)精確倒計(jì)時(shí)(推薦,與1相似,比1更全面)
前端如何實(shí)現(xiàn)一個(gè)倒計(jì)時(shí)組件?(推薦,react中倒計(jì)時(shí)組件使用和web worker使用)
你真的知道怎么用javascript來(lái)寫一個(gè)倒計(jì)時(shí)嗎 ?
總結(jié)
以上是生活随笔為你收集整理的如何让秒杀、活动倒计时更“精确”?的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: [转]订制CentOS自安装光盘
- 下一篇: Kali-linux查看打开的端口
