一文讀懂NodeJs知識體系和原理淺析

coder2028發表於2023-03-03

node.js 初探

Node.js 是一個 JS 的服務端執行環境,簡單的來說,它是在 JS 語言規範的基礎上,封裝了一些服務端的執行時物件,讓我們能夠簡單實現非常多的業務功能。

如果我們只使用 JS 的話,實際上只是能進行一些簡單的邏輯運算。node.js 就是基於 JS 語法增加與作業系統之間的互動。

node.js 的安裝

我們可以使用多種方式來安裝 node.js,node.js 本質上也是一種軟體,我們可以使用直接下載二進位制安裝檔案安裝,透過系統包管理進行安裝或者透過原始碼自行編譯均可。

一般來講,對於個人開發的電腦,我們推薦直接透過 node.js 官網的二進位制安裝檔案來安裝。對於打包上線的一些 node.js 環境,也可以透過二進位制編譯的形式來安裝。

安裝成功之後,我們的 node 命令就會自動加入我們的系統環境變數 path 中,我們就能直接在全域性使用 node 命令訪問到我們剛才安裝的 node 可執行命令列工具。

node.js 版本切換在個人電腦上,我們可以安裝一些工具,對 node.js 版本進行切換,例如 nvmn

nvm 的全稱就是 node version manager,意思就是能夠管理 node 版本的一個工具,它提供了一種直接透過 shell 執行的方式來進行安裝。簡單來說,就是透過將多個 node 版本安裝在指定路徑,然後透過 nvm 命令切換時,就會切換我們環境變數中 node 命令指定的實際執行的軟體路徑。

安裝成功之後,我們就能在當前的作業系統中使用多個 node.js 版本。

包管理工具 npm

curl -o- https://raw.githubusercontent.com/nvm- sh/nvm/v0.35.3/install.sh | bash

我們對 npm 應該都比較熟悉了,它是 node.js 內建的一款工具,目的在於安裝和釋出符合 node.js 標準的模組,從而實現社群共建的目的繁榮整個社群。

npx 是 npm@5 之後新增的一個命令,它使得我們可以在不安裝模組到當前環境的前提下,使用一些 cli 功能。

例如 npx create-react-app some-repo

node.js 的底層依賴

node.js 的主要依賴子模組有以下內容:

V8 引擎

主要是 JS 語法的解析,有了它才能識別 JS 語法\

libuv

c 語言實現的一個高效能非同步非阻塞 IO 庫,用來實現 node.js 的事件迴圈

http-parser/llhttp

底層處理 http 請求,處理報文, 解析請求包等內容

openssl

處理加密演演算法,各種框架運用廣泛

zlib

處理壓縮等內容 node.js 常⻅內建模組

主要模組

node.js 中最主要的內容,就是實現了一套 CommonJS 的模組化規範,以及內建了一些常⻅的模組。

fs:

檔案系統,能夠讀取寫入當前安裝系統環境中硬 盤的資料\

path:

路徑系統,能夠處理路徑之間的問題

crypto:

加密相關模組,能夠以標準的加密方式對我 們的內容進行加解密

dns:

處理 dns 相關內容,例如我們可以設定 dns 服 務器等等\

http:

設定一個 http 伺服器,傳送 http 請求,監聽 響應等等

readline:

讀取 stdin 的一行內容,可以讀取、增加、 刪除我們命令列中的內容\

os:

作業系統層面的一些 api,例如告訴你當前系統類 型及一些引數

vm:

一個專⻔處理沙箱的虛擬機器模組,底層主要來調 用 v8 相關 api 進行程式碼解析。

V8 引擎:

引擎只是解析層面,具體的上層還有許多具體環境的封裝。

Debug & 記憶體洩漏

對於瀏覽器的 JS 程式碼來說,我們可以透過斷點進行分步除錯,每一步列印當前上下文中的變數結果,來定位具體問題出現在哪一步。

我們可以藉助 VSCode 或者自行打斷點的形式,來進行分步 node.js 除錯。

對於 JS 記憶體洩漏,我們也可以使用同樣的道理,藉助工具,列印每次的記憶體快照,對比得出程式碼中的問題。

另一種 JS 解析引擎 quickjs

quickjs 是一個 JS 的解析引擎,輕量程式碼量也不大,與之功能類似的就是 V8 引擎。

他最大的特點就是,非常非常輕量,這點從原始碼中也能體現,事實上並沒有太多的程式碼,它的主要特點和優勢:

  1. 輕量而且易於嵌入: 只需幾個C檔案,沒有外部依賴,一個x86下的簡單的“hello world”程式只要180 KiB
  2. 具有極低啟動時間的快速直譯器: 在一臺單核的臺式PC上,大約在100秒內執行ECMAScript 測試套件156000次的執行時例項完整生命週期在不到300微秒的時間內完成。
  3. 幾乎完整實現ES2019支援,包括: 模組,非同步生成器和和完整Annex B(MPEG-2 transport stream format格式)支援 (傳統的Web相容性)。許多ES2020中帶來的特性也依然會被支援。 透過100%的ECMAScript Test Suite測試。 可以將Javascript源編譯為沒有外部依賴的可執行檔案。

另一類 JS 執行時服務端環境 deno

deno 是一類類似於 node.js 的 JS 執行時環境,同時它也是由 node.js 之父一手打造出來的,他和 node.js 比有什麼區別呢?

相同點:
  • deno 也是基於 V8 ,上層封裝一些系統級別的呼叫我們的 deno 應用也可以使用 JS 開發
