瀏覽器事件迴圈Event Loop

beckyye發表於2023-11-14

引言:

事件迴圈不是瀏覽器獨有的,從字面上看,“迴圈”可以簡單地認為就是重複,比如for迴圈,就是重複地執行for迴圈體中的語句,所以事件迴圈,可以理解為重複地處理事件,那麼下一個問題是,處理的是什麼事件,事件的相關資訊從哪裡獲取。

因為我沒有用nodejs做過什麼專案,所以這裡我暫且只關注瀏覽器的事件迴圈,但我想就“事件迴圈”本身而言,原理應該是相同的,不過就具體的實現可能存在一些差異。

一道面試題

相信應該有部分小夥伴和我一樣,在面試中曾遇到過類似於這種問列印結果的題目。

(async function main() {
  console.log(1);

  setTimeout(() => {
    console.log(2);
  }, 0);

  setTimeout(() => {
    console.log(3);
  }, 100);

  let p1 = new Promise((resolve, reject) => {
    console.log(4);

    resolve(5);
    console.log(6);
  });

  p1.then((res) => {
    console.log(res);
  });

  let result = await Promise.resolve(7);
  console.log(result);

  console.log(8);
})()

這種題目就是變相的在考察事件迴圈的知識。

我個人感覺事件迴圈這個點,也是隨著Promise的出現,成為了一個常見的考點。

什麼是事件迴圈

一提到事件迴圈,我想很多人會和我一樣,立刻想到非同步、宏任務、微任務什麼的。

WIKI

先不著急,我們先看下Wiki上,對事件迴圈的通用性描述。

In computer science, the event loop is a programming construct or design pattern that waits for and dispatches events or messages in a program. The event loop works by making a request to some internal or external "event provider" (that generally blocks the request until an event has arrived), then calls the relevant event handler ("dispatches the event"). The event loop is also sometimes referred to as the message dispatcher, message loop, message pump, or run loop.

The event-loop may be used in conjunction with a reactor, if the event provider follows the file interface, which can be selected or 'polled' (the Unix system call, not actual polling). The event loop almost always operates asynchronously with the message originator.

When the event loop forms the central control flow construct of a program, as it often does, it may be termed the main loop or main event loop. This title is appropriate, because such an event loop is at the highest level of control within the program.

簡而言之,事件迴圈是一種程式設計結構或設計模式,用於在程式中等待和派發事件或訊息。

它的工作原理是,向內部或外部的“事件提供者”發出請求(通常會阻止請求,直到事件發生)這就回答了我們之前的問題:事件的資訊從哪裡來,是由“事件提供者”提供,然後呼叫相關的事件處理程式(“派發事件”)關於如何處理事件

事件迴圈有時也被稱為訊息派發器、訊息迴圈、訊息泵或者執行迴圈。

事件迴圈幾乎總是與訊息傳送者非同步執行

這裡我覺得可以這麼理解,“訊息傳送者”這邊將事件的訊息交給了“事件提供者”,而事件迴圈這邊會向“事件提供者”發出請求獲取事件,然後呼叫相關的事件處理程式;所以說,事件迴圈與訊息傳送者是非同步執行。

事件迴圈必然是在“訊息傳送者”將事件的訊息交出之後,才會去執行事件處理程式;也就是說,事件迴圈的操作是在當下之後,在”將來“才會發生的。

當事件迴圈構成程式的中心控制流結構時(通常如此),它可以被稱為主迴圈或主事件迴圈。這個稱謂是恰當的,因為這樣的事件迴圈處於程式的最高控制層。

MDN

WIKI上提供的是通用性的描述。我們再看一下MDN,MDN上直接搜尋事件迴圈,可以看到是位於JavaScript路徑下,針對JavaScript事件迴圈的描述。

JavaScript has a runtime model based on an event loop, which is responsible for executing the code, collecting and processing events, and executing queued sub-tasks. This model is quite different from models in other languages like C and Java.

第一段很直白的描述:JavaScript的執行時模型,是基於事件迴圈的,負責執行程式碼、收集和處理事件以及執行佇列中的子任務。

