本章概述 Node.js 的運作方式
下列圖表概述 Node.js 的結構方式
Node.js 應用程式可用的 API 包括
fetch
和 CompressionStream
,屬於此類別。process
。'node:path'
(用於處理檔案系統路徑的函式和常數)和 'node:fs'
(與檔案系統相關的功能)。Node.js API 部分以 JavaScript 實作,部分以 C++ 實作。後者用於與作業系統介接。
Node.js 透過內嵌的 V8 JavaScript 引擎執行 JavaScript(與 Google 的 Chrome 瀏覽器使用的引擎相同)。
以下是 Node 全域變數 的一些重點
crypto
讓我們可以存取相容於網路的 加密 API。
console
與瀏覽器中的同名全域變數有許多重疊(例如 console.log()
)。
fetch()
讓我們可以使用 Fetch 瀏覽器 API。
process
包含 類別 Process
的執行個體,並讓我們可以存取命令列引數、標準輸入、標準輸出等。
structuredClone()
是相容於瀏覽器的函式,用於複製物件。
URL
是相容於瀏覽器的類別,用於處理 URL。
本章節中會提到更多全域變數。
下列內建模組提供全域變數的替代方案
'node:console'
是全域變數 console
的替代方案
console.log('Hello!');
import {log} from 'node:console';
log('Hello!');
'node:process'
是全域變數 process
的替代方案
console.log(process.argv);
import {argv} from 'node:process';
console.log(process.argv);
原則上,使用模組比使用全域變數更乾淨。然而,使用全域變數 console
和 process
是如此既定的模式,以至於偏離它們也有缺點。
大部分的 Node API 都是透過模組提供的。以下是幾個常用的模組(按字母順序排列)
'node:assert/strict'
:斷言是檢查條件是否符合的函式,如果不符合則報告錯誤。它們可用於應用程式程式碼和單元測試。以下是使用此 API 的範例
import * as assert from 'node:assert/strict';
.equal(3 + 4, 7);
assert.equal('abc'.toUpperCase(), 'ABC');
assert
.deepEqual({prop: true}, {prop: true}); // deep comparison
assert.notEqual({prop: true}, {prop: true}); // shallow comparison assert
'node:child_process'
用於同步執行原生命令或在個別程序中執行。此模組說明於 §12「在子程序中執行 shell 命令」。
'node:fs'
提供檔案系統操作,例如讀取、寫入、複製和刪除檔案與目錄。如需更多資訊,請參閱 §8「在 Node.js 上使用檔案系統」。
'node:os'
包含作業系統特定的常數和公用程式函數。其中一些函數說明於 §7「在 Node.js 上使用檔案系統路徑和檔案 URL」。
'node:path'
是用於處理檔案系統路徑的跨平台 API。說明於 §7「在 Node.js 上使用檔案系統路徑和檔案 URL」。
'node:stream'
包含 Node.js 特定的串流 API,說明於 §9「原生 Node.js 串流」。
'node:util'
包含各種公用程式函數。
模組 'node:module'
包含函數 builtinModules()
,會傳回一個陣列,其中包含所有內建模組的規格。
import * as assert from 'node:assert/strict';
import {builtinModules} from 'node:module';
// Remove internal modules (whose names start with underscores)
const modules = builtinModules.filter(m => !m.startsWith('_'));
.sort();
modules.deepEqual(
assert.slice(0, 5),
modules
['assert',
'assert/strict',
'async_hooks',
'buffer',
'child_process',
]; )
在本節中,我們使用下列匯入
import * as fs from 'node:fs';
Node 的函數有 3 種不同的樣式。讓我們以內建模組 'node:fs'
為例
我們剛剛看到的 3 個範例示範了具有類似功能的函數的命名慣例
fs.readFile()
fsPromises.readFile()
fs.readFileSync()
讓我們更仔細地了解這三種樣式的運作方式。
同步函式是最簡單的,它們會立即傳回值並將錯誤拋出為例外
try {
const result = fs.readFileSync('/etc/passwd', {encoding: 'utf-8'});
console.log(result);
catch (err) {
} console.error(err);
}
Promise-based 函式會傳回 Promise,而 Promise 會以結果完成,並以錯誤拒絕
import * as fsPromises from 'node:fs/promises'; // (A)
try {
const result = await fsPromises.readFile(
'/etc/passwd', {encoding: 'utf-8'});
console.log(result);
catch (err) {
} console.error(err);
}
請注意第 A 行的模組指定項:Promise-based API 位於不同的模組中。
Promise 在 「JavaScript for impatient programmers」 中有更詳細的說明。
基於回呼的函式會將結果和錯誤傳遞給回呼,而回呼是其最後的參數
.readFile('/etc/passwd', {encoding: 'utf-8'},
fs, result) => {
(errif (err) {
console.error(err);
return;
}console.log(result);
}; )
此樣式在 Node.js 文件 中有更詳細的說明。
預設情況下,Node.js 會在單一執行緒(主執行緒)中執行所有 JavaScript。主執行緒會持續執行事件迴圈,此迴圈會執行 JavaScript 程式區塊。每個程式區塊都是一個回呼,可以視為一個協調排程的工作。第一個工作包含我們用來啟動 Node.js 的程式碼(來自模組或標準輸入)。其他工作通常會在稍後加入,原因是
事件迴圈的第一個近似值如下所示
也就是說,主執行緒執行類似的程式碼
while (true) { // event loop
const task = taskQueue.dequeue(); // blocks
task();
}
事件迴圈會從工作佇列中取出回呼,並在主執行緒中執行它們。如果工作佇列為空,則會區塊(暫停主執行緒)出列。
我們稍後會探討兩個主題
為什麼此迴圈稱為事件迴圈?許多工作會在回應事件時加入,例如作業系統在輸入資料準備好處理時所傳送的事件。
如何將回呼加入工作佇列?以下是常見的可能性
下列程式碼顯示非同步的基於呼叫回呼函式的操作實際運作的狀況。它會從檔案系統中讀取文字檔
import * as fs from 'node:fs';
function handleResult(err, result) {
if (err) {
console.error(err);
return;
}console.log(result); // (A)
}.readFile('reminder.txt', 'utf-8',
fs
handleResult;
)console.log('AFTER'); // (B)
這是輸出
AFTER
Don’t forget!
fs.readFile()
會執行在其他執行緒中讀取檔案的程式碼。在此情況下,程式碼會成功執行並將此呼叫回呼函式新增至工作佇列
=> handleResult(null, 'Don’t forget!') ()
Node.js 執行 JavaScript 程式碼的重要規則是:每個工作在其他工作執行之前完成(「執行至完成」)。我們可以在前一個範例中看到:B 行的 'AFTER'
會在 A 行記錄結果之前記錄,因為在呼叫 handleResult()
的工作執行之前,初始工作會先完成。
執行至完成表示工作生命週期不會重疊,而且我們不必擔心共用資料在背景中被變更。這會簡化 Node.js 程式碼。下一個範例會示範這一點。它實作一個簡單的 HTTP 伺服器
// server.mjs
import * as http from 'node:http';
let requestCount = 1;
const server = http.createServer(
, res) => { // (A)
(_req.writeHead(200);
res.end('This is request number ' + requestCount); // (B)
res++; // (C)
requestCount
};
).listen(8080); server
我們透過 node server.mjs
執行此程式碼。之後,程式碼會啟動並等待 HTTP 要求。我們可以使用網路瀏覽器前往 https://127.0.0.1:8080
來傳送這些要求。每次我們重新載入該 HTTP 資源時,Node.js 會呼叫從 A 行開始的呼叫回呼函式。它會提供一個訊息,其中包含變數 requestCount
的目前值(B 行),並增加其值(C 行)。
呼叫回呼函式的每個呼叫都是一個新的工作,而變數 requestCount
會在工作之間共用。由於執行至完成,因此很容易讀取和更新。不需要與其他同時執行的工作同步,因為沒有其他工作。
為什麼 Node.js 程式碼預設會在單一執行緒(搭配事件迴圈)中執行?這有兩個好處
正如我們已經看過的,如果只有一個執行緒,在工作之間共用資料會更簡單。
在傳統的多執行緒程式碼中,執行時間較長的作業會封鎖目前的執行緒,直到作業完成。此類作業的範例包括讀取檔案或處理 HTTP 要求。執行其中許多作業的成本很高,因為我們必須每次建立一個新的執行緒。使用事件迴圈時,每個作業的成本會較低,特別是如果每個作業執行的內容不多。這就是基於事件迴圈的網路伺服器可以處理比基於執行緒的伺服器更高的負載的原因。
由於 Node 的部分非同步作業會在主執行緒以外的執行緒中執行(稍後會詳細說明),並透過工作佇列回報給 JavaScript,因此 Node.js 實際上並非單執行緒。相反地,我們使用單一執行緒來協調在主執行緒中同時非同步執行的作業。
這結束了我們對事件迴圈的第一次探討。如果你覺得淺顯的說明就夠了,可以跳過本節的剩餘部分。繼續閱讀以瞭解更多詳細資訊。
真正的事件迴圈有多個任務佇列,它會從中讀取多個階段 (您可以在 GitHub 儲存庫 nodejs/node
中查看一些 JavaScript 程式碼)。下圖顯示這些階段中最重要的階段
圖中顯示的事件迴圈階段會執行什麼動作?
階段「計時器」會呼叫由以下方式加入其佇列的計時任務
setTimeout(task, delay=1)
會在 delay
毫秒後執行回呼 task
。setInterval(task, delay=1)
會重複執行回呼 task
,暫停時間為 delay
毫秒。階段「輪詢」會擷取和處理 I/O 事件,並從其佇列執行與 I/O 相關的任務。
階段「檢查」(「立即階段」)會執行透過以下方式排程的任務
setImmediate(task)
會盡快執行回呼 task
(在階段「輪詢」後「立即」)。每個階段會執行直到其佇列為空,或直到處理完最大數量的任務。除了「輪詢」之外,每個階段都會等到下一個輪到它時,才會處理在其執行期間加入的任務。
setImmediate()
任務,處理會進展到「檢查」階段。如果此階段花費的時間超過系統依賴的時間限制,它會結束,而下一個階段會執行。
在每個呼叫的任務之後,會執行一個「子迴圈」,它包含兩個階段
子階段會處理
process.nextTick()
排入佇列的下一個勾選任務。queueMicrotask()
、Promise 反應等排入佇列的微任務。下一個勾選任務是 Node.js 特有的,微任務是跨平台網路標準(請見 MDN 的支援表格)。
此子迴圈會執行直到兩個佇列都為空。在其執行期間加入的任務會立即處理 - 子迴圈不會等到下一個輪到它時。
我們可以使用下列函式和方法將回呼新增至其中一個工作佇列
setTimeout()
(Web 標準)setInterval()
(Web 標準)setImmediate()
(Node.js 特有)process.nextTick()
(Node.js 特有)queueMicrotask()
:(Web 標準)請務必注意,當透過延遲計時工作時,我們指定的是工作執行最早可能的時間。Node.js 無法總是準時執行工作,因為它只能在工作之間檢查是否有任何計時工作到期。因此,長時間執行的工作可能會導致計時工作延遲。
考慮下列程式碼
function enqueueTasks() {
Promise.resolve().then(() => console.log('Promise reaction 1'));
queueMicrotask(() => console.log('queueMicrotask 1'));
process.nextTick(() => console.log('nextTick 1'));
setImmediate(() => console.log('setImmediate 1')); // (A)
setTimeout(() => console.log('setTimeout 1'), 0);
Promise.resolve().then(() => console.log('Promise reaction 2'));
queueMicrotask(() => console.log('queueMicrotask 2'));
process.nextTick(() => console.log('nextTick 2'));
setImmediate(() => console.log('setImmediate 2')); // (B)
setTimeout(() => console.log('setTimeout 2'), 0);
}
setImmediate(enqueueTasks);
我們使用 setImmediate()
來避免 ESM 模組的特殊性:它們在微工作中執行,這表示如果我們在 ESM 模組的最上層排入微工作,它們會在立即執行工作之前執行。正如我們接下來會看到的,這在其他大部分情況下有所不同。
這是前一個程式碼的輸出
nextTick 1
nextTick 2
Promise reaction 1
queueMicrotask 1
Promise reaction 2
queueMicrotask 2
setTimeout 1
setTimeout 2
setImmediate 1
setImmediate 2
觀察
所有立即執行工作都在 enqueueTasks()
之後立即執行。
它們之後是所有微工作,包括 Promise 反應。
「計時器」階段在立即階段之後。這是計時工作執行的時候。
我們在立即(「檢查」)階段新增了立即工作(A 行和 B 行)。它們在輸出中最後顯示,這表示它們未在目前階段執行,而是在下一個立即階段執行。
下列程式碼檢查如果我們在立即執行階段排入立即執行工作,以及在微工作階段排入微工作,會發生什麼事
setImmediate(() => {
setImmediate(() => console.log('setImmediate 1'));
setTimeout(() => console.log('setTimeout 1'), 0);
process.nextTick(() => {
console.log('nextTick 1');
process.nextTick(() => console.log('nextTick 2'));
;
})
queueMicrotask(() => {
console.log('queueMicrotask 1');
queueMicrotask(() => console.log('queueMicrotask 2'));
process.nextTick(() => console.log('nextTick 3'));
;
}); })
這是輸出
nextTick 1
nextTick 2
queueMicrotask 1
queueMicrotask 2
nextTick 3
setTimeout 1
setImmediate 1
觀察
立即執行工作首先執行。
「nextTick 2」在立即執行階段排入並立即執行。只有在立即執行佇列為空時,執行才會繼續。
微工作也是如此。
我們在微工作階段排入「nextTick 3」,執行會迴圈回到立即執行階段。這些子階段會重複,直到它們的佇列都為空。只有在這個時候,執行才會移到下一個全域階段:首先是「計時器」階段(「setTimeout 1」)。然後是立即階段(「setImmediate 1」)。
下列程式碼探討哪些類型的任務可以使事件迴圈階段挨餓(透過無限遞迴阻止它們執行)
import * as fs from 'node:fs/promises';
function timers() { // OK
setTimeout(() => timers(), 0);
}function immediate() { // OK
setImmediate(() => immediate());
}
function nextTick() { // starves I/O
process.nextTick(() => nextTick());
}
function microtasks() { // starves I/O
queueMicrotask(() => microtasks());
}
timers();
console.log('AFTER'); // always logged
console.log(await fs.readFile('./file.txt', 'utf-8'));
「計時器」階段和立即階段不會執行在其階段中排隊的任務。這就是為什麼 timers()
和 immediate()
不會讓 fs.readFile()
挨餓,而 fs.readFile()
會在「輪詢」階段回報(還有一個 Promise 反應,但我們在此先忽略它)。
由於 next-tick 任務和微任務的排程方式,nextTick()
和 microtasks()
都會阻止最後一行的輸出。
在事件迴圈的每個反覆運算結束時,Node.js 會檢查是否該結束了。它會保留未決 逾時 的參考計數(針對計時任務)
setImmediate()
、setInterval()
或 setTimeout()
排程計時任務會增加參考計數。如果在事件迴圈反覆運算結束時參考計數為零,Node.js 會結束。
我們可以在以下範例中看到
function timeout(ms) {
return new Promise(
, _reject) => {
(resolvesetTimeout(resolve, ms); // (A)
};
)
}await timeout(3_000);
Node.js 會等到 timeout()
回傳的 Promise 完成。為什麼?因為我們在 A 行排程的任務讓事件迴圈保持運作。
相反地,建立 Promise 不會增加參考計數
function foreverPending() {
return new Promise(
, _reject) => {}
(_resolve;
)
}await foreverPending(); // (A)
在這種情況下,執行會在 A 行的 await
期間暫時離開此(主要)任務。在事件迴圈結束時,參考計數為零,而 Node.js 會結束。不過,結束並未成功。也就是說,結束代碼不是 0,而是 13 (“未完成的頂層 Await”)。
我們可以手動控制逾時是否讓事件迴圈保持運作:預設情況下,透過 setImmediate()
、setInterval()
和 setTimeout()
排程的任務會在它們待處理時讓事件迴圈保持運作。這些函式會回傳 類別 Timeout
的執行個體,其方法 .unref()
會變更該預設值,讓逾時保持作用不會阻止 Node.js 結束。方法 .ref()
會還原預設值。
Tim Perry 提到了 .unref()
的使用案例:他的函式庫使用 setInterval()
重複執行背景任務。該任務會阻止應用程式結束。他透過 .unref()
修復了這個問題。
libuv 是以 C 編寫的函式庫,支援許多平台(Windows、macOS、Linux 等)。Node.js 使用它來處理 I/O 以及更多。
網路 I/O 是非同步的,不會封鎖目前的執行緒。此類 I/O 包括
為了處理非同步 I/O,libuv 使用原生核心 API 並訂閱 I/O 事件(Linux 上的 epoll;BSD Unix 上的 kqueue,包括 macOS;SunOS 上的事件埠;Windows 上的 IOCP)。然後,當事件發生時,它會收到通知。所有這些活動,包括 I/O 本身,都會在主執行緒上發生。
有些原生 I/O API 是封鎖的(非非同步的)—例如檔案 I/O 和一些 DNS 服務。libuv 從執行緒池(所謂的「工作執行緒池」)中的執行緒呼叫這些 API。這讓主執行緒能夠非同步地使用這些 API。
libuv 協助 Node.js 的不只是 I/O。其他功能包括
順帶一提,libuv 有自己的事件迴圈,您可以在 GitHub 儲存庫 libuv/libuv
中查看其原始碼(函式 uv_run()
)。
如果我們希望 Node.js 對 I/O 保持回應,我們應避免在主執行緒任務中執行長時間運算。有兩種方法可以做到這一點
setImmediate()
執行每個部分。這讓事件迴圈能夠在各部分之間執行 I/O。
以下小節涵蓋了幾個卸載選項。
工作執行緒實作跨平台 Web Workers API,有一些差異,例如
工作執行緒必須從模組匯入,Web Workers 則透過全域變數存取。
在工作執行緒中,監聽訊息和張貼訊息是透過瀏覽器中全域物件的方法來完成的。在 Node.js 中,我們改為匯入parentPort
。
我們可以在工作執行緒中使用大多數 Node.js API。在瀏覽器中,我們的選擇較受限(我們無法使用 DOM 等)。
在 Node.js 中,可傳輸的物件更多(所有類別延伸內部類別JSTransferable
的物件)比在瀏覽器中更多。
一方面,工作執行緒真的是執行緒:它們比程序更輕量,並在與主執行緒相同的程序中執行。
另一方面
Atomics
提供原子操作和同步原語,有助於使用 SharedArrayBuffers。如需更多資訊,請參閱Node.js 工作執行緒文件。
群集是 Node.js 專屬的 API。它讓我們可以執行 Node.js 程序的群集,用於分配工作負載。這些程序完全隔離,但會共用伺服器埠。它們可以透過通道傳遞 JSON 資料來進行通訊。
如果我們不需要程序隔離,可以使用更輕量的 Worker 執行緒。
子程序是另一個 Node.js 專屬的 API。它讓我們可以產生執行原生命令的新程序(通常透過原生殼層)。§12「在子程序中執行殼層命令」中介紹了此 API。
Node.js 事件迴圈
process.nextTick()
”關於事件迴圈的影片(複習本章所需的一些背景知識)
libuv
JavaScript 並行