模組化是一種將軟體功能抽離成獨立、可互動的軟體設計技術,能促進大型應用程式和系統的構建。
Node.js內建了兩種模組系統,分別是預設的CommonJS模組和瀏覽器所支援的ECMAScript模組。
其中,ECMAScript模組是在8.5.0版本中新增的,後面又經過了幾輪的迭代。本文若無特別說明,那麼分析的都是CommonJS模組。
順便說一句,本系列分析的是Node.js的最新版本18.0.0,在Github上下載原始碼後,可以關注下面3個目錄。
├── deps 第三方依賴
├── lib 對外暴露的標準庫JavaScript原始碼,例如path、fs等
├── src 支撐Node執行的C/C++ 原始碼檔案,例如HTTP解析、程序處理等
本系列所有的範例原始碼都已上傳至Github,點選此處獲取。
還有一點需要指出,Node.js的官方說明檔案,是我目前為止遇到的比較符合人類閱讀的檔案。
先來分析一下CommonJS模組的基礎語法,在Node.js中,可通過 module.exports 和 exports 來匯出一個模組,再通過 require() 來匯入一個模組。
來看個簡單的範例,先在 1.js 檔案中宣告 human 物件,然後使用 module.exports 匯出,然後在 2.js 中匯入 1.js 檔案,列印輸出。
// 1.js const human = { name: 'strick' } module.exports = human; // 2.js const human = require('./1.js'); console.log(human); // { name: 'strick' }
exports 是 module.exports 的快捷方式,但是不能對其直接賦值,像下面這樣匯出的就是一個空物件。
// 3.js exports = { name: 'strick' }; // 2.js const human = require('./3.js'); console.log(human); // {}
接下來換一種寫法,為 exports 新增一個屬性,這樣就能正確匯出。
// 3.js exports.human = { name: 'strick' }; // 2.js const human = require('./3.js'); console.log(human); // { human: { name: 'strick' } }
module.exports 匯出了它所指向的物件,而 exports 匯出的是物件的屬性。
在Node.js中,可分成兩大類的模組:核心模組和第三方模組。
其中核心模組又分成 built-in 模組和 native 模組,前者由C/C++編寫,存在於原始碼的src目錄中;後者由JavaScript編寫,存在於lib目錄中。
注意,在 lib/internal/modules 目錄中,可以檢視兩種模組系統的原始碼。
所有非Node.js自帶的模組統稱為第三方模組,也就是任意檔案,大家自己寫的業務程式碼以及依賴的第三方應用庫都屬於此範疇。
Node.js會使用模組封裝器(如下所示)將模組中的程式碼包裹,形成模組作用域,這樣就能避免模組之間的作用域汙染。
(function(exports, require, module, __filename, __dirname) { // 模組程式碼實際存在於此處 });
__filename可以得到當前模組的絕對路徑加檔名。__dirname表示當前模組的目錄名,也包含絕對路徑,與 path.dirname() 相同。
console.log(__filename); // /Users/strick/code/web/node/01/4.js console.log(__dirname); // /Users/strick/code/web/node/01
1)require()
在lib/internal/modules/cjs/loader.js中宣告了 require() 函數,requireDepth 記載了模組載入的深度。
Module.prototype.require = function(id) { validateString(id, 'id'); // 判斷id變數是否是字串型別 if (id === '') { throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string'); } requireDepth++; try { return Module._load(id, this, /* isMain */ false); } finally { requireDepth--; } };
在 _load() 中實現了主要的載入邏輯,原始碼比較長,做了些刪減,只列出了關鍵部分。
Module._load = function(request, parent, isMain) { // 解析模組的路徑和名稱 const filename = Module._resolveFilename(request, parent, isMain); // 核心模組使用 node: 字首,會繞過 require 快取 if (StringPrototypeStartsWith(filename, 'node:')) { const id = StringPrototypeSlice(filename, 5); // Slice 'node:' prefix const module = loadNativeModule(id, request); if (!module?.canBeRequiredByUsers) { throw new ERR_UNKNOWN_BUILTIN_MODULE(filename); } return module.exports; } // 第一種情況:如果快取中已經存在此模組,那麼返回模組的 exports 屬性 const cachedModule = Module._cache[filename]; if (cachedModule !== undefined) { updateChildren(parent, cachedModule, true); if (!cachedModule.loaded) { const parseCachedModule = cjsParseCache.get(cachedModule); if (!parseCachedModule || parseCachedModule.loaded) return getExportsForCircularRequire(cachedModule); parseCachedModule.loaded = true; } else { return cachedModule.exports; } } // 第二種情況:如果是核心模組,那麼呼叫 NativeModule.prototype.compileForPublicLoader() 返回模組的 exports 屬性 const mod = loadNativeModule(filename, request); if (mod?.canBeRequiredByUsers && NativeModule.canBeRequiredWithoutScheme(filename)) { return mod.exports; } // 第三種情況:如果是第三方檔案,那麼建立一個新模組並載入檔案內容,再將其儲存到快取中 const module = cachedModule || new Module(filename, parent); Module._cache[filename] = module; return module.exports; };
在 _load() 方法中,會先判斷 node: 字首(在官方檔案的核心模組中有過介紹),然後列出3種載入情況:
Node.js在載入JS檔案時,會先判斷是否有快取,然後讀取檔案內容,再呼叫 _compile() 進行編譯,下面的原始碼也做了刪減。
還有另外兩種 .json 和 .node 字尾的檔案載入過程在此省略。
Module._extensions['.js'] = function(module, filename) { // 如果已經分析了源,那麼它將被快取 const cached = cjsParseCache.get(module); let content; if (cached?.source) { content = cached.source; cached.source = undefined; } else { content = fs.readFileSync(filename, 'utf8'); } module._compile(content, filename); };
在 _compile() 方法中會呼叫vm模組建立沙盒,再執行函數程式碼,原始碼比較長,在此省略。
注意,雖然 vm 可以在V8虛擬機器器的上下文中編譯和執行JavaScript程式碼,但是它比eval()更為安全,因為它執行的指令碼無權存取外部作用域。
2)載入順序
經過上面的原始碼分析,可知載入順序是先快取,再核心模組,最後第三方模組,再詳細一點的話就是:
(1)快取,模組在第一次載入後被快取,也就是說,解析相同的檔案,會返回完全相同的物件,除非修改require.cache。
(2)核心模組,部分核心模組已被編譯成二進位制檔案,載入到了記憶體中。
(3)檔案模組的載入過程如下:
(4)目錄作為模組的載入過程如下:
(5)從 node_modules 目錄載入,若不是核心模組並且沒有路徑字首,那麼從當前模組的目錄向上查詢,並新增 /node_modules,直至根目錄為止。
例如,在'/Users/strick/code/tmp.js' 中呼叫require('test.js'),那麼將按以下順序查詢:
(6)從全域性目錄載入,一種官方不推薦的載入方式。
如果 NODE_PATH 環境變數設定為以冒號分隔的絕對路徑列表,則 Node.js 將在這些路徑中搜尋模組(如果它們在其他地方找不到)。
3)迴圈參照
在Node.js中,當兩個模組通過 require() 函數載入對方時,就形成了迴圈參照,但不會形成死迴圈。
下面的範例來自於官網,對其做了些調整。
先建立 a.js,在載入 b 模組之前,done 是 false,並且宣告了一個 globalVar 變數,沒有為其新增任何宣告變數的關鍵字,在 b 模組載入完成後,done 賦值為 true。
console.log('a starting'); exports.done = false; globalVar = '全域性變數'; // 在a模組中宣告的全域性變數 const b = require('./b.js'); console.log('在a模組中, b.done = %j', b.done); exports.done = true; console.log('a done');
再建立 b.js,在載入 a 模組之前,done 也是 false,在 a 模組載入完成之後,done 也賦值為 true。
console.log('b starting'); exports.done = false; const a = require('./a.js'); console.log('在b模組中, a.done = %j', a.done); console.log('globalVar: ', globalVar); exports.done = true; console.log('b done');
最後建立 main.js,再載入 b 模組。
console.log('main starting'); const a = require('./a.js'); // 先匯入a模組 const b = require('./b.js'); // 再匯入b模組 console.log('在main模組中, a.done = %j, b.done = %j', a.done, b.done);
最終的列印順序如下所示,在 main.js 中,先載入 a 模組,而在 a 模組中會嘗試載入 b 模組。那麼在進入到 b 模組後,為了防止無限死迴圈,會匯出 a 模組已執行完成的部分。
main starting a starting b starting 在b模組中, a.done = false globalVar: 全域性變數 b done 在a模組中, b.done = true a done 在main模組中, a.done = true, b.done = true
在上述範例中,還涉及到另一個問題,那就是在 a 模組中宣告的 globalVar 變數,能在 b 模組中被成功列印。
在上文中也曾提到過模組封裝器,那麼 globalVar 變數的宣告和列印,相當於下面這樣,如果在函數內宣告變數時省略 var 關鍵字,那麼這個變數就會變成全域性變數。
// a.js (function (exports, require, module, __filename, __dirname) { globalVar = '全域性變數'; }); // b.js (function (exports, require, module, __filename, __dirname) { console.log(globalVar); });
若要避免汙染全域性作用域,那麼可以宣告嚴格模式,禁止隱式的全域性宣告,如下所示。
'use strict';
globalVar = '全域性變數';
5)與ECMAScript模組的差異
(1)import 語句只允許在 ES 模組中使用,但可以匯入兩種模組;而 CommonJS 的 require() 不能匯入 ES 模組。
(2)ES 模組的 import 是非同步執行的;而 CommonJS 模組的 require() 是同步執行的。
(3)ES 模組沒有 __filename、__dirname、require.cache、module.exports 等變數。
(4)ES 模組是編譯時輸出,可以靜態分析模組依賴;而 CommonJS 是執行時載入。
(5)ES 模組輸出的是值參照;而 CommonJS 模組輸出的是值副本。
需要通過一個範例來理解第五點差異,首先建立 lib.mjs 檔案,.mjs 是 Node.js 為 ES 模組保留的字尾,在此類檔案內可使用 export 和 import 語法。
在 lib.mjs 檔案中,宣告 digit 變數和 increase() 函數,在函數中對 digit 執行遞增,通過 export 將它們匯出。
// lib.mjs export let digit = 0; export function increase() { digit++; }
在 main.mjs 檔案中,載入 lib.mjs,列印 digit 變數,值為 0,呼叫 increase() 函數,再列印,值變為 1。由此可知,外部可以修改模組內部的值。
// main.mjs import { digit, increase } from './lib.mjs'; console.log(digit); // 0 increase(); console.log(digit); // 1
接下來建立 lib.js 檔案,同樣是 digit 變數和 increase() 函數,通過 module.exports 將它們匯出。
// lib.js let digit = 0; function increase() { digit++; } module.exports.digit = digit; module.exports.increase = increase;
在 main.js 檔案中,載入 lib.js,列印 digit 變數,值為 0,呼叫 increase() 函數,再列印,仍然是 0。由此可知,外部無法修改模組內部的值。
// main.js const lib = require('./lib'); console.log(lib.digit); // 0 lib.increase(); console.log(lib.digit); // 0
(6)ES 模組不管是否遇到迴圈參照,其 import 匯入的變數都會成為一個指向被載入模組的參照,而 CommonJS 模組遇到迴圈參照只會匯出模組已執行完成的部分。
這其實也是兩者載入機制的不同所導致的,參考第四點不同。
CommonJS 對迴圈參照的處理過程在上文中已介紹,現在改造之前官網的範例,在 main.mjs 中匯入 a 和 b 兩個模組,並列印 a 和 b 的值。
// main.mjs import a from './a.mjs'; import b from './b.mjs'; console.log('在main模組中, a = %j, b = %j', a, b);
在 a.mjs 中,會匯入 b.mjs,並列印 b 的值。而在 b.mjs 中,會匯入 a.mjs,並列印 a 的值,如此就形成了迴圈參照。
// a.mjs import b from './b.mjs'; let done = false; export default done; console.log('在a模組中, b = %j', b); // b.mjs import a from './a.mjs'; let done = false; export default done; console.log('在b模組中, a = %j', a);
執行 main.mjs,馬上就會報錯:ReferenceError: Cannot access 'a' before initialization。
在 main.mjs 中讀取 a 的值時,會執行 a.mjs 並讀取 b 的值,而在 b.mjs 中,預設會認為 a 已存在,但在存取的時候就會發現被欺騙,然後就報錯了。
參考資料:
為什麼 Node.js 不給每一個.js檔案以獨立的上下文來避免作用域被汙染?
What’s the difference between CommonJS and ES6 modules?