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

