JavaScript 事件迴圈竟還能這樣玩!

控心つcrazy發表於2024-06-03

JavaScript 是一種單執行緒的程式語言,這意味著它一次只能執行一個任務。為了能夠處理非同步操作,JavaScript 使用了一種稱為事件迴圈(Event Loop)的機制。
本文將深入探討事件迴圈的工作原理,並展示如何基於這一原理實現一個更為準確的 setTimeoutsetInterval

什麼是事件迴圈?

事件迴圈是 JavaScript 執行時環境中處理非同步操作的核心機制。它允許 JavaScript 在執行任務時不會阻塞主執行緒,從而實現非阻塞 I/O 操作。

為了理解事件迴圈,首先需要了解以下幾個關鍵概念:

  1. 呼叫棧(Call Stack)

    • 呼叫棧是一個 LIFO(後進先出)結構,用於儲存當前執行的函式呼叫。當一個函式被呼叫時,它會被推入呼叫棧,當函式執行完畢後,它會從呼叫棧中彈出。
  2. 任務佇列(Task Queue)

    • 任務佇列儲存了所有等待執行的任務,這些任務通常是非同步操作的回撥函式,例如 setTimeoutsetInterval、I/O 操作等。當呼叫棧為空時,事件迴圈會從任務佇列中取出一個任務並將其推入呼叫棧執行。
  3. 微任務佇列(Microtask Queue)

    • 微任務佇列儲存了所有等待執行的微任務,這些微任務通常是 Promise 的回撥函式、MutationObserver 等。微任務佇列的優先順序高於任務佇列,當呼叫棧為空時,事件迴圈會優先處理微任務佇列中的所有任務,然後再處理任務佇列中的任務。

事件迴圈的工作原理

事件迴圈的工作原理可以簡化為以下幾個步驟:

  1. 執行呼叫棧中的任務

    • JavaScript 引擎會從呼叫棧中取出並執行最頂層的任務,直到呼叫棧為空。
  2. 處理微任務佇列

    • 當呼叫棧為空時,事件迴圈會檢查微任務佇列。如果微任務佇列中有任務,會依次取出並執行,直到微任務佇列為空。
  3. 處理任務佇列

    • 當呼叫棧和微任務佇列都為空時,事件迴圈會檢查任務佇列。如果任務佇列中有任務,會取出一個任務並將其推入呼叫棧執行。
  4. 重複上述步驟

    • 事件迴圈會不斷重複上述步驟,確保所有任務都能被及時處理。

示例

以下是一個簡單的示例,展示事件迴圈的工作原理:

console.log('Start');

setTimeout(() => {
    console.log('Timeout callback');
}, 0);

Promise.resolve().then(() => {
    console.log('Promise callback');
});

console.log('End');

輸出結果:

Start
End
Promise callback
Timeout callback

解釋如下:

  1. 同步任務:首先執行同步任務,console.log('Start')console.log('End') 被推入呼叫棧並立即執行。
  2. 微任務Promise.resolve().then 建立了一個微任務,該微任務被推入微任務佇列。
  3. 任務setTimeout 建立了一個任務,該任務被推入任務佇列。
  4. 處理微任務:同步任務執行完畢後,呼叫棧為空,事件迴圈檢查微任務佇列並執行所有微任務,因此輸出 Promise callback
  5. 處理任務:微任務佇列為空後,事件迴圈檢查任務佇列並執行所有任務,因此輸出 Timeout callback

為什麼 setTimeout 不準確?

JavaScript 中的 setTimeoutsetInterval 是基於事件迴圈和任務佇列的,因此它們的執行時間可能會受到以下幾個因素的影響,從而導致不準確:

  1. 事件迴圈機制

    • JavaScript 是單執行緒的,所有程式碼的執行都是在一個事件迴圈中進行的。事件迴圈會依次處理任務佇列中的任務。
    • 如果前面的任務執行時間較長,或者任務佇列中有很多工,定時器的回撥函式就會被延遲執行。
  2. 任務佇列的優先順序

    • 瀏覽器的任務佇列有不同的優先順序,例如使用者互動事件、渲染更新等任務的優先順序通常高於 setTimeoutsetInterval
    • 這意味著即使定時器到期,如果有其他高優先順序任務在執行,定時器的回撥函式也會被延遲執行。
  3. JavaScript 引擎的限制

    • JavaScript 引擎通常會對最小時間間隔進行限制。例如,在瀏覽器環境中,巢狀的 setTimeout 呼叫的最小時間間隔通常是 4 毫秒。
    • 這意味著即使你設定了一個非常短的時間間隔,實際執行的時間間隔也可能會比你設定的時間更長。
  4. 系統效能和負載

    • 系統的效能和當前負載也會影響定時器的準確性。如果系統負載較高,任務的執行時間可能會被進一步延遲。

為了更直觀地理解這一點,可以考慮以下示例:

console.log('Start');

setTimeout(() => {
    console.log('Timeout callback');
}, 1000);