不同點:
  • deno 基於 rust 和 typescript 開發一些上層模組,所以我們可以直接在 deno 應用中書寫 ts
  • deno 支援從 url 載入模組,同時支援 top level await 等特性

全域性物件解析

JavaScript 中有一個特殊的物件,稱為全域性物件(Global Object),它及其所有屬性都可以在程式的任何地方訪問,即全域性變數。

在瀏覽器 JavaScript 中,通常 window 是全域性物件, 而 Node.js 中的全域性物件是 global,所有全域性變數(除了 global 本身以外)都是 global 物件的屬性。

在 Node.js 我們可以直接訪問到 global 的屬性,而不需要在應用中包含它。

全域性物件和全域性變數

global 最根本的作用是作為全域性變數的宿主。按照 ECMAScript 的定義,滿足以下條 件的變數是全域性變數:

在最外層定義的變數;
全域性物件的屬性;
隱式定義的變數(未定義直接賦值的變數)。
當你定義一個全域性變數時,這個變數同時也會成為全域性物件的屬性,反之亦然。需要注 意的是,在 Node.js 中你不可能在最外層定義變數,因為所有使用者程式碼都是屬於當前模組的, 而模組本身不是最外層上下文。

注意: 永遠使用 var 定義變數以避免引入全域性變數,因為全域性變數會汙染 名稱空間,提高程式碼的耦合風險。

__filename

__filename 表示當前正在執行的指令碼的檔名。它將輸出檔案所在位置的絕對路徑,且和命令列引數所指定的檔名不一定相同。 如果在模組中,返回的值是模組檔案的路徑。

console.log( __filename );

__dirname

__dirname 表示當前執行指令碼所在的目錄。

console.log( __dirname );

setTimeout(cb, ms)

setTimeout(cb, ms) 全域性函式在指定的毫秒(ms)數後執行指定函式(cb)。:setTimeout() 只執行一次指定函式。

返回一個代表定時器的控制程式碼值。

function printHello(){
   console.log( "Hello, World!");
}
// 兩秒後執行以上函式
setTimeout(printHello, 2000);

clearTimeout、setInterval、clearInterval、console 在js中比較常見,故不做展開。

process

process 是一個全域性變數,即 global 物件的屬性。

它用於描述當前Node.js 程式狀態的物件,提供了一個與作業系統的簡單介面。通常在你寫本地命令列程式的時候,少不了要 和它打交道。下面將會介紹 process 物件的一些最常用的成員方法。

  1. exit
    當程式準備退出時觸發。
  2. beforeExit
    當 node 清空事件迴圈,並且沒有其他安排時觸發這個事件。通常來說,當沒有程式安排時 node 退出,但是 ‘beforeExit’ 的監聽器可以非同步呼叫,這樣 node 就會繼續執行。
  3. uncaughtException
    當一個異常冒泡回到事件迴圈,觸發這個事件。如果給異常新增了監視器,預設的操作(列印堆疊跟蹤資訊並退出)就不會發生。
  4. Signal 事件
    當程式接收到訊號時就觸發。訊號列表詳見標準的 POSIX 訊號名,如 SIGINT、SIGUSR1 等。

參考 前端進階面試題詳細解答

process.on('exit', function(code) {
  // 以下程式碼永遠不會執行
  setTimeout(function() {
    console.log("該程式碼不會執行");
  }, 0);

  console.log('退出碼為:', code);
});
console.log("程式執行結束");

退出的狀態碼

  1. Uncaught Fatal Exception
    有未捕獲異常,並且沒有被域或 uncaughtException 處理函式處理。
  2. Internal JavaScript Parse Error
    JavaScript的原始碼啟動 Node 程式時引起解析錯誤。非常罕見,僅會在開發 Node 時才會有。
  3. Internal JavaScript Evaluation Failure
    JavaScript 的原始碼啟動 Node 程式,評估時返回函式失敗。非常罕見,僅會在開發 Node 時才會有。
  4. Fatal Error
    V8 裡致命的不可恢復的錯誤。通常會列印到 stderr ,內容為: FATAL ERROR
  5. Non-function Internal Exception Handler
    未捕獲異常,內部異常處理函式不知為何設定為on-function,並且不能被呼叫。
  6. Internal Exception Handler Run-Time Failure
    未捕獲的異常, 並且異常處理函式處理時自己丟擲了異常。例如,如果 process.on(‘uncaughtException’) 或 domain.on(‘error’) 丟擲了異常。
  7. Invalid Argument
    可能是給了未知的引數,或者給的引數沒有值。
  8. Internal JavaScript Run-Time Failure
    JavaScript的原始碼啟動 Node 程式時丟擲錯誤,非常罕見,僅會在開發 Node 時才會有。
  9. Invalid Debug Argument
    設定了引數–debug 和/或 –debug-brk,但是選擇了錯誤埠。
  10. Signal Exits
    如果 Node 接收到致命訊號,比如SIGKILL 或 SIGHUP,那麼退出程式碼就是128 加訊號程式碼。這是標準的 Unix 做法,退出訊號程式碼放在高位。
// 輸出到終端
process.stdout.write("Hello World!" + "\n");

// 透過引數讀取
process.argv.forEach(function(val, index, array) {
   console.log(index + ': ' + val);
});

// 獲取執行路局
console.log(process.execPath);

// 平臺資訊
console.log(process.platform);
試試看這段程式碼輸出什麼
// this in NodeJS global scope is the current module.exports object, not the global object.

console.log(this);    // {}

module.exports.foo = 5;

console.log(this);   // { foo:5 }

Buffer

在瞭解Nodejs的Buffer之前, 先看幾個基本概念。

