事件循環是理解 JavaScript 最重要的方面之一。本文將以簡單的方式解釋它。
介紹
了解 JavaScript 的事件循環是非常重要的。
我已經用 JavaScript 編程多年了,但我從來沒有完全理解它的運作原理。不知道這個概念的詳細細節是完全正常的,但通常還是有助於了解它的運作方式,而且你可能對此有點好奇。
本文旨在解釋 JavaScript 的內部細節,介紹單線程如何處理異步函數。
JavaScript 代碼在單線程運行,一次只能做一件事。
這是一個實際上非常有用的限制,因為它簡化了編程,不必擔心並發問題。
只需要注意如何編寫代碼,避免阻塞線程的任何操作,如同步網絡調用或無限循環。
通常情況下,大多數瀏覽器每個瀏覽器選項卡都有一個事件循環,以使每個過程都是獨立的,避免無限循環或繁重處理的網頁阻塞整個瀏覽器。
環境管理多個並發的事件循環,以處理 API 調用。Web Workers 也在自己的事件循環中運行。
你主要需要關注的是,你的代碼會在單個事件循環中運行,要以此為依據編寫代碼,避免對其進行阻塞。
阻塞事件循環
任何需要太長時間才能將控制權歸還給事件循環的 JavaScript 代碼都會阻塞頁面中的任何 JavaScript 代碼的執行,甚至會阻塞 UI 线程,用戶無法進行點擊、滾動頁面等操作。
在 JavaScript 中,幾乎所有 I/O 原語都是非阻塞的。網絡請求、Node.js 文件系統操作等都是非阻塞的。阻塞是例外,這就是為什麼 JavaScript 在回調、近來在 promises 和 async/await 上投入了大量資源的原因。
調用堆棧
調用堆棧是一個 LIFO(「後進先出」)隊列。
事件循環不斷檢查 調用堆棧 是否有需要運行的函數。
在此過程中,它將堆棧中找到的所有調用放入調用堆棧並按順序執行。
你可能熟悉調試器或瀏覽器控制台中的錯誤堆棧跟踪,瀏覽器會在堆棧中查找函數名以通知你當前調用的函數:
簡單的事件循環解釋
舉個例子:
我將使用
foo
、bar
和baz
作為隨機名稱。請輸入任意名稱以替換它們
const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
console.log('foo')
bar()
baz()
}
foo()
此代碼正常運行後,會打印:
foo
bar
baz
如預期所示。
運行此代碼時,首先調用 foo()
。在 foo()
中,我們首先調用 bar()
,然後調用baz()
。
此時,調用堆棧如下所示:
在每次迭代時,事件循環檢查調用堆棧中是否有任何需要運行的函數:
直到調用堆棧為空。
排隊執行函數
上面的例子看起來正常,沒有特殊之處:JavaScript 找到要執行的事物,按順序運行它們。
現在我們來看看如何在堆棧清空後推遲一個函數的執行。
setTimeout(() => {}, 0)
的使用場景是調用一個函數,但在代碼中的其他函數都執行完畢後再執行它。
試看下面的例子:
const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
console.log('foo')
setTimeout(bar, 0)
baz()
}
foo()
此代碼會打印出意外的結果:
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 將在當前函數後立即執行。
我喜歡將這個比喻成遊樂園的過山車:消息隊列將你放在隊伍的後排,在所有其他人的後面,而你必須等待輪到你時,而任務隊列則是快速通行證,讓你在完成前一個後立即乘坐另一次過山車。
例如:
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()
這將打印出:
foo
baz
應該在 bar 前、baz 後執行
bar
這是 Promises(以及建立在 Promises 上的 async/await)和通過 setTimeout()
或其他平台 API 的普通異步函數之間的一個重大區別。