執行佇列中的子任務:這基本上等於說,JavaScript的執行時給JavaScript提供了事件迴圈的能力;可以說JavaScript執行時中事件迴圈的部分,提供了JavaScript非同步的具體實現方式。給JavaScript提供了支援非同步的能力。

那麼JavaScript為什麼要處理非同步呢?這就不得不提JavaScript的單執行緒執行特性 ,執行緒是什麼?是進行運算排程的最小單位,而JavaScript設計之初,是為了處理網頁上的互動事件,如果JavaScript允許多執行緒,也就是允許多個觸發的事件同時進行運算,這可能就會呈現出各種不一樣的計算結果,在使用者看來就會顯得互動很混亂,為了減少不確定性,JavaScript乾脆就選擇了單執行緒執行,所有程式碼都在同一個執行緒中執行;另外,JavaScript中的互動事件很多,如果每個觸發事件都單獨開闢執行緒來處理,也是不小的開銷吧。

但是呢,雖然JavaScript是單執行緒執行的,但也存在需要在將來完成的操作,也就是存在非同步程式碼,比如定時器。如果在Java中,我們也許可以選擇new一個執行緒,sleep多少秒,然後再執行,但是JavaScript中不能這樣做,因為它沒有多執行緒,而如果直接在主執行緒等待,必定會引發阻塞和卡頓。事件迴圈就是對這種情況的一種解決方案,為了協調瀏覽器中的各種事件,必須使用事件迴圈;而事件迴圈中的訊息佇列就由JavaScript執行時來管理。

執行時概念

相信不少前端同學都聽過“執行時”這個詞,那執行時到底是什麼呢?我覺得可以這麼簡單理解,既然執行時的功能是負責執行程式碼、收集和處理事件以及執行佇列中的子任務,那麼執行時中必須定義一套規則,關於如何去處理這些事情。所以可以簡單地把執行時認為是定義了一套執行規則的JavaScript執行環境。

關於執行時,可以看到MDN上有一個直觀演示的圖,其中包含了函式呼叫形成的執行棧、分配物件的堆,以及訊息佇列。

根據WIKI給出的描述,執行時模型中,與事件迴圈關係最密切的,是訊息佇列,也就是我們前面提到的“事件提供者”。現在我們來看這個佇列。

A JavaScript runtime uses a message queue, which is a list of messages to be processed. Each message has an associated function that gets called to handle the message.

At some point during the event loop, the runtime starts handling the messages on the queue, starting with the oldest one. To do so, the message is removed from the queue and its corresponding function is called with the message as an input parameter. As always, calling a function creates a new stack frame for that function's use.

The processing of functions continues until the stack is once again empty. Then, the event loop will process the next message in the queue (if there is one).

我們來看翻譯的內容:

JavaScript 執行時使用訊息佇列,這是一個待處理訊息列表。每條訊息都有一個相關函式被呼叫來處理該訊息。

在事件迴圈中的某個時刻,執行時開始處理佇列中的訊息,從最舊的訊息開始。(”隊“這個資料結構我們知道,是先進先出的,所以先進隊的訊息會先被處理。)為此,會從佇列中移除訊息,並將訊息作為輸入引數呼叫相應的函式。一如既往,呼叫函式會建立一個新的堆疊框架供該函式使用。

函式的處理將一直持續到堆疊再次清空為止。然後,事件迴圈將處理佇列中的下一條訊息(如果有的話)。(也就是,訊息佇列中的訊息是一條接一條處理的。這裡的堆疊指的就是函式呼叫形成的執行棧和分配物件的堆)

那麼佇列中的訊息是哪裡來的呢? 從這段內容中我們可以知道,進隊的訊息已經在等待處理了;所以比如有個定時器setTimeout,定義了有段程式碼需要等待3秒才執行,那這段程式碼就不能直接就進隊,為了保證動作3秒後才執行,會在3秒後才進隊,也就是說,setTimeout的第二個引數代表的是將訊息推入佇列的延遲時間。

