前言
在工作中應用定時器的場景非常多,但你會發現有時候定時器好像並沒有按照我們的預期去執行,比如我們常遇到的setTimeout(()=>{},0)
它有時候並不是按我們預期的立馬就執行。想要知道為什么會這樣,我們首先需要了解Javascript計時器
的工作原理。
定時器工作原理
為了理解計時器的內部工作原理,我們首先需要了解一個非常重要的概念:計時器設定的延時是沒有保證的。因為所有在瀏覽器中執行的JavaScript單線程異步事件(比如鼠標點擊事件和計時器)都只有在它有空的時候才執行。
這么說可能不是很清晰,我們來看下面這張圖
圖中有很多信息需要消化,但是完全理解它會讓您更好地了解異步JavaScript執行是如何工作的。這張圖是一維的:垂直方向是(掛鍾)時間,單位是毫秒。藍色框表示正在執行的JavaScript部分。例如,第一個JavaScript塊執行大約18ms,鼠標點擊塊執行大約11ms,以此類推。
由於JavaScript一次只能執行一段代碼(由於它的單線程特性),所以每一段代碼都會“阻塞”其他異步事件的進程。這意味着,當異步事件發生時(如鼠標單擊、計時器觸發或XMLHttpRequest完成),它將排隊等待稍后執行。
首先,在JavaScript的第一個塊中,啟動了兩個計時器:一個10ms的setTimeout和一個10ms的setInterval。由於計時器是在哪里和什么時候啟動的,它實際上在我們實際完成第一個代碼塊之前觸發,但是請注意,它不會立即執行(由於線程的原因,它無法這樣做)。相反,被延遲的函數被排隊,以便在下一個可用的時刻執行。
此外,在第一個JavaScript塊中,我們看到鼠標單擊發生。與此異步事件相關聯的JavaScript回調(我們永遠不知道用戶何時會執行某個動作,因此它被認為是異步的)無法立即執行,因此,就像初始計時器一樣,它被排隊等待稍后執行。
在JavaScript的初始塊完成執行后,瀏覽器會立即問一個問題:等待執行的是什么?在本例中,鼠標單擊處理程序和計時器回調都在等待。然后瀏覽器選擇一個(鼠標點擊回調)並立即執行它。計時器將等待到下一個可能的時間,以便執行。
setInterval調用被廢棄
在click事件執行時,第20毫秒處,第二個setInterval
也到期了,因為此時已經click事件占用了線程,所以setInterval
還是不能被執行,並且因為此時隊列中已經有一個setInterval
正在排隊等待執行,所以這一次的setInterval
的調用將被廢棄。
瀏覽器不會對同一個setInterval處理程序多次添加到待執行隊列。
實際上,我們可以看到,當第三個interval回調被觸發時,interval本身正在執行。這向我們展示了一個重要的事實:interval並不關心當前執行的是什么,它們將不加區別地排隊,即使這意味着回調之間的時間間隔將被犧牲。
setTimeout
/setInterval
無法保證准時執行回調函數
最后,在第二個interval回調執行完成后,我們可以看到JavaScript引擎沒有任何東西可以執行了。這意味着瀏覽器現在等待一個新的異步事件發生。當interval再次觸發時,我們會在50ms處得到這個值。但是這一次,沒有任何東西阻礙它的執行,因此它立即觸發。
OK,總的來說造成JS定時器不可靠的原因就是JavaScript是單線程的,一次只能執行一個任務,而setTimeout() 的第二個參數(延時時間)只是告訴 JavaScript 再過多長時間把當前任務添加到隊列中。如果隊列是空的,那么添加的代碼會立即執行;如果隊列不是空的,那么它就要等前面的代碼執行完了以后再執行定時器任務必須等主線程任務執行才可能開始執行,無論它是否到達我們設置的時間
這里我們可以再來了解下Javascript的事件循環
事件循環
JavaScript中所有的任務分為同步任務與異步任務,同步任務,顧名思義就是立即執行的任務,它一般是直接進入到主線程中執行。而我們的異步任務則是進入任務隊列等待主線程中的任務執行完再執行。
任務隊列是一個事件的隊列,表示相關的異步任務可以進入執行棧了。主線程讀取任務隊列就是讀取里面有哪些事件。
隊列是一種先進先出的數據結構。
上面我們說到異步任務又可以分為宏任務與微任務,所以任務隊列也可以分為宏任務隊列與微任務隊列
-
Macrotask Queue:進行比較大型的工作,常見的有setTimeout,setInterval,用戶交互操作,UI渲染等;
-
Microtask Queue:進行較小的工作,常見的有Promise,Process.nextTick;
- 同步任務直接放入到主線程執行,異步任務(點擊事件,定時器,ajax等)掛在后台執行,等待I/O事件完成或行為事件被觸發。
- 系統后台執行異步任務,如果某個異步任務事件(或者行為事件被觸發),則將該任務添加到任務隊列,並且每個任務會對應一個回調函數進行處理。
- 這里異步任務分為宏任務與微任務,宏任務進入到宏任務隊列,微任務進入到微任務隊列。
- 執行任務隊列中的任務具體是在執行棧中完成的,當主線程中的任務全部執行完畢后,去讀取微任務隊列,如果有微任務就會全部執行,然后再去讀取宏任務隊列
- 上述過程會不斷的重復進行,也就是我們常說的事件循環(Event-Loop)。
這里更詳細的內容可以看我之前的文章探索JavaScript執行機制
導致定時器不可靠的原因
當前任務執行時間過久
JS 引擎會先執行同步的代碼之后才會執行異步的代碼,如果同步的代碼執行時間過久,是會導致異步代碼延遲執行的。
setTimeout(() => {
console.log(1);
}, 20);
for (let i = 0; i < 90000000; i++) { }
setTimeout(() => {
console.log(2);
}, 0);
這個按預期應該是會先打印出2,然后再打印1,但事實並不是如此,就算第二個定時器的時間更短,但中間那個for循環的執行時間遠遠超過了這兩個定時器設定的時間。
setTimeout
設置的回調任務是 按照順序添加到延遲隊列里面的,當執行完一個任務之后,ProcessDelayTask
函數會根據發起時間和延遲時間來計算出到期的任務,然后 依次執行 這些到期的任務。
在執行完前面的任務之后,上面例子的兩個 setTimeout
都到期了,那么按照順序執行就是打印 1
和 2
。所以在這個場景下,setTimeout
就顯得不那么可靠了。
延遲執行時間有最大值
包括 IE, Chrome, Safari, Firefox 在內的瀏覽器其內部以32位帶符號整數存儲延時。這就會導致如果一個延時(delay)大於 2147483647 毫秒 (大約24.8 天)時就會溢出,導致定時器將會被立即執行。(MDN)
setTimeout
的第二個參數設置為 0
(未設置、小於 0
、大於 2147483647
時都默認為 0
)的時候,意味着馬上執行,或者盡快執行。
setTimeout(function () {
console.log("你猜它什么時候打印?")
}, 2147483648);
把這段代碼放到瀏覽器控制台執行,你會發現它會立馬打印出 你猜它什么時候打印?
最小延時>=4ms(嵌套使用定時器)
在瀏覽器中,setTimeout()/
setInterval()
的每調用一次定時器的最小間隔是4ms,這通常是由於函數嵌套導致(嵌套層級達到一定深度),或者是由於已經執行的setInterval的回調函數阻塞導致的。
-
setTimeout
的第二個參數設置為0
(未設置、小於0
、大於2147483647
時都默認為0
)的時候,意味着馬上執行,或者盡快執行。 -
如果延遲時間小於
0
,則會把延遲時間設置為0
。如果定時器嵌套5
次以上並且延遲時間小於4ms
,則會把延遲時間設置為4ms
。
function cb() { f(); setTimeout(cb, 0); }
setTimeout(cb, 0);
在Chrome 和 Firefox中, 定時器的第5次調用被阻塞了;在Safari是在第6次;Edge是在第3次。所以后面的定時器都最少被延遲了4ms
未被激活的tabs的定時最小延遲>=1000ms
瀏覽器為了優化后台tab的加載損耗(以及降低耗電量),在未被激活的tab中定時器的最小延時限制為1S(1000ms)。
let num = 100;
function setTime() {
// 當前秒執行的計時
console.log(`當前秒數:${new Date().getSeconds()} - 執行次數:${100-num}`);
num ? num-- && setTimeout(() => setTime(), 50) : "";
}
setTime();
這里我在39秒時切到了其他標簽頁,我們會發現它后面的執行間隔都是1秒執行一次,並不是我們設定的50ms。
setInterval的處理時長不能比設定的間隔長
setInterval
的處理時長不能比設定的間隔長,否則setInterval
將會沒有間隔的重復執行
但是對這個問題,很多情況下,我們並不能清晰的把控處理程序所消耗的時長,為了能夠按照一定的間隔周期性的觸發定時器,我們可以使用setTimeout
來代替setInterval
執行。
setTimeout(function fn(){
// todo
setTimeout(fn,10)
// 執行完處理程序的內容后,在末尾再間隔10毫秒來調用該程序,這樣就能保證一定是10毫秒的周期調用,這里時間按自己的需求來寫
},10)
解決方案
方法一:requestAnimationFrame
window.requestAnimationFrame()
告訴瀏覽器——你希望執行一個動畫,並且要求瀏覽器在下次重繪之前調用指定的回調函數更新動畫。該方法需要傳入一個回調函數作為參數,該回調函數會在瀏覽器下一次重繪之前執行,理想狀態下回調函數執行次數通常是每秒60次(也就是我們所說的60fsp),也就是每16.7ms 執行一次,但是並不一定保證為 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
這種方案看起來像是增加了誤差,這是因為requestAnimationFrame每16.7ms 執行一次,因此它不適用於間隔很小的定時器修正。
方法二: Web Worker
Web Worker為Web內容在后台線程中運行腳本提供了一種簡單的方法。線程可以執行任務而不干擾用戶界面。此外,他們可以使用XMLHttpRequest
執行 I/O (盡管responseXML
和channel
屬性總是為空)。一旦創建, 一個worker 可以將消息發送到創建它的JavaScript代碼, 通過將消息發布到該代碼指定的事件處理程序(反之亦然)。
Web Worker 的作用就是為 JavaScript 創造多線程環境,允許主線程創建 Worker 線程,將一些任務分配給后者運行。在主線程運行的同時,Worker 線程在后台運行,兩者互不干擾。等到 Worker 線程完成計算任務,再把結果返回給主線程。這樣的好處是,一些計算密集型或高延遲的任務,被 Worker 線程負擔了,主線程不會被阻塞或拖慢。
// index.js
let count = 0;
//耗時任務
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);
這種方案體驗整體上來說還是比較好的,既能較大程度修正計時器也不影響主進程任務
總結
由於js的單線程特性,所以會有事件排隊、先進先出、setInterval調用被廢棄、定時器無法保證准時執行回調函數以及出現setInterval的連續執行。