深入學習 Node.js Module
Node.js 遵循 ofollow,noindex">CommonJS規範 ,該規範的核心思想是允許模組通過 require
方法來 同步載入所要依賴的其他模組 ,然後通過 exports
或 module.exports
來匯出需要暴露的介面。CommonJS 規範是為了解決 JavaScript 的作用域問題而定義的模組形式,可以使每個模組它自身的名稱空間中執行。
示例
add.js
module.exports = (a, b) => a + b;
calculate.js
const add = require("./add"); console.log("Result: ", add(2, 3));
CommonJS 也有瀏覽器端的實現,其原理是現將所有模組都定義好並通過 id
索引,這樣就可以方便的在瀏覽器環境中解析了,可以參考 require1k 和 tiny-browser-require 的原始碼來理解其解析(resolve)的過程。
Node.js 模組分類
在 Node.js 中包含以下幾類模組:
- builtin module: Node.js 中以 C++ 形式提供的模組,如 tcp_wrap、contextify 等
- constants module: Node.js 中定義常量的模組,用來匯出如 signal,openssl 庫、檔案訪問許可權等常量的定義。如檔案訪問許可權中的 O_RDONLY,O_CREAT、signal 中的 SIGHUP,SIGINT 等。
- native module: Node.js 中以 JavaScript 形式提供的模組,如 http、https、fs 等。有些 native module 需要藉助於 builtin module 實現背後的功能。 如對於 native 模組 buffer , 還是需要藉助 builtin node_buffer.cc 中提供的功能來實現大容量記憶體申請和管理,目的是能夠脫離 V8 記憶體大小使用限制 。
- 3rd-party module: 以上模組可以統稱 Node.js 內建模組,除此之外為第三方模組,典型的如 express 模組。
module 物件
每個模組內部,都有一個 module
物件,代表當前模組。它有以下屬性。
module.id module.filename module.loaded module.parent module.children module.exports
Node.js vm
vm
模組提供了一系列 API 用於在 V8 虛擬機器環境中編譯和執行程式碼。JavaScript 程式碼可以被編譯並立即執行,或編譯、儲存然後再執行。
vm.runInThisContext(code[, options])
vm.runInThisContext()
在當前的 global
物件的上下文中編譯並執行 code
,最後返回結果。執行中的程式碼無法獲取本地作用域,但可以獲取當前的 global
物件。
示例
const vm = require('vm'); let localVar = 'initial value'; const vmResult = vm.runInThisContext('localVar = "vm";'); console.log('vmResult:', vmResult); console.log('localVar:', localVar); const evalResult = eval('localVar = "eval";'); console.log('evalResult:', evalResult); console.log('localVar:', localVar); // vmResult: 'vm', localVar: 'initial value' // evalResult: 'eval', localVar: 'eval'
正因 vm.runInThisContext()
無法獲取本地作用域,故 localVar
的值不變。相反, eval()
確實能獲取本地作用域,所以 localVar
的值被改變了。
Node.js Module
Node.js 有一個簡單的模組載入系統。 在 Node.js 中,檔案和模組是一一對應的(每個檔案被視為一個獨立的模組)。廢話不多說,小夥伴們讓我們一起開啟 Node.js Module 的探索之旅吧,這次旅程我們會帶著以下問題:
- 模組中的 module、exports、
__dirname
、__filename
和 require 來自何方? - module.exports 與 exports 有什麼區別?
- 模組出現迴圈依賴了,會出現死迴圈麼?
- require 函式支援匯入哪幾類檔案?
- require 函式執行的主要流程是什麼?
在這次旅程結束後,希望小夥伴對上述的問題,能夠有一個較為清楚的認識。
Module 基本使用
foo.js 模組
const circle = require('./circle.js'); console.log(`半徑為 4 的圓的面積是 ${circle.area(4)}`);
circle.js 模組
const { PI } = Math; exports.area = (r) => PI * r ** 2; exports.circumference = (r) => 2 * PI * r;
circle.js
模組匯出了 area()
和 circumference()
兩個函式。 通過在特殊的 exports
物件上指定額外的屬性,函式和物件可以被新增到模組的根部。
在 circle.js
檔案中,我們使用了特殊的 exports
物件。其實除了 exports
之外,在模組中我們還可以 module、 __dirname
、 __filename
和 require 這些物件,那它們是從哪裡來的呢?好的,我們來解答第一個問題。
模組中的 module、exports、 __dirname
、 __filename
和 require 來自何方?
當然首先我要先知道它們是什麼,這裡我們新建一個模組 module-var.js
,輸入以下內容:
console.log(module); console.log(exports); console.log(__dirname); console.log(__filename); console.log(require);
執行完以上程式碼,控制檯的輸出如下(忽略輸出物件中的大部分屬性):
Module { -------------------------------------------------> module id: '.', exports: {}, paths: [] // 模組查詢路徑 } {}------------------------------------------------->exports /Users/fer/VSCProjects/learn-node/module -----------------------------> __dirname /Users/fer/VSCProjects/learn-node/module/module-var.js -----------------> __filename { [Function: require] -------------------------------------------------> require resolve: [Function: resolve], main: Module { } // Module物件 }
通過控制檯的輸出值,我們可以清楚地看出每個變數的值。這裡先不細究它們,我們先來調查一下它們的來源。
CommonJS 規範是為了解決 JavaScript 的作用域問題而定義的模組形式,可以使每個模組它自身的名稱空間中執行。
那麼 CommonJS 規範是如何解決 JavaScript 的作用域問題,並讓每個模組在自身的名稱空間中執行呢?不知道小夥伴們是否還記得,在前端的模組方案出來之前,為了避免汙染變數汙染,我們通過以下方式來建立獨立的執行空間:
(function(global){ // some code })(window)
那麼 Node.js 是不是也是通過這種方式來解決作用域問題和程式碼封裝呢?
俗話說眼見為實,我們輸入 node --inspect-brk module-var.js
命令,除錯一下前面建立的 module-var.js
檔案:
通過上圖我們可以發現, module-var.js
檔案中定義的內容,以 (function(){})
這種形式被包裝了。這裡,我們就清楚了,模組中的 module、exports、 __dirname
、 __filename
和 require 這些物件都是函式的輸入引數,在呼叫包裝後的函式時傳入。這時第一個問題先告一段落,我們繼續探究第二個問題。
module.exports 與 exports 有什麼區別?
先不急著解釋它們之間的區別,我們先來看一行程式碼:
console.log(module.exports === exports);
執行完上面的程式碼,控制檯會輸出 true
。那好,我們繼續往下看:
exports.id = 1; // 方式一:可以正常匯出 exports = { id: 1 }; // 方式二:無法正常匯出 module.exports = { id: 1 }; // 方式三:可以正常匯出
為什麼方式二無法正常匯出呢?讓我們回顧一下執行 module-var.js
檔案時, module
和 exports
的輸出結果:
Module { -------------------------------------------------> module id: '.', exports: {}, paths: [] // 模組查詢路徑 } {}------------------------------------------------->exports
如果 module.exports === exports
執行的結果為 true,那麼表示模組中的 exports 變數與 module.exports 屬性是指向同一個物件。 當使用方式二 exports = { id: 1 }
的方式會改變 exports 變數的指向,這時與module.exports 屬性指向不同的變數,而當我們匯入某個模組時,是匯入 module.exports 屬性指向的物件 ,具體原因後面會細說。
希望通過上面的分析,小夥伴們能夠清晰地瞭解 module.exports 與 exports 之間的區別和聯絡。接下來,我們繼續第三個問題。
模組出現迴圈依賴了,會出現死迴圈麼?
首先我們先簡單解釋一下迴圈依賴,當模組 a 執行時需要依賴模組 b 中定義的屬性或方法,而在匯入模組 b 中,發現模組 b 同時也依賴模組 a 中的屬性或方法,即兩個模組之間互相依賴,這種現象我們稱之為迴圈依賴。
介紹完迴圈依賴的概念,那出現這種情況會出現死迴圈麼?我們馬上來驗證一下:
module1.js
exports.a = 1; exports.b = 2; require("./module2"); exports.c = 3;
module2.js
const Module1 = require('./module1'); console.log('Module1 is partially loaded here', Module1);
當我們在命令列中輸入 node lib/module1.js
命令,你會發現程式正常執行,並且在控制檯輸出了以下內容:
Module1 is partially loaded here { a: 1, b: 2 }
通過實際驗證,我們發現出現迴圈依賴的時候,程式並不會出現死迴圈,但只會輸出相應模組已載入的部分資料。
解釋完模組迴圈依賴的問題,我們繼續下一個問題。
require 函式支援匯入哪幾類檔案?
模組內的 require 函式,支援的檔案型別主要有 .js
、 .json
和 .node
。其中 .js
和 .json
檔案,相信大家都很熟悉了, .node
字尾的檔案是 Node.js 的二進位制檔案。然而為什麼 require 函式,只支援這三種檔案格式呢?其實答案在模組內輸出的 require
函式物件中:
{ [Function: require] resolve: [Function: resolve], main: Module {}, extensions: { '.js': [Function], '.json': [Function], '.node': [Function] } }
在 require
函式物件中,有一個 extensions
屬性,顧名思義表示它支援的副檔名。細心的小夥伴,可能已經看到了,每種副檔名對應的值都是函式物件。既然發現了它們的蹤跡,我們就來看一下它們的真面目。其實模組內的 require 函式物件是通過 lib/internal/module.js
檔案中的 makeRequireFunction
函式建立的,那我們就來看一下該函式(程式碼片段):
function makeRequireFunction(mod) { const Module = mod.constructor; function require(path) { try { exports.requireDepth += 1; return mod.require(path); } finally { exports.requireDepth -= 1; } } // Enable support to add extra extension types. require.extensions = Module._extensions; require.cache = Module._cache; return require; }
通過以上我們發現,模組內的 require 函式物件,在匯入模組時,最終還是通過呼叫 Module 物件的 require() 方法來實現模組匯入。此時,我們的重點在 require.extensions = Module._extensions;
這行程式碼上,哈哈,終於定位到了源頭。
繼續開啟 lib/module.js
檔案,我們發現了以下的定義:
// Native extension for .js Module._extensions['.js'] = function(module, filename) { var content = fs.readFileSync(filename, 'utf8'); module._compile(internalModule.stripBOM(content), filename); }; // Native extension for .json Module._extensions['.json'] = function(module, filename) { var content = fs.readFileSync(filename, 'utf8'); try { module.exports = JSON.parse(internalModule.stripBOM(content)); } catch (err) { err.message = filename + ': ' + err.message; throw err; } }; //Native extension for .node Module._extensions['.node'] = function(module, filename) { return process.dlopen(module, path.toNamespacedPath(filename)); };
.json
的檔案的處理邏輯很簡單,我們就不進一步說明了。而 .node
檔案的處理方式,因為涉及到 bindings 這個後面會有專門的文章介紹這塊內容。這裡我們就來重點介紹 .js
檔案的處理方式。
// Native extension for .js Module._extensions['.js'] = function(module, filename) { var content = fs.readFileSync(filename, 'utf8'); // (1) module._compile(internalModule.stripBOM(content), filename); // (2) };
函式體中的第一行,我們以同步的方式讀取對應的檔案內容。而第二行中,我們會對檔案的內容進行編譯,然而在編譯前我們會對內容進行處理,比如移除 BOM (Byte Order Mark),stripBOM 的具體實現如下:
function stripBOM(content) { if (content.charCodeAt(0) === 0xFEFF) { content = content.slice(1); } return content; }
位元組順序標記(英語:byte-order mark, BOM )是位於碼點 U+FEFF
的 統一碼 字元的名稱。當以 UTF-16 或 UTF-32 來將 UCS /統一碼字元所組成的字串編碼時,這個字元被用來標示其 位元組序 。它常被用來當做標示檔案是以 UTF-8 、 UTF-16 或 UTF-32 編碼的記號。 —— 維基百科
接下來我們就來重點看一下 _compile()
方法(程式碼片段):
Module.prototype._compile = function(content, filename) { // 在電腦科學中,Shebang(也稱為 Hashbang )是一個由井號和歎號構成的字元序列#! content = internalModule.stripShebang(content); // create wrapper function var wrapper = Module.wrap(content); // (1) var compiledWrapper = vm.runInThisContext(wrapper, { // (2) filename: filename, lineOffset: 0, displayErrors: true }); var dirname = path.dirname(filename); var require = internalModule.makeRequireFunction(this); var depth = internalModule.requireDepth; if (depth === 0) stat.cache = new Map(); var result; if (inspectorWrapper) { result = inspectorWrapper(compiledWrapper, this.exports, this.exports, require, this, filename, dirname); } else { result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname); } if (depth === 0) stat.cache = null; return result; };
這裡我們先來看 (1) 這一行, var wrapper = Module.wrap(content);
,即呼叫 Module 內部的封裝函式對模組的原始內容進行封裝。Module.wrap 函式實現很簡單,具體如下:
Module.wrap = function(script) { return Module.wrapper[0] + script + Module.wrapper[1]; }; Module.wrapper = [ '(function (exports, require, module, __filename, __dirname) { ', '\n});' ];
看到這裡你是不是已經恍然大悟,原來模組中的原始內容是在這個階段進行包裝的。包裝後的格式為:
(function (exports, require, module, __filename, __dirname) { // 模組原始內容 });
經過 Module.wrap 函式包裝後返回的字串,會作為 vm.runInThisContext() 方法的輸入引數,並呼叫該方法。
然後我們把方法的返回值儲存在 compiledWrapper
變數上,接著我們會準備 compiledWrapper 對應函式物件的呼叫引數,最後通過 call()
方法呼叫該函式。
OK,我們繼續下一個問題 —— require 函式執行的主要流程是什麼?
require 函式執行的主要流程是什麼?
在載入對應模組前,我們首先需要定位檔案的路徑,檔案的定位是通過 Module 內部的 _resolveFilename()
方法來實現,相關的虛擬碼描述如下:
從 Y 路徑的模組 require(X) 1. 如果 X 是一個核心模組, a. 返回核心模組 b. 結束 2. 如果 X 是以 '/' 開頭 a. 設 Y 為檔案系統根目錄 3. 如果 X 是以 './' 或 '/' 或 '../' 開頭 a. 載入檔案(Y + X) b. 載入目錄(Y + X) 4. 載入Node模組(X, dirname(Y)) 5. 丟擲 "未找到" 載入檔案(X) 1. 如果 X 是一個檔案,載入 X 作為 JavaScript 文字。結束 2. 如果 X.js 是一個檔案,載入 X.js 作為 JavaScript 文字。結束 3. 如果 X.json 是一個檔案,解析 X.json 成一個 JavaScript 物件。結束 4. 如果 X.node 是一個檔案,載入 X.node 作為二進位制外掛。結束 載入索引(X) 1. 如果 X/index.js 是一個檔案,載入 X/index.js 作為 JavaScript 文字。結束 3. 如果 X/index.json是一個檔案,解析 X/index.json 成一個 JavaScript 物件。結束 4. 如果 X/index.node 是一個檔案,載入 X/index.node 作為二進位制外掛。結束 載入目錄(X) 1. 如果 X/package.json 是一個檔案, a. 解析 X/package.json,查詢 "main" 欄位 b. let M = X + (json main 欄位) c. 載入檔案(M) d. 載入索引(M) 2. 載入索引(X) 載入Node模組(X, START) 1. let DIRS=NODE_MODULES_PATHS(START) 2. for each DIR in DIRS: a. 載入檔案(DIR/X) b. 載入目錄(DIR/X) NODE_MODULES_PATHS(START) 1. let PARTS = path split(START) 2. let I = count of PARTS - 1 3. let DIRS = [] 4. while I >= 0, a. if PARTS[I] = "node_modules" CONTINUE b. DIR = path join(PARTS[0 .. I] + "node_modules") c. DIRS = DIRS + DIR d. let I = I - 1 5. return DIRS
_resolveFilename()
方法內部的判斷邏輯比較複雜,感興趣的小夥伴,可以斷點跟蹤一下整個執行過程。這裡我們需要注意的是載入檔案、載入索引和載入目錄的主要執行過程。
接下來我們來看一下內部的 Module 物件的 require() 方法:
// Loads a module at the given file path. Returns that module's // `exports` property. Module.prototype.require = function(id) { if (typeof id !== 'string') { throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'id', 'string', id); } if (id === '') { throw new errors.Error('ERR_INVALID_ARG_VALUE', 'id', id, 'must be a non-empty string'); } return Module._load(id, this, /* isMain */ false); };
通過原始碼上的註釋,我們清楚地知道了 require 函式的作用,即用來載入給定檔案路徑的模組,並返回相應模組物件的 exports 屬性。趁熱打鐵,我們繼續來看一下 Module._load()
方法(程式碼片段):
// Check the cache for the requested file. // 1. If a module already exists in the cache: return its exports object. // 2. If the module is native: call `NativeModule.require()` with the //filename and return the result. // 3. Otherwise, create a new module for the file and save it to the cache. //Then have it loadthe file contents before returning its exports //object. Module._load = function(request, parent, isMain) { // 解析檔案的具體路徑 var filename = Module._resolveFilename(request, parent, isMain); // 優先從快取中獲取 var cachedModule = Module._cache[filename]; if (cachedModule) { updateChildren(parent, cachedModule, true); // 匯出模組的exports屬性 return cachedModule.exports; } // 判斷是否為native module,如fs、http等 if (NativeModule.nonInternalExists(filename)) { debug('load native module %s', request); return NativeModule.require(filename); } // Don't call updateChildren(), Module constructor already does. // 建立新的模組物件 var module = new Module(filename, parent); if (isMain) { process.mainModule = module; module.id = '.'; } // 快取新建的模組 Module._cache[filename] = module; // 嘗試進行模組載入 tryModuleLoad(module, filename); return module.exports; };
通過原始碼我們可以發現,模組首次被載入後,會被快取在 Module._cache 屬性中,以提高模組的匯入效率。但有些時候,我們修改了已被快取的模組,希望其它模組匯入時,獲取到更新後的內容,那應該怎麼辦呢?針對這種情況,我們可以使用以下方法清除指定快取的模組,或清理所有已快取的模組:
//刪除指定模組的快取 delete require.cache[require.resolve('/*被快取的模組名稱*/')] // 刪除所有模組的快取 Object.keys(require.cache).forEach(function(key) { delete require.cache[key]; });
最後我們再來簡單介紹一下從 node_modules
目錄載入,即通過 require('koa')
匯入 Koa 模組的載入過程。
從 node_modules
目錄載入
如果傳遞給 require()
的模組識別符號不是一個 核心模組 ,也沒有以 '/'
、 '../'
或 './'
開頭,則 Node.js 會從當前模組的父目錄開始,嘗試從它的 /node_modules
目錄里加載模組。 Node.js 不會附加 node_modules
到一個已經以 node_modules
結尾的路徑上。
如果還是沒有找到,則移動到再上一層父目錄,直到檔案系統的根目錄。
比如在 '/home/ry/projects/foo.js'
檔案裡呼叫了 require('bar.js')
,則 Node.js 會按以下順序查詢:
/home/ry/projects/node_modules/bar.js /home/ry/node_modules/bar.js /home/node_modules/bar.js /node_modules/bar.js
這使得程式本地化它們的依賴,避免它們產生衝突。通過在模組名後包含一個路徑字尾,可以請求特定的檔案或分散式的子模組。 例如, require('example-module/path/to/file')
會把 path/to/file
解析成相對於 example-module
的位置。 字尾路徑同樣遵循模組的解析語法。
總結
為了能夠更好地理解 Node.js Module 模組,我們介紹了 CommonJS、Node 模組分類、Module 物件等相關的基礎知識。然後以一系列問題為切入點,循序漸進介紹了 module.exports 與 exports 物件的區別、模組迴圈依賴、require 支援匯入的檔案型別及 require 函式執行的主要流程等相關的知識。最後我們還介紹瞭如何清除已快取的模組,從而實現模組更新和從 node_modules 目錄載入的相關內容。
希望本篇文章,能夠幫你更好地理解並掌握 Node.js 模組的相關知識,如果有寫得不好的地方,請各位小夥伴多多見諒。