一.require() 時發生了什麼?
Node.js 中,模組載入過程分為 5 步:

-
路徑解析(Resolution):根據模組標識找出對應模組(入口)檔案的絕對路徑
-
載入(Loading):如果是 JSON 或 JS 檔案,就把檔案內容讀入記憶體。如果是內建的原生模組,將其共享庫動態連結到當前 Node.js 程序
-
包裝(Wrapping):將檔案內容(JS 程式碼)包進一個函式,建立模組作用域,
exports, require, module等作為引數注入 -
執行(Evaluation):傳入引數,執行包裝得到的函式
-
快取(Caching):函式執行完畢後,將
module快取起來,並把module.exports作為require()的返回值返回
其中,模組標識(Module Identifiers) 就是傳入 require(id) 的第一個字串引數 id,例如 require('./myModule') 中的 './myModule',無需指定副檔名(但帶上也無礙)
對於 .、..、/ 開頭的檔案路徑,嘗試當做檔案、目錄來匹配,具體過程 如下:
-
若路徑存在並且是個檔案,就當做 JS 程式碼來載入(無論檔案副檔名是什麼,
require(./myModule.abcd)完全正確) -
若不存在,依次嘗試拼上
.js、.json、.node(Node.js 支援的二進位制擴充套件)副檔名 -
如果路徑存在並且是個資料夾,就在該目錄下找
package.json,取其main欄位,並載入指定的模組(相當於一次重定向) -
如果沒有
package.json,就依次嘗試index.js、index.json、index.node
對於模組標識不是檔案路徑的,先看是不是 Node.js 原生模組(fs、path 等)。如果不是,就從當前目錄開始,逐級向上在各個 node_modules 下找,一直找到頂層的 /node_modules,以及一些全域性目錄:
-
NODE_PATH環境變數中指定的位置 -
預設的全域性目錄:
$HOME/.node_modules、$HOME/.node_libraries和$PREFIX/lib/node
P.S. 關於全域性目錄的更多資訊,見 Loading from the global folders
找到模組檔案後,讀取內容,並包一層函式:
(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});
(摘自 The module wrapper)
執行時從外部注入這些模組變數(exports, require, module, __filename, __dirname),模組匯出的東西通過 module.exports 帶出來,並將整個 module 物件快取起來,最後返回 require() 結果
迴圈依賴
特殊的,模組之間可能會出現迴圈依賴,對此,Node.js 的處理策略非常簡單:
// module1.js
exports.a = 1;
require('./module2');
exports.b = 2;
exports.c = 3;
// module2.js
const module1 = require('./module1');
console.log('module1 is partially loaded here', module1);
module1.js 執行中引用了 module2.js,module2 又引了 module1,此時 module1 尚未載入完(exports.b = 2; exports.c = 3; 還沒執行)。而在 Node.js 裡,只載入了一部分的模組也可以正常引用:
When there are circular require() calls, a module might not have finished executing when it is returned.
所以 module1.js 執行結果是:
module1 is partially loaded here { a: 1 }
P.S. 關於迴圈引用的更多資訊,見 Cycles
二.Node.js 內部是怎麼實現的?
實現上,模組載入的絕大多數工作都是由 module 模組來完成的:
const Module = require('module');
console.log(Module);
Module 是個函式/類:
function Module(id = '', parent) {
this.id = id;
this.path = path.dirname(id);
// 即 module.exports
this.exports = {};
this.parent = parent;
updateChildren(parent, this, false);
this.filename = null;
this.loaded = false;
this.children = [];
}
每載入一個模組都建立一個 Module 例項,模組檔案執行完後,該例項仍然保留,模組匯出的東西依附於 Module 例項存在
模組載入的所有工作都是由 module 原生模組來完成的,包括 Module._load、Module.prototype._compile
Module._load
Module._load() 負責載入新模組、管理快取,具體如下:
Module._load = function(request, parent, isMain) {
// 0. 解析模組路徑
const filename = Module._resolveFilename(request, parent, isMain);
// 1. 優先找快取 Module._cache
const cachedModule = Module._cache[filename];
// 2. 嘗試匹配原生模組
const mod = loadNativeModule(filename, request, experimentalModules);
// 3. 未命中快取,也沒匹配到原生模組,就建立一個新的 Module 例項
const module = new Module(filename, parent);
// 4. 把新例項快取起來
Module._cache[filename] = module;
// 5. 載入模組
module.load(filename);
// 6. 如果載入/執行出錯了,就刪掉快取
if (threw) {
delete Module._cache[filename];
}
// 7. 返回 module.exports
return module.exports;
};
Module.prototype.load = function(filename) {
// 0. 判定模組型別
const extension = findLongestRegisteredExtension(filename);
// 1. 按型別載入模組內容
Module._extensions[extension](this, filename);
};
支援的型別有 .js、.json、.node 3 種:
// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
// 1. 讀取 JS 檔案內容
const content = fs.readFileSync(filename, 'utf8');
// 2. 包裝、執行
module._compile(content, filename);
};
// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
// 1. 讀取 JSON 檔案內容
const content = fs.readFileSync(filename, 'utf8');
// 2. 直接 JSON.parse() 完事
module.exports = JSONParse(stripBOM(content));
};
// Native extension for .node
Module._extensions['.node'] = function(module, filename) {
// 動態載入共享庫
return process.dlopen(module, path.toNamespacedPath(filename));
};
P.S. process.dlopen 具體見 process.dlopen(module, filename[, flags])
Module.prototype._compile
Module.prototype._compile = function(content, filename) {
// 1. 包一層函式
const compiledWrapper = wrapSafe(filename, content, this);
// 2. 把要注入的引數準備好
const dirname = path.dirname(filename);
const require = makeRequireFunction(this, redirects);
const exports = this.exports;
const thisValue = exports;
const module = this;
// 3. 注入引數、執行
compiledWrapper.call(thisValue, exports, require, module, filename, dirname);
};
包裝部分的實現如下:
function wrapSafe(filename, content, cjsModuleInstance) {
let compiled = compileFunction(
content,
filename,
0,
0,
undefined,
false,
undefined,
[],
[
'exports',
'require',
'module',
'__filename',
'__dirname',
]
);
return compiled.function;
}
P.S. 模組載入的完整實現見 node/lib/internal/modules/cjs/loader.js
三。知道這些有什麼用?
知道了模組的載入機制,在一些需要擴充套件篡改載入邏輯的場景很有用,比如用來實現虛擬模組、模組別名等
虛擬模組
比如,VS Code 外掛通過 require('vscode') 來訪問外掛 API:
// The module 'vscode' contains the VS Code extensibility API
import * as vscode from 'vscode';
而 vscode 模組實際上是不存在的,是個執行時擴充套件出來的虛擬模組:
// ref: src/vs/workbench/api/node/extHost.api.impl.ts
function defineAPI() {
const node_module = <any>require.__$__nodeRequire('module');
const original = node_module._load;
// 1. 劫持 Module._load
node_module._load = function load(request, parent, isMain) {
if (request !== 'vscode') {
return original.apply(this, arguments);
}
// 2. 注入虛擬模組 vscode
// get extension id from filename and api for extension
const ext = extensionPaths.findSubstr(parent.filename);
let apiImpl = extApiImpl.get(ext.id);
if (!apiImpl) {
apiImpl = factory(ext);
extApiImpl.set(ext.id, apiImpl);
}
return apiImpl;
};
}
具體見 [API 注入機制及外掛啟動流程_VSCode 外掛開發筆記 2](/articles/api 注入機制及外掛啟動流程-vscode 外掛開發筆記 2/),這裡不再贅述
模組別名
類似的,可以通過重寫 Module._resolveFilename 來實現模組別名,比如把 proj/src 中的 @lib/my-module 模組引用對映到 proj/lib/my-module:
// src/index.js
require('./patchModule');
const myModule = require('@lib/my-module');
console.log(myModule);
patchModule 具體實現如下:
const Module = require('module');
const path = require('path');
const _resolveFilename = Module._resolveFilename;
Module._resolveFilename = function(request) {
const args = Array.from(arguments);
// 別名對映
const LIB_PREFIX = '@lib/';
if (request.startsWith(LIB_PREFIX)) {
console.log(request);
request = path.resolve(__dirname, '../' + request.slice(1));
args[0] = request;
console.log(` => ${request}`);
}
return _resolveFilename.apply(null, args);
}
P.S. 當然,一般不需要這樣做,可以通過 Webpack 等構建工具來完成
清掉快取
預設 Node.js 模組載入過就有快取,而有些時候可能想要禁掉快取,強制重新載入一個模組,比如想要讀取能被使用者頻繁修改的 JS 檔案(如 webpack.config.js)
此時可以手動刪掉掛在 require.cache 身上的 module.exports 快取:
delete require.cache[require.resolve('./b.js')]
然而,如果 b.js 還引用了其它外部(非原生)模組,也需要一併刪除:
const mod = require.cache[require.resolve('./b.js')];
// 把引用樹上所有模組快取全都刪掉
(function traverse(mod) {
mod.children.forEach((child) => {
traverse(child);
});
console.log('decache ' + mod.id);
delete require.cache[mod.id];
}(mod));
P.S. 或者採用 decache 模組
暫無評論,快來發表你的看法吧