JS定時(shí)器不可靠的原因及解決方案
前言
在工作中應(yīng)用定時(shí)器的場景非常多,但你會(huì)發(fā)現(xiàn)有時(shí)候定時(shí)器好像并沒有按照我們的預(yù)期去執(zhí)行,比如我們常遇到的setTimeout(()=>{},0)它有時(shí)候并不是按我們預(yù)期的立馬就執(zhí)行。想要知道為什么會(huì)這樣,我們首先需要了解Javascript計(jì)時(shí)器的工作原理。
定時(shí)器工作原理
為了理解計(jì)時(shí)器的內(nèi)部工作原理,我們首先需要了解一個(gè)非常重要的概念:計(jì)時(shí)器設(shè)定的延時(shí)是沒有保證的。因?yàn)樗性跒g覽器中執(zhí)行的JavaScript單線程異步事件(比如鼠標(biāo)點(diǎn)擊事件和計(jì)時(shí)器)都只有在它有空的時(shí)候才執(zhí)行。
這么說可能不是很清晰,我們來看下面這張圖

圖中有很多信息需要消化,但是完全理解它會(huì)讓您更好地了解異步JavaScript執(zhí)行是如何工作的。這張圖是一維的:垂直方向是(掛鐘)時(shí)間,單位是毫秒。藍(lán)色框表示正在執(zhí)行的JavaScript部分。例如,第一個(gè)JavaScript塊執(zhí)行大約18ms,鼠標(biāo)點(diǎn)擊塊執(zhí)行大約11ms,以此類推。
? 由于JavaScript一次只能執(zhí)行一段代碼(由于它的單線程特性),所以每一段代碼都會(huì)“阻塞”其他異步事件的進(jìn)程。這意味著,當(dāng)異步事件發(fā)生時(shí)(如鼠標(biāo)單擊、計(jì)時(shí)器觸發(fā)或XMLHttpRequest完成),它將排隊(duì)等待稍后執(zhí)行。
? 首先,在JavaScript的第一個(gè)塊中,啟動(dòng)了兩個(gè)計(jì)時(shí)器:一個(gè)10ms的setTimeout和一個(gè)10ms的setInterval。由于計(jì)時(shí)器是在哪里和什么時(shí)候啟動(dòng)的,它實(shí)際上在我們實(shí)際完成第一個(gè)代碼塊之前觸發(fā),但是請(qǐng)注意,它不會(huì)立即執(zhí)行(由于線程的原因,它無法這樣做)。相反,被延遲的函數(shù)被排隊(duì),以便在下一個(gè)可用的時(shí)刻執(zhí)行。
? 此外,在第一個(gè)JavaScript塊中,我們看到鼠標(biāo)單擊發(fā)生。與此異步事件相關(guān)聯(lián)的JavaScript回調(diào)(我們永遠(yuǎn)不知道用戶何時(shí)會(huì)執(zhí)行某個(gè)動(dòng)作,因此它被認(rèn)為是異步的)無法立即執(zhí)行,因此,就像初始計(jì)時(shí)器一樣,它被排隊(duì)等待稍后執(zhí)行。
? 在JavaScript的初始?jí)K完成執(zhí)行后,瀏覽器會(huì)立即問一個(gè)問題:等待執(zhí)行的是什么?在本例中,鼠標(biāo)單擊處理程序和計(jì)時(shí)器回調(diào)都在等待。然后瀏覽器選擇一個(gè)(鼠標(biāo)點(diǎn)擊回調(diào))并立即執(zhí)行它。計(jì)時(shí)器將等待到下一個(gè)可能的時(shí)間,以便執(zhí)行。
setInterval調(diào)用被廢棄
在click事件執(zhí)行時(shí),第20毫秒處,第二個(gè)setInterval也到期了,因?yàn)榇藭r(shí)已經(jīng)click事件占用了線程,所以setInterval還是不能被執(zhí)行,并且因?yàn)榇藭r(shí)隊(duì)列中已經(jīng)有一個(gè)setInterval正在排隊(duì)等待執(zhí)行,所以這一次的setInterval的調(diào)用將被廢棄。
瀏覽器不會(huì)對(duì)同一個(gè)setInterval處理程序多次添加到待執(zhí)行隊(duì)列。
? 實(shí)際上,我們可以看到,當(dāng)?shù)谌齻€(gè)interval回調(diào)被觸發(fā)時(shí),interval本身正在執(zhí)行。這向我們展示了一個(gè)重要的事實(shí):interval并不關(guān)心當(dāng)前執(zhí)行的是什么,它們將不加區(qū)別地排隊(duì),即使這意味著回調(diào)之間的時(shí)間間隔將被犧牲。
setTimeout/setInterval無法保證準(zhǔn)時(shí)執(zhí)行回調(diào)函數(shù)
? 最后,在第二個(gè)interval回調(diào)執(zhí)行完成后,我們可以看到JavaScript引擎沒有任何東西可以執(zhí)行了。這意味著瀏覽器現(xiàn)在等待一個(gè)新的異步事件發(fā)生。當(dāng)interval再次觸發(fā)時(shí),我們會(huì)在50ms處得到這個(gè)值。但是這一次,沒有任何東西阻礙它的執(zhí)行,因此它立即觸發(fā)。
OK,總的來說造成JS定時(shí)器不可靠的原因就是JavaScript是單線程的,一次只能執(zhí)行一個(gè)任務(wù),而setTimeout() 的第二個(gè)參數(shù)(延時(shí)時(shí)間)只是告訴 JavaScript 再過多長時(shí)間把當(dāng)前任務(wù)添加到隊(duì)列中。如果隊(duì)列是空的,那么添加的代碼會(huì)立即執(zhí)行;如果隊(duì)列不是空的,那么它就要等前面的代碼執(zhí)行完了以后再執(zhí)行定時(shí)器任務(wù)必須等主線程任務(wù)執(zhí)行才可能開始執(zhí)行,無論它是否到達(dá)我們?cè)O(shè)置的時(shí)間
這里我們可以再來了解下Javascript的事件循環(huán)
事件循環(huán)
JavaScript中所有的任務(wù)分為同步任務(wù)與異步任務(wù),同步任務(wù),顧名思義就是立即執(zhí)行的任務(wù),它一般是直接進(jìn)入到主線程中執(zhí)行。而我們的異步任務(wù)則是進(jìn)入任務(wù)隊(duì)列等待主線程中的任務(wù)執(zhí)行完再執(zhí)行。
任務(wù)隊(duì)列是一個(gè)事件的隊(duì)列,表示相關(guān)的異步任務(wù)可以進(jìn)入執(zhí)行棧了。主線程讀取任務(wù)隊(duì)列就是讀取里面有哪些事件。
隊(duì)列是一種先進(jìn)先出的數(shù)據(jù)結(jié)構(gòu)。
上面我們說到異步任務(wù)又可以分為宏任務(wù)與微任務(wù),所以任務(wù)隊(duì)列也可以分為宏任務(wù)隊(duì)列與微任務(wù)隊(duì)列
- Macrotask Queue:進(jìn)行比較大型的工作,常見的有setTimeout,setInterval,用戶交互操作,UI渲染等;
- Microtask Queue:進(jìn)行較小的工作,常見的有Promise,Process.nextTick;
- 同步任務(wù)直接放入到主線程執(zhí)行,異步任務(wù)(點(diǎn)擊事件,定時(shí)器,ajax等)掛在后臺(tái)執(zhí)行,等待I/O事件完成或行為事件被觸發(fā)。
- 系統(tǒng)后臺(tái)執(zhí)行異步任務(wù),如果某個(gè)異步任務(wù)事件(或者行為事件被觸發(fā)),則將該任務(wù)添加到任務(wù)隊(duì)列,并且每個(gè)任務(wù)會(huì)對(duì)應(yīng)一個(gè)回調(diào)函數(shù)進(jìn)行處理。
- 這里異步任務(wù)分為宏任務(wù)與微任務(wù),宏任務(wù)進(jìn)入到宏任務(wù)隊(duì)列,微任務(wù)進(jìn)入到微任務(wù)隊(duì)列。
- 執(zhí)行任務(wù)隊(duì)列中的任務(wù)具體是在執(zhí)行棧中完成的,當(dāng)主線程中的任務(wù)全部執(zhí)行完畢后,去讀取微任務(wù)隊(duì)列,如果有微任務(wù)就會(huì)全部執(zhí)行,然后再去讀取宏任務(wù)隊(duì)列
- 上述過程會(huì)不斷的重復(fù)進(jìn)行,也就是我們常說的事件循環(huán)(Event-Loop)。

