Node.js模塊系統(tǒng)源碼探微
Node.js 的出現(xiàn)使得前端工程師可以跨端工作在服務器上,當然,一個新的運行環(huán)境的誕生亦會帶來新的模塊、功能、抑或是思想上的革新,本文將帶領讀者領略 Node.js (以下簡稱 Node) 的模塊設計思想以及剖析部分核心源碼實現(xiàn)。
CommonJS 規(guī)范
Node 最初遵循 CommonJS 規(guī)范來實現(xiàn)自己的模塊系統(tǒng),同時做了一部分區(qū)別于規(guī)范的定制。CommonJS 規(guī)范是為了解決 JavaScript 的作用域問題而定義的模塊形式,它可以使每個模塊在它自身的命名空間中執(zhí)行。
該規(guī)范強調模塊必須通過 module.exports 導出對外的變量或函數(shù),通過 require() 來導入其他模塊的輸出到當前模塊作用域中,同時,遵循以下約定:
- 在模塊中,必須暴露一個 require 變量,它是一個函數(shù),require 函數(shù)接受一個模塊標識符,require 返回外部模塊的導出的 API。如果要求的模塊不能被返回則 require 必須拋出一個錯誤。
- 在模塊中,必須有一個自由變量叫做 exports,它是一個對象,模塊在執(zhí)行時可以在 exports 上掛載模塊的屬性。模塊必須使用 exports 對象作為唯一的導出方式。
- 在模塊中,必須有一個自由變量 module,它也是一個對象。module 對象必須有一個 id 屬性,它是這個模塊的頂層 id。id 屬性必須是這樣的,require(module.id) 會從源出 module.id 的那個模塊返回 exports 對象(就是說 module.id 可以被傳遞到另一個模塊,而且在要求它時必須返回最初的模塊)。
Node 對 CommonJS 規(guī)范的實現(xiàn)
- 定義了模塊內部的 module.require 函數(shù)和全局的 require 函數(shù),用來加載模塊。
- 在 Node 模塊系統(tǒng)中,每個文件都被視為一個獨立的模塊。模塊被加載時,都會初始化為 Module 對象的實例,Module 對象的基本實現(xiàn)和屬性如下所示:
- 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 對象整體導出一個變量或者函數(shù),也可將需要導出的變量或函數(shù)掛載到 exports 對象的屬性上,代碼如下所示:
- // 1. 使用 exports: 筆者習慣通常用作對工具庫函數(shù)或常量的導出
- exports.name = 'xiaoxiang';
- exports.add = (a, b) => a + b;
- // 2. 使用 module.exports:導出一整個對象或者單一函數(shù)
- ...
- module.exports = {
- add,
- minus
- }
通過全局 require 函數(shù)引用模塊,可傳入模塊名稱、相對路徑或者絕對路徑,當模塊文件后綴為 js / json / node 時,可省略后綴,如下代碼所示:
- // 引用模塊
- const { add, minus } = require('./module');
- const a = require('/usr/app/module');
- const http = require('http');
注意事項:
- exports 變量是在模塊的文件級作用域內可用的,且在模塊執(zhí)行之前賦值給 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
模塊系統(tǒng)實現(xiàn)分析
模塊定位
以下是 require 函數(shù)的代碼實現(xiàn):
- // require 入口函數(shù)
- Module.prototype.require = function(id) {
- //...
- requireDepth++;
- try {
- return Module._load(id, this, /* isMain */ false); // 加載模塊
- } finally {
- requireDepth--;
- }
- };
上述代碼接收給定的模塊路徑,其中的 requireDepth 用來記載模塊加載的深度。其中 Module 的類方法 _load 實現(xiàn)了 Node 加載模塊的主要邏輯,下面我們來解析 Module._load 函數(shù)的源碼實現(xiàn),為了方便大家理解,我把注釋加在了文中。
- Module._load = function(request, parent, isMain) {
- // 步驟一:解析出模塊的全路徑
- const filename = Module._resolveFilename(request, parent, 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 加載函數(shù)針對三種情況給出了不同的加載策略,分別是:
- 情況一:緩存命中,直接返回。
- 情況二:內建模塊,返回暴露出來的 exports 屬性,也就是 module.exports 的別名。
- 情況三:使用文件或第三方代碼生成模塊,最后返回,并且緩存,這樣下次同樣的訪問就會去使用緩存而不是重新加載。
2. Module._resolveFilename(request, parent, isMain) 是怎么解析出文件名稱的?
我們看如下定義的類方法:
- Module._resolveFilename = function(request, parent, isMain, options) {
- if (NativeModule.canBeRequiredByUsers(request)) {
- // 優(yōu)先加載內建模塊
- return request;
- }
- let paths;
- // node require.resolve 函數(shù)使用的 options,options.paths 用于指定查找路徑
- if (typeof options === "object" && options !== null) {
- if (ArrayIsArray(options.paths)) {
- const isRelative =
- request.startsWith("./") ||
- request.startsWith("../") ||
- (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);
- }
- // 依據(jù)給出的模塊和遍歷地址數(shù)組,以及是否為入口模塊來查找模塊路徑
- const filename = Module._findPath(request, paths, isMain);
- if (!filename) {
- const requireStack = [];
- for (let cursor = parent; cursor; cursorcursor = cursor.parent) {
- requireStack.push(cursor.filename || cursor.id);
- }
- // 未找到模塊,拋出異常(是不是很熟悉的錯誤)
- let message = `Cannot find module '${request}'`;
- if (requireStack.length > 0) {
- messagemessage = 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 使用的遍歷范圍數(shù)組。
- // 模塊文件尋址的地址數(shù)組方法
- 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 環(huán)境變量指定目錄、全局 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 = ["."].concat(Module._nodeModulePaths("."), modulePaths);
- return mainPaths;
- }
- // 如果是相對路徑引入,則將父級文件夾路徑加入查找路徑
- const parentDir = [path.dirname(parent.filename)];
- return parentDir;
- };
- _findPath: 依據(jù)目標模塊和上述函數(shù)查找到的范圍,找到對應的 filename 并返回。
- // 依據(jù)給出的模塊和遍歷地址數(shù)組,以及是否頂層模塊來尋找模塊真實路徑
- Module._findPath = function(request, paths, 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 狀態(tài)返回 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 狀態(tài)返回 1 且文件名不存在,則認為是文件夾
- * 如果文件后綴不存在,則嘗試加載該目錄下的 package.json 中 main 入口指定的文件
- * 如果不存在,然后嘗試 index[.js, .node, .json] 文件
- */
- if (exts === undefined) exts = ObjectKeys(Module._extensions);
- filename = tryPackage(basePath, exts, isMain, request);
- }
- if (filename) { // 如果存在該文件,將文件名則加入緩存
- Module._pathCache[cacheKey] = filename;
- return filename;
- }
- }
- const selfFilename = trySelf(paths, exts, isMain, trailingSlash, request);
- if (selfFilename) {
- // 設置路徑的緩存
- Module._pathCache[cacheKey] = selfFilename;
- return selfFilename;
- }
- return false;
- };
模塊加載
標準模塊處理
閱讀完上面的代碼,我們發(fā)現(xiàn),當遇到模塊是一個文件夾的時候會執(zhí)行 tryPackage 函數(shù)的邏輯,下面簡要分析一下具體實現(xiàn)。
- // 嘗試加載標準模塊
- function tryPackage(requestPath, exts, isMain, originalPath) {
- const pkg = readPackageMain(requestPath);
- if (!pkg) {
- // 如果沒有 package.json 這直接使用 index 作為默認入口文件
- return tryExtensions(path.resolve(requestPath, "index"), exts, isMain);
- }
- const filename = path.resolve(requestPath, pkg);
- let actual =
- tryFile(filename, isMain) ||
- tryExtensions(filename, exts, isMain) ||
- tryExtensions(path.resolve(filename, "index"), exts, isMain);
- //...
- return actual;
- }
- // 讀取 package.json 中的 main 字段
- function readPackageMain(requestPath) {
- const pkg = readPackage(requestPath);
- return pkg ? pkg.main : undefined;
- }
readPackage 函數(shù)負責讀取和解析 package.json 文件中的內容,具體描述如下:
- function readPackage(requestPath) {
- const jsonPath = path.resolve(requestPath, "package.json");
- const existing = packageJsonCache.get(jsonPath);
- if (existing !== undefined) return existing;
- // 調用 libuv uv_fs_open 的執(zhí)行邏輯,讀取 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);
- //...
- // 執(zhí)行特定文件后綴名解析函數(shù) 如 js / json / node
- Module._extensions[extension](this, filename);
- // 表示該模塊加載成功
- this.loaded = true;
- // ... 省略 esm 模塊的支持
- };
后綴處理
可以看出,針對不同的文件后綴,Node.js 的加載方式是不同的,一下針對 .js, .json, .node 簡單進行分析。
- .js 后綴 js 文件讀取主要通過 Node 內置 API fs.readFileSync 實現(xiàn)。
- Module._extensions[".js"] = function(module, filename) {
- // 讀取文件內容
- const content = fs.readFileSync(filename, "utf8");
- // 編譯執(zhí)行代碼
- module._compile(content, filename);
- };
- .json 后綴 JSON 文件的處理邏輯比較簡單,讀取文件內容后執(zhí)行 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++ 實現(xiàn)的原生模塊,通過 process.dlopen 函數(shù)讀取,而 process.dlopen 函數(shù)實際上調用了 C++ 代碼中的 DLOpen 函數(shù),而 DLOpen 中又調用了 uv_dlopen, 后者加載 .node 文件,類似 OS 加載系統(tǒng)類庫文件。
- Module._extensions[".node"] = function(module, filename) {
- //...
- return process.dlopen(module, path.toNamespacedPath(filename));
- };
從上面的三段源碼,我們看出來并且可以理解,只有 JS 后綴最后會執(zhí)行實例方法 _compile,我們去除一些實驗特性和調試相關的邏輯來簡要的分析一下這段代碼。
編譯執(zhí)行
模塊加載完成后,Node 使用 V8 引擎提供的方法構建運行沙箱,并執(zhí)行函數(shù)代碼,代碼如下所示:
- Module.prototype._compile = function(content, filename) {
- let moduleURL;
- let redirects;
- // 向模塊內部注入公共變量 __dirname / __filename / module / exports / require,并且編譯函數(shù)
- 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();
- //...
- // 執(zhí)行模塊中的函數(shù)
- result = compiledWrapper.call(
- thisValue,
- exports,
- require,
- module,
- filename,
- dirname
- );
- hasLoadedAnyUserCJSModule = true;
- if (requireDepth === 0) statCache = null;
- return result;
- };
- // 注入變量的核心邏輯
- function wrapSafe(filename, content, cjsModuleInstance) {
- if (patched) {
- const wrapper = Module.wrap(content);
- // vm 沙箱運行 ,直接返回運行結果,env -> SetProtoMethod(script_tmpl, "runInThisContext", RunInThisContext);
- return vm.runInThisContext(wrapper, {
- filename,
- lineOffset: 0,
- displayErrors: true,
- // 動態(tài)加載
- importModuleDynamically: async specifier => {
- const loader = asyncESM.ESMLoader;
- return loader.import(specifier, normalizeReferrerURL(filename));
- }
- });
- }
- let compiled;
- try {
- compiled = compileFunction(
- content,
- filename,
- 0,
- 0,
- undefined,
- false,
- undefined,
- [],
- ["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 函數(shù)中調用了 wrapwrapSafe 函數(shù),執(zhí)行了 __dirname / __filename / module / exports / require 公共變量的注入,并且調用了 C++ 的 runInThisContext 方法(位于 src/node_contextify.cc 文件)構建了模塊代碼運行的沙箱環(huán)境,并返回了 compiledWrapper 對象,最終通過 compiledWrapper.call 方法運行模塊。
結語
至此,Node.js 的模塊系統(tǒng)分析告一段落,Node.js 世界的精彩和絕妙無窮無盡,學習的路上和諸君共勉。