JS定時器不可靠的原因及解決方案

語言: CN / TW / HK

前言

在工作中應用定時器的場景非常多,但你會發現有時候定時器好像並沒有按照我們的預期去執行,比如我們常遇到的 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;

  1. 同步任務直接放入到主執行緒執行,非同步任務(點選事件,定時器,ajax等)掛在後臺執行,等待I/O事件完成或行為事件被觸發。
  2. 系統後臺執行非同步任務,如果某個非同步任務事件(或者行為事件被觸發),則將該任務新增到任務佇列,並且每個任務會對應一個回撥函式進行處理。
  3. 這裡非同步任務分為巨集任務與微任務,巨集任務進入到巨集任務佇列,微任務進入到微任務佇列。
  4. 執行任務佇列中的任務具體是在執行棧中完成的,當主執行緒中的任務全部執行完畢後,去讀取微任務佇列,如果有微任務就會全部執行,然後再去讀取巨集任務佇列
  5. 上述過程會不斷的重複進行,也就是我們常說的 事件迴圈(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 都到期了,那麼按照順序執行就是列印 12 。所以在這個場景下, 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 (儘管 responseXMLchannel 屬性總是為空)。一旦建立, 一個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的連續執行。