const start = Date.now();
while (Date.now() - start < 2000) {
    // 模擬一個耗時2秒的任務
}

console.log('End');

在這個示例中,setTimeout 的回撥函式設定為 1 秒後執行,但由於在主執行緒上有一個耗時 2 秒的任務,導致定時器的回撥函式被延遲到這個任務執行完畢後才執行。

因此,實際執行時間會遠遠超過 1 秒。

實現一個更準確的 setTimeout

為了實現更精確的定時器,可以結合 Date 物件和遞迴的 setTimeout 來實現更高精度的定時器。

以下是一個實現準時 setTimeout 的例子:

function preciseTimeout(callback, delay) {
    const start = Date.now();

    function loop() {
        const now = Date.now();
        const elapsed = now - start;
        const remaining = delay - elapsed;

        if (remaining <= 0) {
            callback();
        } else {
            setTimeout(loop, remaining);
        }
    }

    setTimeout(loop, delay);
}

// 使用示例
preciseTimeout(() => {
    console.log('This is a precise timeout callback');
}, 1000); // 1秒

在這個實現中:

  1. 獲取當前時間 start
  2. loop 函式中不斷計算已經過去的時間 elapsed 和剩餘時間 remaining
  3. 如果剩餘時間 remaining 小於等於 0,就呼叫回撥函式 callback
  4. 如果剩餘時間 remaining 大於 0,就使用 setTimeout 遞迴呼叫 loop 函式。

這種方法能比直接使用 setTimeout 更精確地執行定時任務。

進一步最佳化

上面的程式碼還可以進一步最佳化,可以考慮使用 requestAnimationFrame 來實現更高精度的定時器。

requestAnimationFrame 是專門為動畫設計的,它會在瀏覽器下一次重繪之前呼叫指定的回撥函式。由於瀏覽器的重繪通常是每秒 60 次(即每 16.67 毫秒一次),所以使用 requestAnimationFrame 可以實現更高精度的定時器。

以下是使用 requestAnimationFrame 實現的高精度定時器:

function preciseTimeout(callback, delay) {
    const start = Date.now();

    function loop() {
        const now = Date.now();
        const elapsed = now - start;

        if (elapsed >= delay) {
            callback();
        } else {
            requestAnimationFrame(loop);
        }
    }

    requestAnimationFrame(loop);
}

// 使用示例
preciseTimeout(() => {
    console.log('This is a precise timeout callback');
}, 1000); // 1秒

在這個實現中,requestAnimationFrame 會在每次瀏覽器重繪之前呼叫 loop 函式,從而實現更高精度的定時器。

實現一個更準確的 setInterval

同樣地,我們可以透過結合 Date 物件和遞迴的 setTimeout 來實現更高精度的 setInterval。以下是一個實現準時 setInterval 的例子:

function preciseInterval(callback, interval) {
    let expected = Date.now() + interval;

    function step() {
        const now = Date.now();
        const drift = now - expected;

        if (drift >= 0) {
            callback();
            expected += interval;
        }

        setTimeout(step, interval - drift);
    }

    setTimeout(step, interval);
}

// 使用示例
preciseInterval(() => {
    console.log('This is a precise interval callback');
}, 1000); // 每秒

在這個實現中:

  1. 設定預期的下一次執行時間 expected
  2. step 函式中不斷計算當前時間 now 和預期時間 expected 之間的偏差 drift
  3. 如果偏差 drift 大於等於 0,就呼叫回撥函式 callback,並更新預期時間 expected
  4. 使用 setTimeout 遞迴呼叫 step 函式,並根據偏差 drift 調整下一次呼叫的時間間隔。

進一步最佳化

為了進一步最佳化,可以考慮使用 requestAnimationFrame 來實現更高精度的定時器。requestAnimationFrame 是專門為動畫設計的,它會在瀏覽器下一次重繪之前呼叫指定的回撥函式。由於瀏覽器的重繪通常是每秒 60 次(即每 16.67 毫秒一次),所以使用 requestAnimationFrame 可以實現更高精度的定時器。

那我們使用 requestAnimationFrame 來實現的高精度 setInterval

function preciseSetInterval(callback, interval) {
    let expected = performance.now() + interval;
    function step() {
        const drift = performance.now() - expected;
        if (drift >= 0) {
            callback();
            expected += interval;
        }
        requestAnimationFrame(step);
    }
    requestAnimationFrame(step);
}

// 使用示例
preciseSetInterval(() => {
    console.log('This runs every 2 seconds with higher precision');
}, 2000);

總結

事件迴圈是 JavaScript 處理非同步操作的核心機制,透過呼叫棧、任務佇列和微任務佇列的協調工作,實現了非阻塞 I/O 操作。

雖然 setTimeout 的定時精度受到事件迴圈的影響,但透過結合 Date 物件和遞迴的 setTimeout,或者使用 requestAnimationFrame,可以實現更為準確的定時器。

相關文章