前言
通过断点调试理解 NodeJS & CommonJS 的模块机制,先说结论:
- NodeJS 中每个文件视作一个模块,每个模块默认可以访问
module
、exports
、require
、__filename
、__dirname
变量 - NodeJS 中通过将模块源码包裹在 Wrapper 函数中,并通过调用函数传递参数的方式传递默认变量
- 通过
vm
模块 的runInThisContext
方法 生成 Wrapper 函数,使用eval
和new Function
的方式生成都会出现作用域问题,eval
的作用域为当前上下文,new Function
的作用域为全局上下文
示例代码:
const path = require("path");
const fs = require("fs");
const vm = require("vm");function myRequire(_path) {// 计算决定路径const absolatePath = /* ... */;// 计算文件名const filename = /* ... */;// 读取文件源码const sourceCode = fs.readFileSync(absolatePath, 'utf-8');// 生成 Wrapper 函数const fn = vm.runInThisContext(`(function (exports, module, require, __filename, __dirname) { ${sourceCode} });`);// 定义 moduleconst module = { exports: {} };// 调用 Wrapper 函数,此时 module 代码执行,并为 module.exports 赋值fn.apply(module.exports, [module.exports, module, myRequire, filename, absolatePath]);return module.exports;
}
大致的流程如此,后面是调试流程,长文警告。
准备调试
调试流程:
新建目录,在目录根路径执行:
npm init -y
新建 index.js
文件,写点代码,并在首行代码处打上断点:
新建 /.vscode/launch.json
vscode 调试配置文件:
简单说下调试配置项:
- type:调试类型,node 表示 node 程序
- request:请求类型,这里是启动
- name:调试配置名称
- skipFiles:配置调试过程中跳过的文件,默认跳过 node 内部代码文件,我们需要调试 node 的启动流程,所以这里将他注释
- program:程序所在的位置
在 vscode 左侧调试面板中,选择我们的配置并运行调试,默认会跳过 node 的启动流程,直接断到我们编写的程序中:
但是可以看到左下角列出来经过的全部程序,我们直接在首先启动的程序文件中打上断点:
接下来就可以开始愉快的调试了。
流程调试
查看最先执行的程序代码:
/*** other code in here...*/if (getOptionValue('--experimental-default-type') === 'module') {require('internal/modules/run_main').executeUserEntryPoint(mainEntry);
} else {require('internal/modules/cjs/loader').Module.runMain(mainEntry);
}
getOptionValue 获取到 nodejs 对应选项的配置值,这里判断 --experimental-default-type
属性值是否是 module,如果是则使用 esm 加载器来执行,可以通过 node --experimental-default-type=module
来进入这个条件。
在调试时可以在调试配置中添加 "runtimeArgs": ["--experimental-default-type=module"]
来配置运行时参数。
我们不关注 type=module 的情况,所以这里直接忽略,进入 else 的判断。
接下来进入 runMain
方法,代码如下:
/*** other code in here...*/function executeUserEntryPoint(main = process.argv[1]) {/** 获取入口代码的路径 */const resolvedMain = resolveMainPath(main);/** 判断是否应该使用 esm loader */const useESMLoader = shouldUseESMLoader(resolvedMain);let mainURL;if (!useESMLoader) {/** 使用 commonjs loader */const cjsLoader = require('internal/modules/cjs/loader');const { wrapModuleLoad } = cjsLoader;wrapModuleLoad(main, null, true);} else {/** 使用 esm loader */const mainPath = resolvedMain || main;if (mainURL === undefined) {mainURL = pathToFileURL(mainPath).href;}runEntryPointWithESMLoader((cascadedLoader) => {return cascadedLoader.import(mainURL, undefined, { __proto__: null }, true);});}
}
resolveMainPath
函数比较简单,就是用一堆工具函数来找到真实的入口文件路径,这里不进行说明,来看看 shouldUseESMLoader
的函数的代码:
/** 判断是否应该使用 esm loader */
function shouldUseESMLoader(mainPath) {if (getOptionValue('--experimental-default-type') === 'module') {return true;}/** 用户注册的自定义 loader 列表 */const userLoaders = getOptionValue('--experimental-loader');/** 用户注册的预加载模块列表 */const userImports = getOptionValue('--import');if (userLoaders.length > 0 || userImports.length > 0) {return true;}/** 解析入口文件的文件后缀,如果为 mjs 则是 esm,如果是 cjs 则是 commonjs */if (mainPath && StringPrototypeEndsWith(mainPath, '.mjs')) {return true;}if (!mainPath || StringPrototypeEndsWith(mainPath, '.cjs')) {return false;}/** 这里是获取到 package.json 中的 type 定义 */const type = getNearestParentPackageJSONType(mainPath);if (type === undefined || type === 'none') {return false;}/** type 为 module 则为 esm 模块 */return type === 'module';
}
这里可以看到满足以下几个条件之一则使用 esm loader:
- 运行时设置
--experimental-default-type=module
- 运行时指定了自定义 loader
- 运行时指定了预加载的模块
- 入口文件后缀为 .mjs
- 当前项目的 package.json 中 type 指定为 module
我们依然不关心 esm loader 的相关逻辑,走到 wrapModuleLoad
方法,代码如下:
/*** other code in here...*//** 这是对内部方法 Module._load 的包装,request 是加载的模块,parent 是父模块,isMain 表示入口模块 */
function wrapModuleLoad(request, parent, isMain) {/* ... */try {return onRequire().traceSync(Module._load,{__proto__: null,parentFilename: parent?.filename,id: request},Module,request,parent,isMain);} finally {/* ... */}
}
traceSync
方法就是对函数的一层封装,在执行函数前后添加了 hook 代码,主要用于跟踪函数执行,这里执行的是 Module._load
方法,我们继续查看他的代码:
Module._load = function (request, parent, isMain) {/** TIPS: 省略了大部分代码,完整的代码建议自己调试查看 *//** 判断请求的模块路径是否以 node: 开头,node: 开头的是 nodejs 内置模块 */if (StringPrototypeStartsWith(request, 'node:')) {const id = StringPrototypeSlice(request, 5);if (!BuiltinModule.canBeRequiredByUsers(id)) {throw new ERR_UNKNOWN_BUILTIN_MODULE(request);}const module = loadBuiltinModule(id, request);return module.exports;}/** 获取模块的决定路径 */const filename = Module._resolveFilename(request, parent, isMain);/** 获取缓存 */const cachedModule = Module._cache[filename];/** 返回被缓存且已加载完成的模块 */if (cachedModule !== undefined) {if (cachedModule.loaded) {return cachedModule.exports;}}/** 获取缓存模块或构建新模块 */const module = cachedModule || new Module(filename, parent);/** 标识模块信息 */if (!cachedModule) {if (isMain) {setOwnProperty(process, 'mainModule', module);setOwnProperty(module.require, 'main', process.mainModule);module.id = '.';module[kIsMainSymbol] = true;} else {module[kIsMainSymbol] = false;}/** 缓存模块 */Module._cache[filename] = module;}let threw = true;try {/** 尝试加载模块 */module.load(filename);threw = false;} finally {/** 模块加载失败需要清理残留 */if (threw) {delete Module._cache[filename];}}/** 返回模块 */return module.exports;
};
核心的加载代码在 module.load
方法中,代码如下:
Module.prototype.load = function (filename) {this.filename = filename;this.paths = Module._nodeModulePaths(path.dirname(filename));/** 通过文件后缀查找已注册的扩展 */const extension = findLongestRegisteredExtension(filename);/** 如果文件以 .mjs 为后缀,且没有注册 .mjs 的扩展,则抛出错误 */if (StringPrototypeEndsWith(filename, '.mjs') && !Module._extensions['.mjs']) {throw new ERR_REQUIRE_ESM(filename, true);}/** 调用对应文件后缀的扩展,将 module 实例和模块路径传递 */Module._extensions[extension](this, filename);/** 标识已被加载 */this.loaded = true;const exports = this.exports;this[kModuleExport] = exports;
};
load
方法内部通过 findLongestRegisteredExtension
方法查找对应的扩展名,最后调用指定扩展来加载模块,这里的扩展名为 .js
,默认在 Module._extension
中注册,他的代码如下:
Module._extensions['.js'] = function (module, filename) {/** 获取模块源码 */const content = getMaybeCachedSource(module, filename);let format;if (StringPrototypeEndsWith(filename, '.js')) {/** 获取 package.json 配置 */const pkg = packageJsonReader.getNearestParentPackageJSON(filename);if (pkg?.data.type === 'module') {/** 如果启用了 --experimental-require-module,则允许在 type=module 的情况下使用 require */if (getOptionValue('--experimental-require-module')) {/** 以 esm 模式编译模块 */module._compile(content, filename, 'module');return;}/** 构造错误信息 */const parent = module[kModuleParent];const parentPath = parent?.filename;const packageJsonPath = path.resolve(pkg.path, 'package.json');const usesEsm = containsModuleSyntax(content, filename);const err = new ERR_REQUIRE_ESM(filename, usesEsm, parentPath, packageJsonPath);// Attempt to reconstruct the parent require frame.if (Module._cache[parentPath]) {let parentSource;try {parentSource = fs.readFileSync(parentPath, 'utf8');} catch {// Continue regardless of error.}if (parentSource) {const errLine = StringPrototypeSplit(StringPrototypeSlice(err.stack, StringPrototypeIndexOf(err.stack, ' at ')),'\n',1)[0];const { 1: line, 2: col } = RegExpPrototypeExec(/(\d+):(\d+)\)/, errLine) || [];if (line && col) {const srcLine = StringPrototypeSplit(parentSource, '\n')[line - 1];const frame = `${parentPath}:${line}\n${srcLine}\n${StringPrototypeRepeat(' ',col - 1)}^\n`;setArrowMessage(err, frame);}}}throw err;} else if (pkg?.data.type === 'commonjs') {format = 'commonjs';}} else if (StringPrototypeEndsWith(filename, '.cjs')) {format = 'commonjs';}/** 以 commonjs 模式编译源码 */module._compile(content, filename, format);
};
继续查看 module._compile
方法,代码如下:
Module.prototype._compile = function (content, filename, format) {/** 删除大部分代码,建议自行调试查看 */let redirects;let compiledWrapper;if (format !== 'module') {/** wrapSafe 方法包装模块源码并生成函数,函数作用域与外界隔离 */const result = wrapSafe(filename, content, this, format);/** compiledWrapper 即是生成的函数 */compiledWrapper = result.function;}/** 获取模块所在目录 */const dirname = path.dirname(filename);/**** 构建 require 函数,对 module.require 方法的包装, module.require 方法又是对开头介绍的 wrapModuleLoad 函数的包装* */const require = makeRequireFunction(this, redirects);let result;const exports = this.exports;const thisValue = exports;const module = this;/** 执行生成的包装函数并传递参数 */result = ReflectApply(compiledWrapper, thisValue, [exports, require, module, filename, dirname]);return result;
};
当执行到 ReflectApply
函数内部时,会执行生成的包装函数,继续向下执行会发现断到了我们编写的模块内部:
此时流程基本已经结束,可以看到核心的逻辑基本与 Module
类相关,下面我用 ts 给出 Module
类的主要定义(部分内容忽略)。
Module 类型定义
declare type Exports = { [key: string | symbol]: any };declare class Module {constructor(id: string, parent: Module);/** 模块 id,一般为模块绝对路径 */id: string;/** 模块路径 */path: string;/** module.exports,模块中通过给这个对象新增属性来达到导出目的 */exports: Exports;/** 文件名 */filename: string | null;/** 是否加载完成 */loaded: boolean;/** 外层包裹函数字符串的代理 ["function(module, exports, require, __filename, __dirname) {", "};"] */wrapper: [string, string];/** 父模块 */parent?: Module;/** 子模块 */children?: Module[];/** 缓存模块 */static _cache: Record<string, Module>;/** 缓存路径 */static _pathCache: Record<string, Module>;/** 注册扩展 */static _extensions: Record<string, (module: Module, filename: string) => void>;/** 全局路径 */static globalPaths: string[];/** 生成包裹函数 */wrap(script: string): string;/** 加载模块 */load(filename: string): void;/** 请求模块 */require(id: string): Exports;private _compile(content: string,filename: string,format: 'module' | 'commonjs' | undefined): Exports;/** 创建 require 函数 */static createRequire(filename: string | URL): (id: string) => Exports;/*** 如果用户覆盖了内置模块的导出,此函数可以确保覆盖用于 CommonJS 和 ES 模块上下文*/static syncBuiltinESMExports(): void;/** 查找路径 */private static _findPath(request: string, paths: string[], isMain: boolean): string | false;/** 根据给定路径查找 node_modules 路径 */private static _nodeModulePaths(from: string): string[];/** 获取模块解析路径 */private static _resolveLookupPaths(request: string, parent: Module): string[];/** 加载模块 */private static _load(request: string, parent: Module, isMain: boolean): Exports;/** 解析模块绝对路径 */private static _resolveFilename(request: string,parent: Module,isMain: boolean,options: object,paths: string[]): string;/** 定义用于解析模块的路径 */private static _initPaths(): void;/** 处理通过 “--require” 加载的模块 */private static _preloadModules(requests: string[]): void;
}
–end