JS中setTimeout導(dǎo)致倒計(jì)時(shí)誤差的三個(gè)優(yōu)化方法詳解
你肯定遇到過(guò)這種情況。頁(yè)面上有一個(gè)倒計(jì)時(shí),顯示“距離活動(dòng)結(jié)束還有 10 秒”。你屏住呼吸,準(zhǔn)備在最后一刻點(diǎn)擊搶購(gòu)按鈕。但奇怪的是,倒計(jì)時(shí)從 10 跳到 9 時(shí),好像停頓了一下,或者跳得特別快。最終,你點(diǎn)擊按鈕時(shí),系統(tǒng)提示“活動(dòng)已結(jié)束”。
這不是你的錯(cuò)覺(jué)。前端實(shí)現(xiàn)的倒計(jì)時(shí),確實(shí)存在誤差。今天,我們就來(lái)聊聊這個(gè)誤差是怎么產(chǎn)生的,以及我們能做些什么來(lái)減小它。
誤差從何而來(lái)
要理解誤差,我們得先看看最常見(jiàn)的前端倒計(jì)時(shí)是怎么工作的。
1. 核心機(jī)制:setInterval與setTimeout
大多數(shù)倒計(jì)時(shí)使用 JavaScript 的 setInterval 或遞歸的 setTimeout 來(lái)實(shí)現(xiàn)。代碼邏輯很簡(jiǎn)單:
- 設(shè)定一個(gè)目標(biāo)時(shí)間(比如活動(dòng)結(jié)束時(shí)間)。
- 每秒執(zhí)行一次函數(shù),計(jì)算“當(dāng)前時(shí)間”與“目標(biāo)時(shí)間”的差值。
- 將這個(gè)差值轉(zhuǎn)換成天、時(shí)、分、秒,顯示在頁(yè)面上。 看起來(lái)天衣無(wú)縫,對(duì)嗎?問(wèn)題就藏在“每秒執(zhí)行一次”這個(gè)動(dòng)作里。
2. 誤差的三大“元兇”
元兇一:JavaScript 的單線(xiàn)程與事件循環(huán)
JavaScript 是單線(xiàn)程語(yǔ)言。這意味著它一次只能做一件事。setInterval 和 setTimeout 指定的延遲時(shí)間,并不是精確的“等待 X 毫秒后執(zhí)行”,而是“等待至少 X 毫秒后,將回調(diào)函數(shù)放入任務(wù)隊(duì)列”。
什么時(shí)候執(zhí)行呢?要等主線(xiàn)程上當(dāng)前的任務(wù)都執(zhí)行完了,才會(huì)從隊(duì)列里取出這個(gè)回調(diào)來(lái)執(zhí)行。
想象一下:
- 你設(shè)定
setInterval(fn, 1000),希望每秒跑一次。 - 第0秒,
fn執(zhí)行了。 - 第1秒,
fn被放入隊(duì)列。但此時(shí)主線(xiàn)程正在處理一個(gè)復(fù)雜的動(dòng)畫(huà)計(jì)算,花了 200 毫秒。 - 結(jié)果,
fn直到第1.2秒才真正開(kāi)始執(zhí)行。
這就產(chǎn)生了至少 200 毫秒的延遲。
元兇二:瀏覽器標(biāo)簽頁(yè)休眠
為了節(jié)省電量,當(dāng)用戶(hù)切換到其他標(biāo)簽頁(yè)或最小化瀏覽器時(shí),當(dāng)前頁(yè)面的 setInterval 和 setTimeout 會(huì)被“限流”。它們的執(zhí)行頻率會(huì)大大降低,可能變成每秒一次,甚至更慢。
如果你的倒計(jì)時(shí)在后臺(tái)運(yùn)行了5分鐘,再切回來(lái),它可能直接顯示“已結(jié)束”,或者時(shí)間跳了一大截。
元兇三:系統(tǒng)時(shí)間依賴(lài)
很多倒計(jì)時(shí)是這樣計(jì)算剩余時(shí)間的:
剩余秒數(shù) = Math.floor((目標(biāo)時(shí)間戳 - Date.now()) / 1000);
這里有兩個(gè)潛在問(wèn)題:
-
Date.now()的精度:它返回的是系統(tǒng)時(shí)間。如果用戶(hù)手動(dòng)修改了電腦時(shí)間,或者系統(tǒng)時(shí)間同步有微小偏差,倒計(jì)時(shí)就會(huì)出錯(cuò)。 - 計(jì)算時(shí)機(jī):這個(gè)計(jì)算發(fā)生在回調(diào)函數(shù)執(zhí)行的那一刻。如果回調(diào)函數(shù)本身被延遲了,那么用來(lái)計(jì)算的“當(dāng)前時(shí)刻”也已經(jīng)晚了。
如何減小誤差?試試這些方案
知道了原因,我們就可以對(duì)癥下藥。解決方案的目標(biāo)是:讓顯示的時(shí)間盡可能接近真實(shí)的世界時(shí)間。
方案一:優(yōu)化計(jì)時(shí)器邏輯
這是最基礎(chǔ)的改進(jìn),核心思想是:不依賴(lài)計(jì)時(shí)器的周期,而是依賴(lài)絕對(duì)時(shí)間。
具體做法:
在倒計(jì)時(shí)啟動(dòng)時(shí),記錄一個(gè)精確的開(kāi)始時(shí)間戳(startTime = Date.now())和目標(biāo)結(jié)束時(shí)間戳(endTime)。
在每次更新函數(shù)中,不再簡(jiǎn)單地“減1秒”,而是重新計(jì)算:
const now = Date.now(); const elapsed = now - startTime; // 已經(jīng)過(guò)去的時(shí)間 const remainingTime = endTime - now; // 剩余時(shí)間 const displaySeconds = Math.floor(remainingTime / 1000);
動(dòng)態(tài)調(diào)整下一次執(zhí)行的時(shí)間。例如,我們希望每 1000 毫秒更新一次顯示,但上次執(zhí)行晚了 50 毫秒,那么下次就只延遲 950 毫秒。
function updateTimer() {
// ... 計(jì)算并顯示時(shí)間
const deviation = Date.now() - (startTime + expectedElapsed); // 計(jì)算偏差
const nextTick = 1000 - deviation; // 調(diào)整下次間隔
setTimeout(updateTimer, Math.max(0, nextTick)); // 確保間隔不為負(fù)數(shù)
}
優(yōu)點(diǎn):
- 實(shí)現(xiàn)相對(duì)簡(jiǎn)單。
- 能有效抵消單次延遲的累積。
- 一次慢了,下次會(huì)找補(bǔ)回來(lái)一些。
缺點(diǎn):
- 無(wú)法解決瀏覽器標(biāo)簽頁(yè)休眠導(dǎo)致的長(zhǎng)時(shí)間停滯。
- 仍然依賴(lài)
Date.now(),受系統(tǒng)時(shí)間影響。
方案二:使用 Web Worker(隔離線(xiàn)程)
既然主線(xiàn)程繁忙會(huì)導(dǎo)致延遲,那我們就把計(jì)時(shí)任務(wù)放到一個(gè)獨(dú)立的線(xiàn)程里去。
Web Worker 可以讓腳本在后臺(tái)線(xiàn)程運(yùn)行。在這個(gè)線(xiàn)程里運(yùn)行的 setInterval 不容易被主線(xiàn)程的繁重任務(wù)阻塞。
實(shí)現(xiàn)思路:
- 創(chuàng)建一個(gè) Web Worker 文件(
timer.worker.js),在里面用setInterval向主線(xiàn)程發(fā)送消息。 - 主線(xiàn)程接收消息,更新界面。
優(yōu)點(diǎn):
- 計(jì)時(shí)更穩(wěn)定,受主線(xiàn)程影響小。
- 代碼分離,邏輯清晰。
缺點(diǎn):
- 仍然無(wú)法解決瀏覽器標(biāo)簽頁(yè)休眠限流的問(wèn)題。
- 增加了一定的架構(gòu)復(fù)雜度。
方案三:終極方案:服務(wù)器時(shí)間同步 + 前端補(bǔ)償
這是目前最精確、最可靠的方案。核心原則是:前端不再信任本地時(shí)間,而是以服務(wù)器時(shí)間為準(zhǔn),并持續(xù)校準(zhǔn)。
步驟拆解:
第一步:獲取權(quán)威的服務(wù)器時(shí)間
在頁(yè)面加載或倒計(jì)時(shí)開(kāi)始時(shí),向服務(wù)器發(fā)送一個(gè)請(qǐng)求。服務(wù)器在響應(yīng)中返回當(dāng)前的服務(wù)器時(shí)間戳。
注意:這個(gè)時(shí)間戳應(yīng)該放在 HTTP 響應(yīng)的 Date 頭或 body 里,避免受到網(wǎng)絡(luò)傳輸時(shí)間的影響。更專(zhuān)業(yè)的做法是,計(jì)算一個(gè)往返延遲(RTT),然后估算出當(dāng)前的準(zhǔn)確服務(wù)器時(shí)間。
第二步:在前端建立一個(gè)“虛擬的服務(wù)器時(shí)鐘”
我們不在前端直接使用 Date.now(),而是自己維護(hù)一個(gè)時(shí)鐘:
// 假設(shè)通過(guò) API 得到:serverTime 是服務(wù)器當(dāng)前時(shí)間,rtt 是網(wǎng)絡(luò)往返延遲
const initialServerTime = serverTime + rtt / 2; // 估算的準(zhǔn)確服務(wù)器時(shí)間
const localTimeAtThatMoment = Date.now();
// 此后,要獲取“當(dāng)前服務(wù)器時(shí)間”,就用這個(gè)公式:
function getCurrentServerTime() {
const nowLocal = Date.now();
const elapsedLocal = nowLocal - localTimeAtThatMoment;
return initialServerTime + elapsedLocal;
}
這個(gè)時(shí)鐘的原理是:服務(wù)器告訴我們一個(gè)“起點(diǎn)時(shí)間”,我們記錄下那個(gè)時(shí)刻的本地時(shí)間。之后,我們相信本地時(shí)間的流逝速度是基本準(zhǔn)確的(電腦的晶體振蕩器很穩(wěn)定),用本地流逝的時(shí)間加上服務(wù)器的起點(diǎn)時(shí)間,就得到了連續(xù)的“服務(wù)器時(shí)間”。
第三步:用這個(gè)虛擬時(shí)鐘驅(qū)動(dòng)倒計(jì)時(shí)
倒計(jì)時(shí)的更新函數(shù),使用 getCurrentServerTime() 來(lái)計(jì)算剩余時(shí)間,而不是 Date.now()。
第四步:定期校準(zhǔn)
本地時(shí)鐘的流逝速度可能有微小偏差(時(shí)鐘漂移)。我們可以設(shè)置一個(gè)間隔(比如每1分鐘或5分鐘),悄悄地再向服務(wù)器請(qǐng)求一次時(shí)間,來(lái)修正我們的 initialServerTime 和 localTimeAtThatMoment,讓虛擬時(shí)鐘始終與服務(wù)器保持同步。
這個(gè)方案的優(yōu)點(diǎn)非常突出:
- 抗干擾:用戶(hù)修改本地時(shí)間,完全不影響倒計(jì)時(shí)。
- 高精度:誤差主要來(lái)自時(shí)鐘漂移和網(wǎng)絡(luò)延遲,通過(guò)定期校準(zhǔn)可以控制在極低水平(百毫秒內(nèi))。
- 一致性:所有用戶(hù)看到的倒計(jì)時(shí)基于同一時(shí)間源,公平公正。
當(dāng)然,它的實(shí)現(xiàn)也最復(fù)雜,需要前后端配合。
實(shí)戰(zhàn)建議:如何選擇?
面對(duì)不同的場(chǎng)景,你可以這樣選擇:
對(duì)精度要求不高的展示型倒計(jì)時(shí)(如文章發(fā)布后的閱讀時(shí)間):使用方案一(優(yōu)化計(jì)時(shí)器邏輯) 就足夠了。簡(jiǎn)單有效。
營(yíng)銷(xiāo)活動(dòng)、秒殺搶購(gòu)倒計(jì)時(shí):必須使用方案三(服務(wù)器時(shí)間同步) 。這是保證公平性和準(zhǔn)確性的底線(xiàn)。
方案一和方案二可以作為輔助,讓更新更平滑。
到此這篇關(guān)于JS中setTimeout導(dǎo)致倒計(jì)時(shí)誤差的三個(gè)優(yōu)化方法詳解的文章就介紹到這了,更多相關(guān)JS setTimeout倒計(jì)時(shí)誤差優(yōu)化內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- JavaScript出現(xiàn)setTimeout倒計(jì)時(shí)誤差的原因分析
- JavaScript使用setTimeout實(shí)現(xiàn)倒計(jì)時(shí)效果
- js setTimeout()函數(shù)介紹及應(yīng)用以倒計(jì)時(shí)為例
- js中setTimeout導(dǎo)致try catch無(wú)法捕獲異常的問(wèn)題解決
- JavaScript中setTimeout使用重要的注意事項(xiàng)總結(jié)
- 淺析JavaScript定時(shí)器setTimeout的時(shí)延問(wèn)題
- JavaScript中setTimeout()的具體用法
相關(guān)文章
Next.js解決axios獲取真實(shí)ip問(wèn)題方法分析
這篇文章主要介紹了Next.js解決axios獲取真實(shí)ip問(wèn)題方法分析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-09-09
javascript對(duì)數(shù)組的常用操作代碼 數(shù)組方法總匯
js下數(shù)組也是比較常用的,對(duì)數(shù)組元素的處理也是有非常多的方法,這里整理了下,需要的朋友可以參考下。2011-01-01
詳解Webpack如何引入CDN鏈接來(lái)優(yōu)化編譯后的體積
這篇文章主要介紹了詳解Webpack如何引入CDN鏈接來(lái)優(yōu)化編譯后的體積,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-06-06
js和jquery實(shí)現(xiàn)監(jiān)聽(tīng)鍵盤(pán)事件示例代碼
這篇文章主要為大家介紹了js實(shí)現(xiàn)監(jiān)聽(tīng)鍵盤(pán)事件示例代碼,監(jiān)聽(tīng)鍵盤(pán)組合鍵CTRL+C,以便做出對(duì)應(yīng)的響應(yīng),文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-01-01
javascript實(shí)現(xiàn)的上下無(wú)縫滾動(dòng)效果
這篇文章主要介紹了javascript實(shí)現(xiàn)的上下無(wú)縫滾動(dòng)效果,具有無(wú)縫滾動(dòng)及響應(yīng)鼠標(biāo)滑過(guò)停止?jié)L動(dòng)的功能,簡(jiǎn)便實(shí)用,需要的朋友可以參考下2016-09-09
解決JavaScript中0.1+0.2不等于0.3問(wèn)題
這篇文章主要介紹了解決JavaScript中0.1+0.2不等于0.3問(wèn)題,需要的朋友可以參考下2018-10-10
Next.js路由組使用之組織路由結(jié)構(gòu)示例詳解
這篇文章主要為大家介紹了Next.js路由組使用之組織路由結(jié)構(gòu)示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-10-10