背景知識

1. ArrayBuffer

ArrayBuffer 物件用來表示通用的、固定長度的原始二進位制資料緩衝區。

ArrayBuffer 不能直接操作,而是要透過型別陣列物件或 DataView 物件來操作,它們會將緩衝區中的資料表示為特定的格式,並透過這些格式來讀寫緩衝區的內容。

可以把它理解為一塊記憶體, 具體存什麼需要其他的宣告。

new ArrayBuffer(length)

// 引數:length 表示要建立的 ArrayBuffer 的大小,單位為位元組。
// 返回值:一個指定大小的 ArrayBuffer 物件,其內容被初始化為 0。
// 異常:如果 length 大於 Number.MAX_SAFE_INTEGER(>= 2 ** 53)或為負數,則丟擲一個 RangeError 異常。

ex. 比如這段程式碼, 可以執行一下看看輸出什麼

var buffer = new ArrayBuffer(8);
var view = new Int16Array(buffer);

console.log(buffer);
console.log(view);

2. Unit8Array

Uint8Array 陣列型別表示一個 8 位無符號整型陣列,建立時內容被初始化為 0。
建立完後,可以物件的方式或使用陣列下標索引的方式引用陣列中的元素。

// 來自長度
var uint8 = new Uint8Array(2);
uint8[0] = 42;
console.log(uint8[0]); // 42
console.log(uint8.length); // 2
console.log(uint8.BYTES_PER_ELEMENT); // 1

// 來自陣列
var arr = new Uint8Array([21,31]);
console.log(arr[1]); // 31

// 來自另一個 TypedArray
var x = new Uint8Array([21, 31]);
var y = new Uint8Array(x);
console.log(y[0]); // 21

3. ArrayBuffer 和 TypedArray

TypedArray: Unit8Array, Int32Array這些都是TypedArray, 那些 Uint32Array 也好,Int16Array 也好,都是給 ArrayBuffer 提供了一個 “View”,MDN上的原話叫做 “Multiple views on the same data”,對它們進行下標讀寫,最終都會反應到它所建立在的 ArrayBuffer 之上。

ArrayBuffer 本身只是一個 0 和 1 存放在一行裡面的一個集合,ArrayBuffer 不知道第一個和第二個元素在陣列中該如何分配。

為了能提供上下文,我們需要將其封裝在一個叫做 View 的東西里面。這些在資料上的 View 可以被新增進確定型別的陣列,而且我們有很多種確定型別的資料可以使用。

  1. 總結

總之, ArrayBuffer 基本上扮演了一個原生記憶體的角色.

NodeJs Buffer

Buffer 類以一種更最佳化、更適合 Node.js 用例的方式實現了 Uint8Array API.

Buffer 類的例項類似於整數陣列,但 Buffer 的大小是固定的、且在 V8 堆外分配實體記憶體。

Buffer 的大小在被建立時確定,且無法調整。

基本使用

// 建立一個長度為 10、且用 0 填充的 Buffer。
const buf1 = Buffer.alloc(10);

// 建立一個長度為 10、且用 0x1 填充的 Buffer。 
const buf2 = Buffer.alloc(10, 1);

// 建立一個長度為 10、且未初始化的 Buffer。
// 這個方法比呼叫 Buffer.alloc() 更快,
// 但返回的 Buffer 例項可能包含舊資料,
// 因此需要使用 fill() 或 write() 重寫。
const buf3 = Buffer.allocUnsafe(10);

// 建立一個包含 [0x1, 0x2, 0x3] 的 Buffer。
const buf4 = Buffer.from([1, 2, 3]);

// 建立一個包含 UTF-8 位元組  的 Buffer。
const buf5 = Buffer.from('tést');
tips

當呼叫 Buffer.allocUnsafe() 時,被分配的記憶體段是未初始化的(沒有用 0 填充)。

雖然這樣的設計使得記憶體的分配非常快,但已分配的記憶體段可能包含潛在的敏感舊資料。 使用透過 Buffer.allocUnsafe() 建立的沒有被完全重寫記憶體的 Buffer ,在 Buffer記憶體可讀的情況下,可能洩露它的舊資料。
雖然使用 Buffer.allocUnsafe() 有明顯的效能優勢,但必須額外小心,以避免給應用程式引入安全漏洞。

Buffer 與字元編碼

Buffer 例項一般用於表示編碼字元的序列,比如 UTF-8 、 UCS2 、 Base64 、或十六進位制編碼的資料。 透過使用顯式的字元編碼,就可以在 Buffer 例項與普通的 JavaScript 字串之間進行相互轉換。

const buf = Buffer.from('hello world', 'ascii');

console.log(buf)

// 輸出 68656c6c6f20776f726c64
console.log(buf.toString('hex'));

// 輸出 aGVsbG8gd29ybGQ=
console.log(buf.toString('base64'));

Buffer 與字元編碼

Buffer 例項一般用於表示編碼字元的序列,比如 UTF-8 、 UCS2 、 Base64 、或十六進位制編碼的資料。 透過使用顯式的字元編碼,就可以在 Buffer 例項與普通的 JavaScript 字串之間進行相互轉換。

const buf = Buffer.from('hello world', 'ascii');

console.log(buf)

// 輸出 68656c6c6f20776f726c64
console.log(buf.toString('hex'));

// 輸出 aGVsbG8gd29ybGQ=
console.log(buf.toString('base64'));