這里更詳細(xì)的內(nèi)容可以看我之前的文章探索JavaScript執(zhí)行機(jī)制
導(dǎo)致定時(shí)器不可靠的原因
當(dāng)前任務(wù)執(zhí)行時(shí)間過久
JS 引擎會(huì)先執(zhí)行同步的代碼之后才會(huì)執(zhí)行異步的代碼,如果同步的代碼執(zhí)行時(shí)間過久,是會(huì)導(dǎo)致異步代碼延遲執(zhí)行的。
setTimeout(() => {
console.log(1);
}, 20);
for (let i = 0; i < 90000000; i++) { }
setTimeout(() => {
console.log(2);
}, 0);這個(gè)按預(yù)期應(yīng)該是會(huì)先打印出2,然后再打印1,但事實(shí)并不是如此,就算第二個(gè)定時(shí)器的時(shí)間更短,但中間那個(gè)for循環(huán)的執(zhí)行時(shí)間遠(yuǎn)遠(yuǎn)超過了這兩個(gè)定時(shí)器設(shè)定的時(shí)間。
setTimeout 設(shè)置的回調(diào)任務(wù)是 按照順序添加到延遲隊(duì)列里面的,當(dāng)執(zhí)行完一個(gè)任務(wù)之后,ProcessDelayTask 函數(shù)會(huì)根據(jù)發(fā)起時(shí)間和延遲時(shí)間來計(jì)算出到期的任務(wù),然后 依次執(zhí)行 這些到期的任務(wù)。
在執(zhí)行完前面的任務(wù)之后,上面例子的兩個(gè) setTimeout 都到期了,那么按照順序執(zhí)行就是打印 1 和 2。所以在這個(gè)場景下,setTimeout 就顯得不那么可靠了。
延遲執(zhí)行時(shí)間有最大值
包括 IE, Chrome, Safari, Firefox 在內(nèi)的瀏覽器其內(nèi)部以32位帶符號(hào)整數(shù)存儲(chǔ)延時(shí)。這就會(huì)導(dǎo)致如果一個(gè)延時(shí)(delay)大于 2147483647 毫秒 (大約24.8 天)時(shí)就會(huì)溢出,導(dǎo)致定時(shí)器將會(huì)被立即執(zhí)行。(MDN)
setTimeout 的第二個(gè)參數(shù)設(shè)置為 0 (未設(shè)置、小于 0、大于 2147483647 時(shí)都默認(rèn)為 0)的時(shí)候,意味著馬上執(zhí)行,或者盡快執(zhí)行。
setTimeout(function () {
console.log("你猜它什么時(shí)候打?。?)
}, 2147483648);把這段代碼放到瀏覽器控制臺(tái)執(zhí)行,你會(huì)發(fā)現(xiàn)它會(huì)立馬打印出 你猜它什么時(shí)候打???
最小延時(shí)>=4ms(嵌套使用定時(shí)器)
在瀏覽器中,setTimeout()/setInterval() 的每調(diào)用一次定時(shí)器的最小間隔是4ms,這通常是由于函數(shù)嵌套導(dǎo)致(嵌套層級(jí)達(dá)到一定深度),或者是由于已經(jīng)執(zhí)行的setInterval的回調(diào)函數(shù)阻塞導(dǎo)致的。
setTimeout的第二個(gè)參數(shù)設(shè)置為0(未設(shè)置、小于0、大于2147483647時(shí)都默認(rèn)為0)的時(shí)候,意味著馬上執(zhí)行,或者盡快執(zhí)行。- 如果延遲時(shí)間小于
0,則會(huì)把延遲時(shí)間設(shè)置為0。如果定時(shí)器嵌套5次以上并且延遲時(shí)間小于4ms,則會(huì)把延遲時(shí)間設(shè)置為4ms。
function cb() { f(); setTimeout(cb, 0); }
setTimeout(cb, 0);在Chrome 和 Firefox中, 定時(shí)器的第5次調(diào)用被阻塞了;在Safari是在第6次;Edge是在第3次。所以后面的定時(shí)器都最少被延遲了4ms
未被激活的tabs的定時(shí)最小延遲>=1000ms
瀏覽器為了優(yōu)化后臺(tái)tab的加載損耗(以及降低耗電量),在未被激活的tab中定時(shí)器的最小延時(shí)限制為1S(1000ms)。
let num = 100;
function setTime() {
// 當(dāng)前秒執(zhí)行的計(jì)時(shí)
console.log(`當(dāng)前秒數(shù):${new Date().getSeconds()} - 執(zhí)行次數(shù):${100-num}`);
num ? num-- && setTimeout(() => setTime(), 50) : "";
}
setTime();這里我在39秒時(shí)切到了其他標(biāo)簽頁,我們會(huì)發(fā)現(xiàn)它后面的執(zhí)行間隔都是1秒執(zhí)行一次,并不是我們?cè)O(shè)定的50ms。

