nodejs模組系統原始碼分析
概述
Node.js的出現使得前端工程師可以跨端工作在伺服器上,當然,一個新的執行環境的誕生亦會帶來新的模組、功能、抑或是思想上的革新,本文將帶領讀者領略 Node.js(以下簡稱 Node) 的模組設計思想以及剖析部分核心原始碼實現。
CommonJS 規範
Node 最初遵循 CommonJS 規範來實現自己的模組系統,同時做了一部分區別於規範的定製。CommonJS 規範是為了解決javascript的作用域問題而定義的模組形式,它可以使每個模組在它自身的名稱空間中執行。
該規範強調模組必須通過module.exports匯出對外的變數或函式,通過require()來匯入其他模組的輸出到當前模組作用域中,同時,遵循以下約定:
- 在模組中,必須暴露一個 require 變數,它是一個函式,require 函式接受一個模組識別符號,require 返回外部模組的匯出的 API。如果要求的模組不能被返回則 require 必須丟擲一個錯誤。
- 在模組中,必須有一個自由變數叫做 exports,它是一個物件,模組在執行時可以在 exports 上掛載模組的屬性。模組必須使用 exports 物件作為唯一的匯出方式。
- 在模組中,必須有一個自由變數 module,它也是一個物件。module 物件必須有一個 id 屬性,它是這個模組的頂層 id。id 屬性必須是這樣的,require(module.id)會從源出module.id的那個模組返回 exports 物件(就是說 module.id 可以被傳遞到另一個模組,而且在要求它時必須返回最初的模組)。
Node 對 CommonJS 規範的實現
定義了模組內部的 module.require 函式和全域性的 require 函式,用來載入模組。
在 Node 模組系統中,每個檔案都被視為一個獨立的模組。模組被載入時,都會初始化為 Module 物件的例項,Module 物件的基本實現和屬性如下所示:
function Module(id = "",parent) { // 模組 id,通常為模組的絕對路徑 this.id = id; this.path = path.dirname(id); this.exports = {}; // 當前模組呼叫者 this.parent = parent; updateChildren(parent,this,false); this.filename = null; // 模組是否載入完成 this.loaded = false; // 當前模組所引用的模組 this.children = []; }
每一個模組都對外暴露自己的 exports 屬性作為使用介面。
模組匯出以及引用
在 Node 中,可使用 module.exports 物件整體匯出一個變數或者函式,也可將需要匯出的變數或函式掛載到 exports 物件的屬性上,程式碼如下所示:
// 1. 使用 exports: 筆者習慣通常用作對工具庫函式或常量的匯出 exports.name = 'xiaoxiang'; exports.add = (a,b) => a + b; // 2. 使用 module.exports:匯出一整個物件或者單一函式 ... module.exports = { add,minus }
通過全域性 require 函式引用模組,可傳入模組名稱、相對路徑或者絕對路徑,當模組檔案字尾為 js / json / node 時,可省略字尾,如下程式碼所示:
// 引用模組 const { add,minus } = require('./module'); const a = require('/usr/app/module'); const http = require('http');
注意事項:
exports變數是在模組的檔案級作用域內可用的,且在模組執行之前賦值給module.exports。
exports.name = 'test'; console.log(module.exports.name); // test module.export.name = 'test'; console.log(exports.name); // test
如果為exports賦予了新值,則它將不再繫結到module.exports,反之亦然:
exports = { name: 'test' }; console.log(module.exports.name,exports.name); // undefined,test
]當module.exports屬性被新物件完全替換時,通常也需要重新賦值exports:
module.exports = exports = { name: 'test' }; console.log(module.exports.name,exports.name) // test,test
模組系統實現分析模組定位
以下是require函式的程式碼實現:
// require 入口函式 Module.prototype.require = function(id) { //... requireDepth++; try { return Module._load(id,/* isMain */ false); // 載入模組 } finally { requireDepth--; } };
上述程式碼接收給定的模組路徑,其中的 requireDepth 用來記載模組載入的深度。其中 Module 的類方法_load實現了 Node 載入模組的主要邏輯,下面我們來解析Module._load函式的原始碼實現,為了方便大家理解,我把註釋加在了文中。
Module._load = function(request,parent,isMain) { // 步驟一:解析出模組的全路徑 const filename = Module._resolveFilename(request,isMain); // 步驟二:載入模組,具體分三種情況處理 // 情況一:存在快取的模組,直接返回模組的 exports 屬性 const cachedModule = Module._cache[filename]; if (cachedModule !== undefined) return cachedModule.exports; // 情況二:載入內建模組 const mod = loadNativeModule(filename,request); if (mod && mod.canBeRequiredByUsers) return mod.exports; // 情況三:構建模組載入 const module = new Module(filename,parent); // 載入過之後就進行模組例項快取 Module._cache[filename] = module; // 步驟三:載入模組檔案 module.load(filename); // 步驟四:返回匯出物件 return module.exports; };
載入策略
上面的程式碼資訊量比較大,我們主要看以下幾個問題:
模組的快取策略是什麼? 分析上述程式碼我們可以看到,_load載入函式針對三種情況給出了不同的載入策略,分別是:
- 情況一:快取命中,直接返回。
- 情況二:內建模組,返回暴露出來的 exports 屬性,也就是 module.exports 的別名。
- 情況三:使用檔案或第三方程式碼生成模組,最後返回,並且快取,這樣下次同樣的訪問就會去使用快取而不是重新載入。
Module._resolveFilename(request,isMain) 是怎麼解析出檔名稱的?
我們看如下定義的類方法:
Module._resolveFilename = function(request,isMain,options) {
if (NativeModule.canBeRequiredByUsers(request)) {
// 優先載入內建模組
return request;
}
let paths;
// node require.resolve 函式使用的 options,options.paths 用於指定查詢路徑
if (typeof options === "object" && options !== null) {
if (ArrayIsArray(options.paths)) {
const isRelative =
request.startsWith("./") ||
request.startsWybtafith("../") ||
(isWindows && request.startsWith(".\\")) ||
request.startsWith("..\\");
if (isRelative) {
paths = options.paths;
} else {
const fakeParent = new Module("",null);
paths = [];
for (let i = 0; i < options.paths.length; i++) {
const path = options.paths[i];
fakeParent.paths = Module._nodeModulePaths(path);
const lookupPaths = Module._resolveLookupPaths(request,fakeParent);
for (let j = 0; j < lookupPaths.length; j++) {
if (!paths.includes(lookupPaths[j])) paths.push(lookupPaths[j]);
}
}
}
} else if (options.paths === undefined) {
paths = Module._resolveLookupPaths(request,parent);
} else {
//...
}
} else {
// 查詢模組存在路徑
paths = Module._resolveLookupPaths(request,parent);
}
// 依據給出的模組和遍歷地址陣列,以及是否為入口模組來查詢模組路徑
const file程式設計客棧name = Module._findPath(request,paths,isMain);
if (!filename) {
const requireStack = [];
for (let cursor = parent; cursor; cursor = cursor.parent) {
requireStack.push(cursor.filename || cursor.id);
}
// 未找到模組,丟擲異常(是不是很熟悉的錯誤)
let message = `Cannot find module '${request}'`;
if (requireStack.length > 0) {
message = message + "\nRequire stack:\n- " + requireStack.join("\n- ");
}
const err = new Error(message);
err.code = "MODULE_NOT_FOUND";
err.requireStack = requireStack;
throw err;
}
// 最終返回包含檔名的完整路徑
return filename;
};
上面的程式碼中比較突出的是使用了_resolveLookupPaths和_findPath兩個方法。
_resolveLookupPaths: 通過接受模組名稱和模組呼叫者,返回提供_findPath使用的遍歷範圍陣列。
// 模組檔案定址的地址陣列方法
Module._resolveLookupPaths = function(request,parent) {
if (NativeModule.canBeRequiredByUsers(request)) {
debug("looking for %j in []",request);
return null;
}
// 如果不是相對路徑
if (
request.charAt(0) !== "." ||
(request.length > 1 &&
request.charAt(1) !== "." &&
request.charAt(1) !== "/" &&
(!isWindows || request.charAt(1) !== "\\"))
) {
/**
* 檢查 node_modules 資料夾
* modulePaths 為使用者目錄,node_path 環境變數指定目錄、全域性 node 安裝目錄
*/
let paths = modulePaths;
if (parent != null && parent.paths && parent.paths.length) {
// 父模組的 modulePath 也要加到子模組的 modulePath 裡面,往上回溯查詢
paths = parent.paths.concat(paths);
}
return paths.length > 0 ? paths : null;
}
// 使用 repl 互動時,依次查詢 ./ ./node_modules 以及 modulePaths
if (!parent || !parent.id || !parent.filename) {
const mainPaths = [http://www.cppcns.com"."].concat(Module._nodeModulePaths("."),modulePaths);
return mainPaths;
}
// 如果是相對路徑引入,則將父級資料夾路徑加入查詢路徑
const parentDir = [path.dirname(parent.filename)];
return parentDir;
};
_findPath: 依據目標模組和上述函式查詢到的範圍,找到對應的 filename 並返回。
// 依據給出的模組和遍歷地址陣列,以及是否頂層模組來尋找模組真實路徑 Module._findPath = function(request,isMain) { const absoluteRequest = path.isAbsolute(request); if (absoluteRequest) { // 絕對路徑,直接定位到具體模組 paths = [""]; } else if (!paths || paths.length === 0) { return false; } const cacheKey = request + "\x00" + (paths.length === 1 ? paths[0] : paths.join("\x00")); // 快取路徑 const entry = Module._pathCache[cacheKey]; if (entry) return entry; let exts; let trailingSlash = request.length > 0 && request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH; // '/' if (!trailingSlash) { trailingSlash = /(?:^|\/)\.?\.$/.test(request); } // For each path for (let i = 0; i < paths.length; i++) { const curPath = paths[i]; if (curPath && stat(curPath) < 1) continue; const basePath = resolveExports(curPath,request,absoluteRequest); let filename; const rc = stat(basePath); if (!trailingSlash) { if (rc === 0) { // stat 狀態返回 0,則為檔案 // File. if (!isMain) { if (preserveSymlinks) { // 當解析和快取模組時,命令模組載入器保持符號連線。 filename = path.resolve(basePath); } else { // 不保持符號連結 filename = toRealPath(basePath); } } else if (preserveSymlinksMain) { filename = path.resolve(basePath); } else { filename = toRealPath(basePath); } } if (!filename) { if (exts === undefined) exts = ObjectKeys(Module._extensions); // 解析字尾名 filename = tryExtensions(basePath,exts,isMain); } } if (!filename && rc === 1) { /** * stat 狀態返回 1 且檔名不存在,則認為是資料夾 * 如果檔案字尾不存在,則嘗試載入該目錄下的 package.json 中 main 入口指定的檔案 * 如果不存在,然後嘗試 index[.js,.node,.json] 檔案 *程式設計客棧/ if (exts === undefined) exts = ObjectKeys(Module._extensions); filename = tryPackage(basePath,request); } if (filename) { // 如果存在該檔案,將檔名則加入快取 Module._pathCache[cacheKey] = filename; return filename; } } const selfFilename = trySelf(paths,trailingSlash,request); if (selfFilename) { // 設定路徑的快取 Module._pathCache[cacheKey] = selfFilename; return selfFilename; } return false; };
模組載入
標準模組處理
閱讀完上面的程式碼,我們發現,當遇到模組是一個資料夾的時候會執行tryPackage函式的邏輯,下面簡要分析一下具體實現。
// 嘗試載入標準模組 function tryPackage(requestPath,originalPath) { const pkg = readPackageMain(requestPath); if (!pkg) { // 如果沒有 package.json 這直接使用 index 作為預設入口檔案 return tryExtensions(path.resolve(requestPath,"index"),isMain); } const filename = path.resolve(requestPath,pkg); let actual = tryFile(filename,isMain) || tryExtensions(filename,isMain) || tryExtensions(path.resolve(filename,isMain); //... return actual; } // 讀取 package.json 中的 main 欄位 function readPackageMain(requestPath) { const pkg = readPackage(requestPath); return pkg ? pkg.main : undefined; }
readPackage 函式負責讀取和解析 package.json 檔案中的內容,具體描述如下:
functionwww.cppcns.com readPackage(requestPath) {
const jsonPath = path.resolve(requestPath,"package.json");
const existing = packageJsonCache.get(jsonPath);
if (existing !== undefined) return existing;
// 呼叫 libuv uv_fs_open 的執行邏輯,讀取 package.json 檔案,並且快取
const json = internalModuleReadJSON(path.toNamespacedPath(jsonPath));
if (json === undefined) {
// 接著快取檔案
packageJsonCache.set(jsonPath,false);
return false;
}
//...
try {
const parsed = JSONParse(json);
const filtered = {
name: parsed.name,main: parsed.main,exports: parsed.exports,type: parsed.type
};
packageJsonCache.set(jsonPath,filtered);
return filtered;
} catch (e) {
//...
}
}
上面的兩段程式碼完美地解釋 package.json 檔案的作用,模組的配置入口( package.json 中的 main 欄位)以及模組的預設檔案為什麼是 index,具體流程如下圖所示:
模組檔案處理
定位到對應模組之後,該如何載入和解析呢?以下是具體程式碼分析:
Module.prototype.load = function(filename) { // 保證模組沒有載入過 assert(!this.loaded); this.filename = filename; // 找到當前資料夾的 node_modules this.paths = Module._nodeModulePaths(path.dirname(filename)); const extension = findLongestRegisteredExtension(filename); //... // 執行特定檔案字尾名解析函式 如 js / json / node Module._extensions[extension](this,filename); // 表示該模組載入成功 this.loaded = true; // ... 省略 esm 模組的支援 };
字尾處理
可以看出,針對不同的檔案字尾,Node.js 的載入方式是不同的,以下針對.js,.json,.node簡單進行分析。
.js 字尾 js 檔案讀取主要通過 Node 內建 APIfs.readFileSync實現。
Module._extensions[".js"] = function(module,filename) { // 讀取檔案內容 const content = fs.readFileSync(filename,"utf8"); // 編譯執行程式碼 module._compile(content,filename); };
json 字尾 JSON 檔案的處理邏輯比較簡單,讀取檔案內容後執行JSONParse即可拿到結果。
Module._extensions[".json"] = function(module,filename) { // 直接按照 utf-8 格式載入檔案 const content = fs.readFileSync(filename,"utf8"); //... try { // 以 JSON 物件格式匯出檔案內容 module.exports = JSONParse(stripBOM(content)); } catch (err) { //... } };
.node 字尾 .node 檔案是一種由 C / C++ 實現的原生模組,通過 process.dlopen 函式讀取,而 process.dlopen 函式實際上呼叫了 C++ 程式碼中的 DLOpen 函式,而 DLOpen 中又呼叫了 uv_dlopen,後者載入 .node 檔案,類似 OS 載入系統類庫檔案。
Module._extensions[".node"] = function(module,filename) { //... return process.dlopen(module,path.toNamespacedPath(filename)); };
從上面的三段原始碼,我們看出來並且可以理解,只有 JS 字尾最後會執行例項方法_compile,我們去除一些實驗特性和除錯相關的邏輯來簡要的分析一下這段程式碼。
編譯執行
模組載入完成後,Node 使用 V8 引擎提供的方法構建執行沙箱,並執行函式程式碼,程式碼如下所示:
Module.prototype._compile = function(content,filename) { let moduleURL; let redirects; // 向模組內部注入公共變數 __dirname / __filename / module / exports / require,並且編譯函式 const compiledWrapper = wrapSafe(filename,content,this); const dirname = path.dirname(filename); const require = makeRequireFunction(this,redirects); let result; const exports = this.exports; const thisValue = exports; const module = this; if (requireDepth === 0) statCache = new Map(); //... // 執行模組中的函式 result = compiledWrapper.call( thisValue,exports,require,module,filename,dirname ); hasLoadedAnyUserCJSModule = true; if (requireDepth === 0) statCache = null; return result; }; // 注入變數的核心邏輯 function wrapSafe(filename,cjsModuleInstance) { if (patched) { const wrapper = Module.wrap(content); // vm 沙箱執行 ,直接返回執行結果,env -> SetProtoMethod(script_tmpl,"runInThisContext",RunInThisContext); return vm.runInThisContext(wrapper,{ filename,lineOffset: 0,displayErrors: true,// 動態載入 importModuleDynamically: async specifier => { const loader = asyncESM.ESMLoader; return loader.import(specifier,normalizeReferrerURL(filename)); } }); } let compiled; try { compiled = compileFunction( content,undefined,false,[],["exports","require","module","__filename","__dirname"] ); } catch (err) { //... } const { callbackMap } = internalBinding("module_wrap"); callbackMap.set(compiled.cacheKey,{ importModuleDynamically: async specifier => { const loader = asyncESM.ESMLoader; return loader.import(specifier,normalizeReferrerURL(filename)); } }); return compiled.function; }
上述程式碼中,我們可以看到在_compile函式中呼叫了wrapwrapSafe函式,執行了__dirname / __filename / module / exports / require公共變數的注入,並且呼叫了 C++ 的 runInThisContext 方法(位於 src/node_contextify.cc 檔案)構建了模組程式碼執行的沙箱環境,並返回了compiledWrapper物件,最終通過compiledWrapper.call方法執行模組。
以上就是nodejs模組系統原始碼分析的詳細內容,更多關於nodejs模組系統原始碼分析的資料請關注我們其它相關文章!