Node.js 目前支援的字元編碼包括:

  1. 'ascii' - 僅支援 7 位 ASCII 資料。如果設定去掉高位的話,這種編碼是非常快的。
  2. 'utf8' - 多位元組編碼的 Unicode 字元。許多網頁和其他檔案格式都使用 UTF-8 。
  3. 'utf16le' - 2 或 4 個位元組,小位元組序編碼的 Unicode 字元。支援代理對(U+10000 至 U+10FFFF)。
  4. 'ucs2' - 'utf16le' 的別名。
  5. 'base64' - Base64 編碼。當從字串建立 Buffer 時,按照 RFC4648 第 5 章的規定,這種編碼也將正確地接受 “URL 與檔名安全字母表”。
  6. 'latin1' - 一種把 Buffer 編碼成一位元組編碼的字串的方式(由 IANA 定義在 RFC1345 第 63 頁,用作 Latin-1 補充塊與 C0/C1 控制碼)。
  7. 'binary' - 'latin1' 的別名。
  8. 'hex' - 將每個位元組編碼為兩個十六進位制字元。

Buffer 記憶體管理

在介紹 Buffer 記憶體管理之前,我們要先來介紹一下 Buffer 內部的 8K 記憶體池。

8K 記憶體池
  1. 在 Node.js 應用程式啟動時,為了方便地、高效地使用 Buffer,會建立一個大小為 8K 的記憶體池。
Buffer.poolSize = 8 * 1024; // 8K
var poolSize, poolOffset, allocPool;

// 建立記憶體池
function createPool() {
  poolSize = Buffer.poolSize;
  allocPool = createUnsafeArrayBuffer(poolSize);
  poolOffset = 0;
}

createPool();
  1. 在 createPool() 函式中,透過呼叫 createUnsafeArrayBuffer() 函式來建立 poolSize(即8K)的 ArrayBuffer 物件。createUnsafeArrayBuffer() 函式的實現如下:
function createUnsafeArrayBuffer(size) {
  zeroFill[0] = 0;
  try {
    return new ArrayBuffer(size); // 建立指定size大小的ArrayBuffer物件,其內容被初始化為0。
  } finally {
    zeroFill[0] = 1;
  }
}

這裡你只需知道 Node.js 應用程式啟動時,內部有個 8K 的記憶體池即可。

  1. 前面簡單介紹了 ArrayBuffer 和 Unit8Array 相關的基礎知識,而 ArrayBuffer 的應用在 8K 的記憶體池部分的已經介紹過了。那接下來當然要輪到 Unit8Array 了,我們再來回顧一下它的語法:
Uint8Array(length);
Uint8Array(typedArray);
Uint8Array(object);
Uint8Array(buffer [, byteOffset [, length]]);

其實除了 Buffer 類外,還有一個 FastBuffer 類,該類的宣告如下:

class FastBuffer extends Uint8Array {
  constructor(arg1, arg2, arg3) {
    super(arg1, arg2, arg3);
  }
}

是不是知道 Uint8Array 用在哪裡了,在 FastBuffer 類的建構函式中,透過呼叫 Uint8Array(buffer [, byteOffset [, length]]) 來建立 Uint8Array 物件。

  1. 那麼現在問題來了,FastBuffer 有什麼用?它和 Buffer 類有什麼關係?帶著這兩個問題,我們先來一起分析下面的簡單示例:
const buf = Buffer.from('semlinker');
console.log(buf); // <Buffer 73 65 6d 6c 69 6e 6b 65 72>

為什麼輸出了一串數字, 我們建立的字串呢? 來看一下原始碼

/** * Functionally equivalent to Buffer(arg, encoding) but throws a TypeError * if value is a number. * Buffer.from(str[, encoding]) * Buffer.from(array) * Buffer.from(buffer) * Buffer.from(arrayBuffer[, byteOffset[, length]]) **/
Buffer.from = function from(value, encodingOrOffset, length) {
  if (typeof value === "string") return fromString(value, encodingOrOffset);
  // 處理其它資料型別,省略異常處理等其它程式碼
  if (isAnyArrayBuffer(value))
    return fromArrayBuffer(value, encodingOrOffset, length);
  var b = fromObject(value);
};

可以看出 Buffer.from() 工廠函式,支援基於多種資料型別(string、array、buffer 等)建立 Buffer 物件。對於字串型別的資料,內部呼叫 fromString(value, encodingOrOffset) 方法來建立 Buffer 物件。

是時候來會一會 fromString() 方法了,它內部實現如下:

function fromString(string, encoding) {
  var length;
  if (typeof encoding !== "string" || encoding.length === 0) {
    if (string.length === 0) return new FastBuffer();
    // 若未設定編碼,則預設使用utf8編碼。
    encoding = "utf8"; 
    // 使用 buffer binding 提供的方法計算string的長度
    length = byteLengthUtf8(string);
  } else {
    // 基於指定的 encoding 計算string的長度
    length = byteLength(string, encoding, true);
    if (length === -1)
      throw new errors.TypeError("ERR_UNKNOWN_ENCODING", encoding);
    if (string.length === 0) return new FastBuffer();
  }

  // 當字串所需位元組數大於4KB,則直接進行記憶體分配
  if (length >= Buffer.poolSize >>> 1)
    // 使用 buffer binding 提供的方法,建立buffer物件
    return createFromString(string, encoding);

  // 當剩餘的空間小於所需的位元組長度,則先重新申請8K記憶體
  if (length > poolSize - poolOffset)
    // allocPool = createUnsafeArrayBuffer(8K); poolOffset = 0;
    createPool(); 
  // 建立 FastBuffer 物件,並寫入資料。
  var b = new FastBuffer(allocPool, poolOffset, length);
  const actual = b.write(string, encoding);
  if (actual !== length) {
    // byteLength() may overestimate. That's a rare case, though.
    b = new FastBuffer(allocPool, poolOffset, actual);
  }
  // 更新pool的偏移
  poolOffset += actual;
  alignPool();
  return b;

所以我們得到這樣的結論

  1. 當未設定編碼的時候,預設使用 utf8 編碼;
  2. 當字串所需位元組數大於4KB,則直接進行記憶體分配;
  3. 當字串所需位元組數小於4KB,但超過預分配的 8K 記憶體池的剩餘空間,則重新申請 8K 的記憶體池;
  4. 呼叫 new FastBuffer(allocPool, poolOffset, length) 建立 FastBuffer 物件,進行資料儲存,資料成功儲存後,會進行長度校驗、更新 poolOffset 偏移量和位元組對齊等操作。

事件迴圈模型

什麼是事件迴圈

事件迴圈使 Node.js 可以透過將操作轉移到系統核心中來執行非阻塞 I/O 操作(儘管 JavaScript 是單執行緒的)。

由於大多數現代核心都是多執行緒的,因此它們可以處理在後臺執行的多個操作。 當這些操作之一完成時,核心會告訴 Node.js,以便可以將適當的回撥新增到輪詢佇列中以最終執行。

Node.js 啟動時,它將初始化事件迴圈,處理提供的輸入指令碼,這些指令碼可能會進行非同步 API 呼叫,排程計時器或呼叫 process.nextTick, 然後開始處理事件迴圈。

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

每個階段都有一個要執行的回撥 FIFO 佇列。 儘管每個階段都有其自己的特殊方式,但是通常,當事件迴圈進入給定階段時,它將執行該階段特定的任何操作,然後在該階段的佇列中執行回撥,直到佇列耗盡或執行回撥的最大數量為止。 當佇列已為空或達到回撥限制時,事件迴圈將移至下一個階段。

  1. timers:此階段執行由 setTimeoutsetInterval 設定的回撥。
  2. pending callbacks:執行推遲到下一個迴圈迭代的 I/O 回撥。
  3. idle, prepare, :僅在內部使用。
  4. poll:取出新完成的 I/O 事件;執行與 I/O 相關的回撥(除了關閉回撥,計時器排程的回撥和 setImmediate 之外,幾乎所有這些回撥) 適當時,node 將在此處阻塞。
  5. check:在這裡呼叫 setImmediate 回撥。
  6. close callbacks:一些關閉回撥,例如 socket.on('close', ...)

在每次事件迴圈執行之間,Node.js 會檢查它是否正在等待任何非同步 I/O 或 timers,如果沒有,則將其乾淨地關閉。

各階段詳細解析

timers 計時器階段

計時器可以在回撥後面指定時間閾值,但這不是我們希望其執行的確切時間。 計時器回撥將在經過指定的時間後儘早執行。 但是,作業系統排程或其他回撥的執行可能會延遲它們,即執行的實際時間不確定。

const fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

當事件迴圈進入 poll 階段時,它有一個空佇列(fs.readFile 尚未完成),因此它將等待直到達到最快的計時器 timer 閾值為止。

等待 95 ms 過去時,fs.readFile 完成讀取檔案,並將需要 10ms 完成的其回撥新增到輪詢 (poll) 佇列並執行。

回撥完成後,佇列中不再有回撥,此時事件迴圈已達到最早計時器 (timer) 的閾值 (100ms),然後返回到計時器 (timer) 階段以執行計時器的回撥。

pending callbacks 階段

此階段執行某些系統操作的回撥,例如 TCP 錯誤,平時無需關注。

輪詢 poll 階段

輪詢階段具有兩個主要功能:

  1. 計算應該阻塞並 I/O 輪詢的時間
  2. 處理輪詢佇列 (poll queue) 中的事件

當事件迴圈進入輪詢 (poll) 階段並且沒有任何計時器排程 (timers scheduled) 時,將發生以下兩種情況之一:

  1. 如果輪詢佇列 (poll queue) 不為空,則事件迴圈將遍歷其回撥佇列,使其同步執行,直到佇列用盡或達到與系統相關的硬性限制為止。
  2. 如果輪詢佇列為空,則會發生以下兩種情況之一:
    2.1 如果已透過 setImmediate 排程了指令碼,則事件迴圈將結束輪詢 poll 階段,並繼續執行 check 階段以執行那些排程的指令碼。
    2.2 如果指令碼並沒有 setImmediate 設定回撥,則事件迴圈將等待 poll 佇列中的回撥,然後立即執行它們。

一旦輪詢佇列 (poll queue) 為空,事件迴圈將檢查哪些計時器 timer 已經到時間。 如果一個或多個計時器 timer 準備就緒,則事件迴圈將返回到計時器階段,以執行這些計時器的回撥。

檢查階段 check

此階段允許在輪詢 poll 階段完成後立即執行回撥。 如果輪詢 poll 階段處於空閒,並且指令碼已使用 setImmediate 進入 check 佇列,則事件迴圈可能會進入 check 階段,而不是在 poll 階段等待。

setImmediate 實際上是一個特殊的計時器,它在事件迴圈的單獨階段執行。 它使用 libuv API,該 API 計劃在輪詢階段完成後執行回撥。

通常,在執行程式碼時,事件迴圈最終將到達輪詢 poll 階段,在該階段它將等待傳入的連線,請求等。但是,如果已使用 setImmediate 設定回撥並且輪詢階段變為空閒,則它將將結束並進入 check 階段,而不是等待輪詢事件。

注意:setImmediate為實驗性方法,可能不會被批准成為標準,目前只有最新版本的 Internet Explorer 和 Node.js 0.10+ 實現了該方法。

close callbacks 階段

如果套接字或控制程式碼突然關閉(例如 socket.destroy),則在此階段將發出 'close' 事件。 否則它將透過 process.nextTick 發出。

setImmediate 和 setTimeout 的區別

setImmediate 和 setTimeout 相似,但是根據呼叫時間的不同,它們的行為也不同。

  • setImmediate 設計為在當前輪詢 poll 階段完成後執行指令碼。
  • setTimeout 計劃在以毫秒為單位的最小閾值過去之後執行指令碼。

Tips: 計時器的執行順序將根據呼叫它們的上下文而有所不同。 如果兩者都是主模組中呼叫的,則時序將受到程式效能的限制.

來看兩個例子:

  1. 在主模組中執行

    兩者的執行順序是不固定的, 可能timeout在前, 也可能immediate在前

    setTimeout(() => {
    console.log('timeout');
    }, 0);
    
    setImmediate(() => {
    console.log('immediate');
    });
  2. 在同一個I/O回撥裡執行

    setImmediate總是先執行

    const fs = require('fs');
    
    fs.readFile(__filename, () => {
       setTimeout(() => {
           console.log('timeout');
       }, 0);
       setImmediate(() => {
           console.log('immediate');
       });
    });

問題:那為什麼在外部 (比如主程式碼部分 mainline) 這兩者的執行順序不確定呢?

解答:在 主程式碼 部分執行 setTimeout 設定定時器 (此時還沒有寫入佇列),與 setImmediate 寫入 check 佇列。

mainline 執行完開始事件迴圈,第一階段是 timers,這時候 timers 佇列可能為空,也可能有回撥;
如果沒有那麼執行 check 佇列的回撥,下一輪迴圈在檢查並執行 timers 佇列的回撥;
如果有就先執行 timers 的回撥,再執行 check 階段的回撥。因此這是 timers 的不確定性導致的。

process.nextTick

process.nextTick 從技術上講不是事件迴圈的一部分。 相反,無論事件迴圈的當前階段如何,都將在當前操作完成之後處理 nextTickQueue

process.nextTick 和 setImmediate 的區別

  • process.nextTick 在同一階段立即觸發
  • setImmediate fires on the following iteration or 'tick' of the event loop (在事件迴圈接下來的階段迭代中執行 - check 階段)。

nextTick在事件迴圈中的位置

           ┌───────────────────────────┐
        ┌─>│           timers          │
        │  └─────────────┬─────────────┘
        │           nextTickQueue
        │  ┌─────────────┴─────────────┐
        │  │     pending callbacks     │
        │  └─────────────┬─────────────┘
        │           nextTickQueue
        │  ┌─────────────┴─────────────┐
        |  |     idle, prepare         │
        |  └─────────────┬─────────────┘
  nextTickQueue     nextTickQueue
        |  ┌─────────────┴─────────────┐
        |  │           poll            │
        │  └─────────────┬─────────────┘
        │           nextTickQueue
        │  ┌─────────────┴─────────────┐
        │  │           check           │
        │  └─────────────┬─────────────┘
        │           nextTickQueue
        │  ┌─────────────┴─────────────┐
        └──┤       close callbacks     │
           └───────────────────────────┘

Microtasks 微任務

在 Node 領域,微任務是來自以下物件的回撥:

  1. process.nextTick()
  2. then()

在主線結束後以及事件迴圈的每個階段之後,立即執行微任務回撥。

resolved 的 promise.then 回撥像微處理一樣執行,就像 process.nextTick 一樣。 雖然,如果兩者都在同一個微任務佇列中,則將首先執行 process.nextTick 的回撥。

優先順序 process.nextTick > promise.then

執行程式碼看看輸出順序

async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}
async function async2() {
    console.log('async2')
}
console.log('script start')
setTimeout(function () {
    console.log('setTimeout0')
    setTimeout(function () {
        console.log('setTimeout1');
    }, 0);
    setImmediate(() => console.log('setImmediate'));
}, 0)

