/

JavaScript 事件循環

JavaScript 事件循環

事件循環是理解 JavaScript 最重要的方面之一。本文將以簡單的方式解釋它。

介紹

了解 JavaScript 的事件循環是非常重要的。

我已經用 JavaScript 編程多年了,但我從來沒有完全理解它的運作原理。不知道這個概念的詳細細節是完全正常的,但通常還是有助於了解它的運作方式,而且你可能對此有點好奇。

本文旨在解釋 JavaScript 的內部細節,介紹單線程如何處理異步函數。

JavaScript 代碼在單線程運行,一次只能做一件事。

這是一個實際上非常有用的限制,因為它簡化了編程,不必擔心並發問題。

只需要注意如何編寫代碼,避免阻塞線程的任何操作,如同步網絡調用或無限循環。

通常情況下,大多數瀏覽器每個瀏覽器選項卡都有一個事件循環,以使每個過程都是獨立的,避免無限循環或繁重處理的網頁阻塞整個瀏覽器。

環境管理多個並發的事件循環,以處理 API 調用。Web Workers 也在自己的事件循環中運行。

你主要需要關注的是,你的代碼會在單個事件循環中運行,要以此為依據編寫代碼,避免對其進行阻塞。

阻塞事件循環

任何需要太長時間才能將控制權歸還給事件循環的 JavaScript 代碼都會阻塞頁面中的任何 JavaScript 代碼的執行,甚至會阻塞 UI 线程,用戶無法進行點擊、滾動頁面等操作。

在 JavaScript 中,幾乎所有 I/O 原語都是非阻塞的。網絡請求、Node.js 文件系統操作等都是非阻塞的。阻塞是例外,這就是為什麼 JavaScript 在回調、近來在 promises 和 async/await 上投入了大量資源的原因。

調用堆棧

調用堆棧是一個 LIFO(「後進先出」)隊列。

事件循環不斷檢查 調用堆棧 是否有需要運行的函數。

在此過程中,它將堆棧中找到的所有調用放入調用堆棧並按順序執行。

你可能熟悉調試器或瀏覽器控制台中的錯誤堆棧跟踪,瀏覽器會在堆棧中查找函數名以通知你當前調用的函數:

異常調用堆棧

簡單的事件循環解釋

舉個例子:

我將使用 foobarbaz 作為隨機名稱。請輸入任意名稱以替換它們

1
2
3
4
5
6
7
8
9
10
11
const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
console.log('foo')
bar()
baz()
}

foo()

此代碼正常運行後,會打印:

1
2
3
foo
bar
baz

如預期所示。

運行此代碼時,首先調用 foo()。在 foo() 中,我們首先調用 bar(),然後調用baz()

此時,調用堆棧如下所示:

第一個例子的調用堆棧

在每次迭代時,事件循環檢查調用堆棧中是否有任何需要運行的函數:

第一個例子的執行順序

直到調用堆棧為空。

排隊執行函數

上面的例子看起來正常,沒有特殊之處:JavaScript 找到要執行的事物,按順序運行它們。

現在我們來看看如何在堆棧清空後推遲一個函數的執行。

setTimeout(() => {}, 0) 的使用場景是調用一個函數,但在代碼中的其他函數都執行完畢後再執行它。

試看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
console.log('foo')
setTimeout(bar, 0)
baz()
}

foo()

此代碼會打印出意外的結果:

1
2
3
foo
baz
bar

運行此代碼時,首先調用 foo()。在 foo() 中,我們首先調用 setTimeout(),將 bar 作為參數傳遞給它,以 0 為計時器指示讓它立即以最快的速度運行。然後,我們調用 baz()

此時,調用堆棧如下所示:

第二個例子的調用堆棧

以下是我們程序中所有函數的執行順序:

第二個例子的執行順序

為什麼會這樣呢?

消息隊列

當調用 setTimeout() 時,瀏覽器或 Node.js 會啟動 計時器。計時器到期后,即刻將其回調函數放入消息隊列

消息隊列也是用戶觸發的事件(如點擊、鍵盤事件或 fetch 響應)或 DOM 事件(如 onLoad)在你的代碼有機會對其做出反應之前排隊的地方。

事件循環優先處理調用堆棧,首先處理其中的所有內容,一旦其中沒有東西,則去檢查消息隊列中的內容。

我們不必等待像 setTimeout、fetch 或其他平台 API 的函數完成自己的工作,因為它們由瀏覽器提供,而且它們在自己的線程上運行。例如,如果你將 setTimeout 的超時設置為 2 秒,你不必等待 2 秒 – 直接在其他地方等待即可。

ES6 任務隊列

ECMAScript 2015 引入了任務隊列的概念,Promises 也使用了它。這是一種在異步函數的結果可以馬上執行的方式,而不是被放在調用堆棧的末尾。

在當前函數結束前解析的 promises 將在當前函數後立即執行。

我喜歡將這個比喻成遊樂園的過山車:消息隊列將你放在隊伍的後排,在所有其他人的後面,而你必須等待輪到你時,而任務隊列則是快速通行證,讓你在完成前一個後立即乘坐另一次過山車。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
console.log('foo')
setTimeout(bar, 0)
new Promise((resolve, reject) =>
resolve('應該在 bar 前、baz 後執行')
).then(resolve => console.log(resolve))
baz()
}

foo()

這將打印出:

1
2
3
4
foo
baz
應該在 bar 前、baz 後執行
bar

這是 Promises(以及建立在 Promises 上的 async/await)和通過 setTimeout() 或其他平台 API 的普通異步函數之間的一個重大區別。

tags: [“JavaScript”, “Event Loop”, “Call Stack”, “Message Queue”, “Promises”]