Node.js 的官方文檔中有一段對 Node.js 的簡介,如下。
Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient.
大意就是說 Node.js 是基於 V8 的 JavaScript 運行時,事件驅動、非阻塞,因此輕量、高效。
寥寥數語,並沒有說清楚 Node.js 到底是什么。參考了一些 Node.js 的官方文章以及社區里的分析,整理如下。
基礎架構
要想深入理解 Node.js,我們需要把 Node.js 進行必要的拆解,了解每個組成部分的作用,它們之間如何交互,最終構成 Node.js 這個強大的運行時環境。

上圖是 Node.js 的內部結構圖。我們可以看到,自底向上主要可以分成三層:最底層是 Node.js 依賴的各種庫,有 V8、libuv 等;中間層是各種 Binding,也就是膠水代碼;最上層是應用代碼,可使用 Node.js 的各種 API。
-
V8
Google 開源的高性能 JavaScript 引擎,它將 JavaScript 代碼轉換成機器碼,然后執行,因此速度非常快。V8 以 C++ 語言開發,Google 的 Chrome 瀏覽器正是使用的 V8 引擎。 -
libuv
libuv 以 C 語言開發,內部管理着一個線程池。在此基礎之上,提供事件循環(Event Loop)、異步網絡 I/O、文件系統 I/O等能力。 -
其他底層依賴庫
如 c-ares、crypto (OpenSSL)、http-parser 以及 zlib。這些依賴提供了對系統底層功能的訪問,包括網絡、壓縮、加密等。
Node.js 底層的依賴庫,有的以 C 語言開發,有的以 C++ 語言開發,如何讓應用代碼(JavaScript)能夠與這些底層庫相互調用呢?這就需要中間層的 Binding 來完成。Binding 是一些膠水代碼,能夠把不同語言綁定在一起使其能夠互相溝通。在 Node.js 中,binding 所做的就是把 Node.js 那些用 C/C++ 寫的庫接口暴露給 JS 環境。
中間層中,除了 Binding,還有 Addon。Binding 僅橋接 Node.js 核心庫的一些依賴,如果你想在應用程序中包含其他第三方或者你自己的 C/C++ 庫的話,需要自己完成這部分膠水代碼。你寫的這部分膠水代碼就稱為 Addon。本質上都是完成橋接的作用,使得應用與底層庫能夠互通有無。
應用層的代碼,就不必多言了,我們開發的應用、npm 安裝的包都運行在這里。
事件循環 (event loop)
剛接觸 Node.js 的時候,就知道 Node.js 有一個事件循環,類似於 while(true),但是不知道每次循環什么時候開始,什么時候結束,在每次循環中,Node.js 是如何處理同步與異步代碼的。
要說事件循環,就不得不先說明一下 Node.js 的工作流程。下圖可以簡要說明。

一個 Node.js 應用啟動時,V8 引擎會執行你寫的應用代碼,保持一份觀察者(注冊在事件上的回調函數)列表。當事件發生時,它的回調函數會被加進一個事件隊列。只要這個隊列還有等待執行的回調函數,事件循環就會持續把回調函數從隊列中拿出並執行。
在回調函數執行過程中,所有的 I/O 請求都會轉發給工作線程處理。libuv 維持着一個線程池,包含四個工作線程(默認值,可配置)。文件系統 I/O 請求和 DNS 相關請求都會放進這個線程池處理;其他的請求,如網絡、平台特性相關的請求會分發給相應的系統處理單元進行處理。
安排給線程池的這些 I/O 操作由 Node.js 的底層庫執行,完成之后觸發相應事件,對應的事件回調函數會被放入事件隊列,等待執行后續操作。這就是一個事件在 Node.js 中執行的整個生命周期。
前面說了,我們只知道 Node.js 有事件循環,但是不知道每次循環何時開始、何時結束。下面就簡要說明一下每次循環的處理過程,詳細內容請參考Node.js 官方說明。
一次事件循環,大概可以分為如下幾個階段:

圖中每一個方塊,在事件循環中被稱為一個階段(phase)。
每個階段都有自己獨有的一個用於執行回調函數的 FIFO 隊列。當事件循環進入一個指定階段時,會執行隊列中的回調函數,當隊列中已經被清空或者執行的回調函數個數達到系統最大限制時,事件循環會進入下一個階段。
上圖中總共有6個階段:
- timers: 該階段執行由
setTimeout()和setInterval()設置的回調函數。 - I/O callbacks: 執行除了close 回調、timers 以及
setImmediate()設置的回調以外的幾乎所有的回調。 - idle,prepare: 僅供內部使用。
- poll: 檢索新的 I/O 事件;在適當的時候 Node.js 會阻塞等待。
- check: 執行
setImmediate()設置的回調。 - close callbacks: 執行關閉回調。比如:
socket.on('close', ...).
這里有個令人困惑的地方,I/O callbacks 與 poll 這兩個階段有什么區別? 既然 I/O callbacks 中已經把回調都執行完了,還要 poll 做什么?
查閱了 libuv 的文檔后發現,在 libuv 的 event loop 中,I/O callbacks 階段會執行 Pending callbacks 。絕大多數情況下,在 poll 階段,所有的 I/O 回調都已經被執行。但是,在某些情況下,有一些回調會被延遲到下一次循環執行。也就是說,在 I/O callbacks 階段執行的回調函數,是上一次事件循環中被延遲執行的回調函數。
還需要提到的一點是 process.nextTick()。process.nextTick() 產生的回調函數保存在一個叫做 nextTickQueue 的隊列中,不在上面任何一個階段的隊列里面。當當前操作完成后,nextTickQueue 中的回調函數會立即被執行,不管事件循環處在哪個階段。也就是說,在 nextTickQueue 中的回調函數被執行完畢之前,事件循環不會往前推進。
測試與實踐
如下代碼中使用了 setTimeout(), setInterval(), setImmediate(), promise, process.nextTick(),可借助於輸出結果,理解事件循環。
'use strict';
const fs = require('fs');
console.log('script start');
const interval = setInterval(() => {
console.log('setInterval')
}, 500);
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve().then(() => {
console.log('promise 3');
}).then(() => {
console.log('promise 4');
process.nextTick(() => {
console.log('nextTick 1');
});
}).then(() => {
setTimeout(() => {
console.log('setTimeout 2');
Promise.resolve().then(() => {
console.log('promise 5');
}).then(() => {
console.log('promise 6');
process.nextTick(() => {
console.log('nextTick 2');
});
}).then(() => {
clearInterval(interval);
});
}, 0);
});
}, 1000);
Promise.resolve().then(() => {
console.log('promise 1');
}).then(() => {
console.log('promise 2');
});
setImmediate(() => {
console.log('setImmediate 1');
});
console.log('script done');
執行結果為:
script start
script done
promise 1
promise 2
setImmediate 1
setInterval
setTimeout 1
promise 3
promise 4
nextTick 1
setInterval
setTimeout 2
promise 5
promise 6
nextTick 2