process.nextTick(() => console.log('nextTick'));
async1();
new Promise(function (resolve) {
    console.log('promise1')
    resolve();
    console.log('promise2')
}).then(function () {
    console.log('promise3')
})
console.log('script end')

Events

events模組是node的核心模組之一,幾乎所有常用的node模組都繼承了events模組,比如http、fs等。

模組本身非常簡單,API雖然也不少,但常用的就那麼幾個,這裡舉幾個簡單例子。

例子1:單個事件監聽器

var EventEmitter = require('events');

class Man extends EventEmitter {}

var man = new Man();

man.on('wakeup', function(){
    console.log('man has woken up');
});

man.emit('wakeup');
// 輸出如下:
// man has woken up

例子2:同個事件,多個事件監聽器

可以看到,事件觸發時,事件監聽器按照註冊的順序執行。

var EventEmitter = require('events');

class Man extends EventEmitter {}

var man = new Man();

man.on('wakeup', function(){
    console.log('man has woken up');
});

man.on('wakeup', function(){
    console.log('man has woken up again');
});

man.emit('wakeup');

// 輸出如下:
// man has woken up
// man has woken up again

例子3:只執行一次的事件監聽器

var EventEmitter = require('events');

class Man extends EventEmitter {}

var man = new Man();

man.on('wakeup', function(){
    console.log('man has woken up');
});

man.once('wakeup', function(){
    console.log('man has woken up again');
});

man.emit('wakeup');
man.emit('wakeup');

// 輸出如下:
// man has woken up
// man has woken up again
// man has woken up

