事件循環是了解Node.js最重要的方面之一。

為什麼這個很重要?因為它解釋了Node.js為什麼可以是非同步的並且具有非阻塞的I/O,也解釋了Node.js的"殺手級應用",這正是使它變得如此成功的原因。

Node.js的JavaScript代碼運行在單個線程上,一次只能發生一件事。

這實際上是一種很有幫助的限制,因為它簡化了編程,不需要擔心並發問題。

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

總的來說,在大多數瀏覽器中,每個瀏覽器標簽都有一個事件循環,以確保每個進程都是隔離的,避免網頁中的無限循環或重型處理導致整個瀏覽器被阻塞。

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

您只需要關心您的代碼將運行在一個單獨的事件循環上,並根據這一點來編寫代碼,以避免阻塞它。

阻塞事件循環

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

在JavaScript中,幾乎所有的I/O原語都是非阻塞的,例如網絡請求、文件系統操作等。阻塞是例外,這就是為什麼JavaScript在很大程度上基於回調,最近則是基於Promise和async/await的原因。

調用堆疊

調用堆疊是一個後進先出(LIFO)的隊列。

事件循環會不斷檢查調用堆疊,以查看是否有需要運行的函數。

在這個過程中,它將找到的每個函數調用添加到調用堆疊中,並按順序執行每個函數。

您可能熟悉調試器或瀏覽器控制台中的錯誤堆疊跟踪,瀏覽器通過查找調用堆疊中的函數名稱來告訴您哪個函數產生了當前的調用,如下圖所示:

堆疊追踪

一個簡單的事件循環解釋

讓我們舉個例子:

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作為參數傳遞給它,然後我們調用baz()

此時,調用堆疊如下:

第二個例子的調用堆疊

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

第二個例子的執行順序

為什麼會這樣呢?

信息隊列

當調用setTimeout()時,瀏覽器或Node.js開始計時器運行。計時器過期後,立即執行回調函數並將其放入信息隊列

信息隊列還是用於將用戶觸發的事件(如點擊或鍵盤事件,或者fetch響應)在您的代碼有機會對它們做出反應之前進行排隊。還有像onLoad這樣的DOM事件。

事件循環首先將優先權賦予調用堆疊,並首先處理其中的所有內容,一旦堆疊中沒有任何內容,它就會在信息隊列中提取對象。

我們無需等待像setTimeout、fetch或其他平台API這樣的函數完成它們自己的工作,因為它們是由瀏覽器提供的,它們在它們自己的線程上運行。例如,如果將setTimeout的超時設置為2秒,您無需等待2秒,因為等待時間是在其他地方發生的。

ES6作業隊列

ECMAScript 2015引入了作業隊列的概念,它被Promise使用(也在ES6/ES2015中引入)。它是一種在異步函數的結果可以盡快執行,而不是放在調用堆疊的末尾的方法。

在當前函數結束之前解析的Promise會在當前函數之後立即執行。

我覺得在遊樂園裡坐過山車時的比喻很好:信息隊列將你放在隊伍的後面,後面有所有其他人,你將不得不等待你的輪到到來,而作業隊列則是快速通行證,它讓你在完成前一個輪後立即坐上下一個輪。

舉個例子:

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

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

const foo = () => {
 console.log('foo')
 setTimeout(bar, 0)
 new Promise((resolve, reject) =>
 resolve('should be right after baz, before bar')
 ).then(resolve => console.log(resolve))
 baz()
}

foo()

這將打印:

foo
baz
should be right after baz, before bar
bar

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

結論

本文介紹了Node.js事件循環的基本構建塊。

這是使用Node.js編寫的任何程序的重要部分,我希望這裡解釋的一些概念對您將來有所幫助。