setInterval的處理時(shí)長不能比設(shè)定的間隔長
setInterval的處理時(shí)長不能比設(shè)定的間隔長,否則setInterval將會(huì)沒有間隔的重復(fù)執(zhí)行
但是對(duì)這個(gè)問題,很多情況下,我們并不能清晰的把控處理程序所消耗的時(shí)長,為了能夠按照一定的間隔周期性的觸發(fā)定時(shí)器,我們可以使用setTimeout來代替setInterval執(zhí)行。
setTimeout(function fn(){
// todo
setTimeout(fn,10)
// 執(zhí)行完處理程序的內(nèi)容后,在末尾再間隔10毫秒來調(diào)用該程序,這樣就能保證一定是10毫秒的周期調(diào)用,這里時(shí)間按自己的需求來寫
},10)解決方案
方法一:requestAnimationFrame
window.requestAnimationFrame() 告訴瀏覽器——你希望執(zhí)行一個(gè)動(dòng)畫,并且要求瀏覽器在下次重繪之前調(diào)用指定的回調(diào)函數(shù)更新動(dòng)畫。該方法需要傳入一個(gè)回調(diào)函數(shù)作為參數(shù),該回調(diào)函數(shù)會(huì)在瀏覽器下一次重繪之前執(zhí)行,理想狀態(tài)下回調(diào)函數(shù)執(zhí)行次數(shù)通常是每秒60次(也就是我們所說的60fsp),也就是每16.7ms 執(zhí)行一次,但是并不一定保證為 16.7 ms。
const t = Date.now()
function mySetTimeout (cb, delay) {
let startTime = Date.now()
loop()
function loop () {
if (Date.now() - startTime >= delay) {
cb();
return;
}
requestAnimationFrame(loop)
}
}
mySetTimeout(()=>console.log('mySetTimeout' ,Date.now()-t),2000) //2005
setTimeout(()=>console.log('SetTimeout' ,Date.now()-t),2000) // 2002這種方案看起來像是增加了誤差,這是因?yàn)閞equestAnimationFrame每16.7ms 執(zhí)行一次,因此它不適用于間隔很小的定時(shí)器修正。
方法二: Web Worker
Web Worker為Web內(nèi)容在后臺(tái)線程中運(yùn)行腳本提供了一種簡單的方法。線程可以執(zhí)行任務(wù)而不干擾用戶界面。此外,他們可以使用XMLHttpRequest執(zhí)行 I/O (盡管responseXML和channel屬性總是為空)。一旦創(chuàng)建, 一個(gè)worker 可以將消息發(fā)送到創(chuàng)建它的JavaScript代碼, 通過將消息發(fā)布到該代碼指定的事件處理程序(反之亦然)。
Web Worker 的作用就是為 JavaScript 創(chuàng)造多線程環(huán)境,允許主線程創(chuàng)建 Worker 線程,將一些任務(wù)分配給后者運(yùn)行。在主線程運(yùn)行的同時(shí),Worker 線程在后臺(tái)運(yùn)行,兩者互不干擾。等到 Worker 線程完成計(jì)算任務(wù),再把結(jié)果返回給主線程。這樣的好處是,一些計(jì)算密集型或高延遲的任務(wù),被 Worker 線程負(fù)擔(dān)了,主線程不會(huì)被阻塞或拖慢。
// index.js
let count = 0;
//耗時(shí)任務(wù)
setInterval(function(){
let i = 0;
while(i++ < 100000000);
}, 0);
// worker
let worker = new Worker('./worker.js')
// worker.js
let startTime = new Date().getTime();
let count = 0;
setInterval(function(){
count++;
console.log(count + ' --- ' + (new Date().getTime() - (startTime + count * 1000)));
}, 1000);
這種方案體驗(yàn)整體上來說還是比較好的,既能較大程度修正計(jì)時(shí)器也不影響主進(jìn)程任務(wù)
總結(jié)
由于js的單線程特性,所以會(huì)有事件排隊(duì)、先進(jìn)先出、setInterval調(diào)用被廢棄、定時(shí)器無法保證準(zhǔn)時(shí)執(zhí)行回調(diào)函數(shù)以及出現(xiàn)setInterval的連續(xù)執(zhí)行。
到此這篇關(guān)于JS定時(shí)器不可靠的原因及解決方案的文章就介紹到這了,更多相關(guān)JS定時(shí)器內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
調(diào)試Javascript代碼(瀏覽器F12及VS中debugger關(guān)鍵字)
目前,常用的瀏覽器IE、Chrome、Firefox都有相應(yīng)的腳本調(diào)試功能下面我就介紹如何在瀏覽器/VS中調(diào)試我們的JS代碼,感興趣的你可不要走開啊,希望本文對(duì)你有所幫助2013-01-01
JavaScript性能陷阱小結(jié)(附實(shí)例說明)
JavaScript陷阱太多,因此我們得步步為營,下面這些點(diǎn),相信很多同學(xué)都會(huì)遇到,希望朋友們多注意下。JavaScript陷阱太多,因此我們得步步為營,下面是一些常見的影響性能的陷阱。2010-12-12
Javascript獲取數(shù)組中的最大值和最小值的方法匯總
比較數(shù)組中數(shù)值的大小是比較常見的操作,下面同本文給大家分享四種放哪廣發(fā)獲取數(shù)組中最大值和最小值,對(duì)此感興趣的朋友一起學(xué)習(xí)吧2016-01-01
淺述節(jié)點(diǎn)的創(chuàng)建及常見功能的實(shí)現(xiàn)
本文主要對(duì)節(jié)點(diǎn)的創(chuàng)建及常見功能的實(shí)現(xiàn)方法進(jìn)行介紹,希望會(huì)對(duì)大家學(xué)習(xí)javascript有所幫助,下面就跟小編一起來看下吧2016-12-12
JavaScript實(shí)現(xiàn)簡單評(píng)論功能
這篇文章主要為大家詳細(xì)介紹了JavaScript實(shí)現(xiàn)簡單評(píng)論功能,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-08-08
JS實(shí)現(xiàn)的倒計(jì)時(shí)效果實(shí)例(2則實(shí)例)
這篇文章主要介紹了JS實(shí)現(xiàn)的倒計(jì)時(shí)效果,列舉了兩則JavaScript倒計(jì)時(shí)效果代碼供大家參考,原理基本相似,代碼簡潔實(shí)用,需要的朋友可以參考下2015-12-12