例子4:註冊事件監聽器前,事件先觸發

可以看到,註冊事件監聽器前,事件先觸發,則該事件會直接被忽略。

var EventEmitter = require('events');

class Man extends EventEmitter {}

var man = new Man();

man.emit('wakeup', 1);

man.on('wakeup', function(index){
    console.log('man has woken up ->' + index);
});

man.emit('wakeup', 2);
// 輸出如下:
// man has woken up ->2

例子5:非同步執行,還是順序執行

例子很簡單,但非常重要。究竟是程式碼1先執行,還是程式碼2先執行,這點差異,無論對於我們理解別人的程式碼,還是自己編寫node程式,都非常關鍵。

實踐證明,程式碼1先執行了

var EventEmitter = require('events');

class Man extends EventEmitter {}

var man = new Man();

man.on('wakeup', function(){
    console.log('man has woken up'); // 程式碼1
});

man.emit('wakeup');

console.log('woman has woken up');  // 程式碼2

// 輸出如下:
// man has woken up
// woman has woken up

例子6:移除事件監聽器

var EventEmitter = require('events');

function wakeup(){
    console.log('man has woken up');
}

class Man extends EventEmitter {}

var man = new Man();

man.on('wakeup', wakeup);
man.emit('wakeup');

man.removeListener('wakeup', wakeup);
man.emit('wakeup');

// 輸出如下:
// man has woken up

手寫實現EventEmitter

event.js ,使用釋出訂閱模式實現,原理非常簡單,就是在內部用一個物件儲存事件和回撥的對應關係,並且在合適的時候進行觸發。

let effects = [];

function depend(obj) { // 收集依賴
  effects.push(obj);
}
function notify(key, data) { // 執行依賴
  const fnList = effects.filter(x => x.name === key);
  fnList.forEach(list => list.fn(data))
}

export default {
  $emit(name, data) {
    notify(name, data);
  },
  $on(name, fn) {
    depend({ name, fn });
    return () => { this.$off(name, fn) }; // 為了方便銷燬事件,將方法吐出
  },
  $off(name, fn) {
    const fnList = effects.filter(x => x.name === name);
    effects = fnList.filter(x => x.fn !== fn);
  }
}
};

呼叫:

import bus from "./event";