那麼肯定需要有什麼東西,來管理這段程式碼,將這段程式碼在給定的延時後,推入訊息佇列。既然js沒法去開執行緒管理,所以也是瀏覽器在管理;Chrome就有一個定時器執行緒,專門用於處理定時器,在定時器計時結束後,通知事件觸發執行緒將訊息推入佇列;同樣的,在使用者觸發互動事件時,事件觸發執行緒也會將已在程式碼中定義的訊息推入佇列,也就是在事件監聽程式addEventListener中監聽的操作;還有非同步HTTP請求執行緒,來管理請求回撥的訊息入隊。等等,瀏覽器的這些執行緒共同作用來實現事件迴圈這個機制。

在JS主執行緒空閒時,就會將這些訊息佇列中的訊息出列,交由主執行緒來執行。

那麼接下來就是事件迴圈的執行步驟的問題。

事件迴圈執行步驟

首先,關於微任務:我們來看HTML的文件

Each event loop has a microtask queue, which is a queue of microtasks, initially empty. A microtask is a colloquial way of referring to a task that was created via the queue a microtask algorithm.

每個事件迴圈都有一個微任務佇列,這是一個初始為空的微任務佇列。微任務是一種通俗的說法,指透過微任務佇列演算法建立的任務。

也就是說,每個事件迴圈都會維護一個自己的微任務佇列。它和我們之前看的訊息佇列,不是同一個佇列,訊息佇列指的是這個文件中的任務佇列,也就是task queue。

總所周知,常見的產生宏任務的方式有script、setTimeout、setInterval、UI事件等等;常見的產生微任務的方式有Promise.prototype.then、MutationObserver等等。

假設我們在瀏覽器中載入了一個頁面,現在我們來看事件迴圈的處理步驟

  • 初始狀態:執行時的呼叫棧空。微任務佇列空,訊息佇列裡有且僅有一個script指令碼(整體程式碼)
  • 然後訊息佇列中的script指令碼被推入呼叫棧,同步程式碼開始執行。
  • 當碰到微任務時,比如Promise.then,就將微任務推入事件迴圈的微任務佇列中;這裡要注意一下,Promise執行器函式中的程式碼屬於同步程式碼,會被順序執行;
  • 當碰到宏任務時,就將它們丟給相應的瀏覽器執行緒;
  • 當本次程式碼中的同步程式碼都執行完畢後,就將微任務佇列中的任務一一處理並出隊;
  • 這樣就完成了一次迴圈;
  • 本次的宏任務script指令碼也被出隊。
  • 此時DOM修改完成,然後瀏覽器會執行渲染操作,更新介面。
  • 如果宏任務在各自的執行緒中被處理完畢後,就會被推入訊息佇列。
  • 再接著就是當JS主執行緒空閒後,會去查詢佇列中是否還有任務,開啟新一輪的迴圈。

這個步驟我大概畫了個圖:
image

現在我們照著最開始的面試題進行舉例。

首先,這段程式碼是一整個script指令碼,其中的同步程式碼會首先被按順序執行,

可以看到這個script指令碼中有一個async非同步函式,async函式中的同步程式碼會首先被執行,所以先會列印1

然後碰到兩個產生宏任務的setTimeout,丟給定時器執行緒,為了後面方便講述,這裡分別把它們叫做宏任務1和宏任務2;

然後執行promise執行器函式中的同步程式碼,列印4和6

接著碰到Promise.then這個微任務,我們給它記為微任務1,將它推入微任務佇列,

然後我們又碰到一個await,await之後的程式碼相當於是Promise.then中的程式碼,也就是會被推入微任務佇列,我們給它記為微任務2;

到這裡,本次迴圈中的同步程式碼都執行完畢了;

接著就是開始把微任務佇列中的微任務取出執行,首先是執行微任務1,列印5

接著執行微任務2,列印7和8

本次事件迴圈就結束了。

等到計時結束,宏任務1會先被推入訊息佇列,在JS主執行緒空閒,去查詢訊息佇列後,程式碼就會被執行,會列印2

同理,宏任務2後面也會被執行,並列印3

這樣我們就完成了這道面試題的解答。

總結

總的來說,事件迴圈就是JS中非同步的具體實現方式,它的實現需要來自宿主環境的支援,比如瀏覽器中的各種執行緒,執行時中的訊息佇列等等。

相關文章