const busoff = bus.$on('effect', (data) => { // TODO ... data.id ... }) // 註冊事件

bus.$emit('effect', { id: xxx }) // 觸發事件

busoff() // 事件銷燬

Stream

在構建較複雜的系統時,通常將其拆解為功能獨立的若干部分。這些部分的介面遵循一定的規範,透過某種方式相連,以共同完成較複雜的任務。譬如,shell透過管道|連線各部分,其輸入輸出的規範是文字流。

在Node.js中,內建的Stream模組也實現了類似功能,各部分透過.pipe()連線。

Stream提供了以下四種型別的流:

var Stream = require('stream')

var Readable = Stream.Readable
var Writable = Stream.Writable
var Duplex = Stream.Duplex
var Transform = Stream.Transform

使用Stream可實現資料的流式處理,如:

var fs = require('fs')
// `fs.createReadStream`建立一個`Readable`物件以讀取`bigFile`的內容,並輸出到標準輸出
// 如果使用`fs.readFile`則可能由於檔案過大而失敗
fs.createReadStream(bigFile).pipe(process.stdout)

Readable

建立可讀流。

例項:流式消耗迭代器中的資料。

'use strict'
const Readable = require('stream').Readable

class ToReadable extends Readable {
  constructor(iterator) {
    super()
    this.iterator = iterator
  }

  // 子類需要實現該方法
  // 這是生產資料的邏輯
  _read() {
    const res = this.iterator.next()
    if (res.done) {
      // 資料來源已枯竭,呼叫`push(null)`通知流
      return this.push(null)
    }
    setTimeout(() => {
      // 透過`push`方法將資料新增到流中
      this.push(res.value + '\n')
    }, 0)
  }
}

module.exports = ToReadable

實際使用時,new ToReadable(iterator)會返回一個可讀流,下游可以流式的消耗迭代器中的資料。

const iterator = function (limit) {
  return {
    next: function () {
      if (limit--) {
        return { done: false, value: limit + Math.random() }
      }
      return { done: true }
    }
  }
}(1e10)

const readable = new ToReadable(iterator)

// 監聽`data`事件,一次獲取一個資料
readable.on('data', data => process.stdout.write(data))

// 所有資料均已讀完
readable.on('end', () => process.stdout.write('DONE'))

執行上述程式碼,將會有100億個隨機數源源不斷地寫進標準輸出流。

建立可讀流時,需要繼承Readable,並實現_read方法。

  • _read方法是從底層系統讀取具體資料的邏輯,即生產資料的邏輯。
  • 在_read方法中,透過呼叫push(data)將資料放入可讀流中供下游消耗。
  • 在_read方法中,可以同步呼叫push(data),也可以非同步呼叫。
  • 當全部資料都生產出來後,必須呼叫push(null)來結束可讀流。
  • 流一旦結束,便不能再呼叫push(data)新增資料。

可以透過監聽data事件的方式消耗可讀流。

  • 在首次監聽其data事件後,readable便會持續不斷地呼叫_read(),透過觸發data事件將資料輸出。
  • 第一次data事件會在下一個tick中觸發,所以,可以安全地將資料輸出前的邏輯放在事件監聽後(同一個tick中)。
  • 當資料全部被消耗時,會觸發end事件。

上面的例子中,process.stdout代表標準輸出流,實際是一個可寫流。下小節中介紹可寫流的用法。

Writable

建立可寫流。

前面透過繼承的方式去建立一類可讀流,這種方法也適用於建立一類可寫流,只是需要實現的是_write(data, enc, next)方法,而不是_read()方法。

有些簡單的情況下不需要建立一類流,而只是一個流物件,可以用如下方式去做:

const Writable = require('stream').Writable

const writable = Writable()
// 實現`_write`方法
// 這是將資料寫入底層的邏輯
writable._write = function (data, enc, next) {
  // 將流中的資料寫入底層
  process.stdout.write(data.toString().toUpperCase())
  // 寫入完成時,呼叫`next()`方法通知流傳入下一個資料
  process.nextTick(next)
}

// 所有資料均已寫入底層
writable.on('finish', () => process.stdout.write('DONE'))

// 將一個資料寫入流中
writable.write('a' + '\n')
writable.write('b' + '\n')
writable.write('c' + '\n')

// 再無資料寫入流時,需要呼叫`end`方法
writable.end()
  • 上游透過呼叫writable.write(data)將資料寫入可寫流中。write()方法會呼叫_write()將data寫入底層。
  • 在_write中,當資料成功寫入底層後,必須呼叫next(err)告訴流開始處理下一個資料。
  • next的呼叫既可以是同步的,也可以是非同步的。
  • 上游必須呼叫writable.end(data)來結束可寫流,data是可選的。此後,不能再呼叫write新增資料。
  • 在end方法呼叫後,當所有底層的寫操作均完成時,會觸發finish事件。

Duplex

建立可讀可寫流。

Duplex實際上就是繼承了Readable和Writable的一類流。 所以,一個Duplex物件既可當成可讀流來使用(需要實現_read方法),也可當成可寫流來使用(需要實現_write方法)。

var Duplex = require('stream').Duplex

var duplex = Duplex()

// 可讀端底層讀取邏輯
duplex._read = function () {
  this._readNum = this._readNum || 0
  if (this._readNum > 1) {
    this.push(null)
  } else {
    this.push('' + (this._readNum++))
  }
}

// 可寫端底層寫邏輯
duplex._write = function (buf, enc, next) {
  // a, b
  process.stdout.write('_write ' + buf.toString() + '\n')
  next()
}

// 0, 1
duplex.on('data', data => console.log('ondata', data.toString()))

duplex.write('a')
duplex.write('b')
duplex.write('x')


duplex.end()

上面的程式碼中實現了_read方法,所以可以監聽data事件來消耗Duplex產生的資料。 同時,又實現了_write方法,可作為下游去消耗資料。

因為它既可讀又可寫,所以稱它有兩端:可寫端和可讀端。 可寫端的介面與Writable一致,作為下游來使用;可讀端的介面與Readable一致,作為上游來使用。

Transform

在上面的例子中,可讀流中的資料(0, 1)與可寫流中的資料(’a’, ‘b’)是隔離開的,但在Transform中可寫端寫入的資料經變換後會自動新增到可讀端。 Tranform繼承自Duplex,並已經實現了_read和_write方法,同時要求使用者實現一個_transform方法。

'use strict'

const Transform = require('stream').Transform

class Rotate extends Transform {
  constructor(n) {
    super()
    // 將字母移動`n`個位置
    this.offset = (n || 13) % 26
  }

  // 將可寫端寫入的資料變換後新增到可讀端
  _transform(buf, enc, next) {
    var res = buf.toString().split('').map(c => {
      var code = c.charCodeAt(0)
      if (c >= 'a' && c <= 'z') {
        code += this.offset
        if (code > 'z'.charCodeAt(0)) {
          code -= 26
        }
      } else if (c >= 'A' && c <= 'Z') {
        code += this.offset
        if (code > 'Z'.charCodeAt(0)) {
          code -= 26
        }
      }
      return String.fromCharCode(code)
    }).join('')

    // 呼叫push方法將變換後的資料新增到可讀端
    this.push(res)
    // 呼叫next方法準備處理下一個
    next()
  }

}

var transform = new Rotate(3)
transform.on('data', data => process.stdout.write(data))
transform.write('hello, ')
transform.write('world!')
transform.end()

資料型別

前面幾節的例子中,經常看到呼叫data.toString()。這個toString()的呼叫是必需的嗎?

在shell中,用管道(|)連線上下游。上游輸出的是文字流(標準輸出流),下游輸入的也是文字流(標準輸入流)

對於可讀流來說,push(data)時,data只能是String或Buffer型別,而消耗時data事件輸出的資料都是Buffer型別。對於可寫流來說,write(data)時,data只能是String或Buffer型別,_write(data)呼叫時傳進來的data都是Buffer型別。

也就是說,流中的資料預設情況下都是Buffer型別。產生的資料一放入流中,便轉成Buffer被消耗;寫入的資料在傳給底層寫邏輯時,也被轉成Buffer型別。

但每個建構函式都接收一個配置物件,有一個objectMode的選項,一旦設定為true,就能出現“種瓜得瓜,種豆得豆”的效果。

  1. Readable未設定objectMode時:
const Readable = require('stream').Readable

const readable = Readable()

readable.push('a')
readable.push('b')
readable.push(null)

readable.on('data', data => console.log(data))
  1. Readable設定objectMode後:
const Readable = require('stream').Readable

const readable = Readable({ objectMode: true })

readable.push('a')
readable.push('b')
readable.push({})
readable.push(null)

readable.on('data', data => console.log(data))

可見,設定objectMode後,push(data)的資料被原樣地輸出了。此時,可以生產任意型別的資料。

相關文章