Node.js 模块系统源码探微
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');consta =require('/usr/app/module');consthttp =require('http');
注意事项:
exports变量是在模块的文件级作用域内可用的,且在模块执行之前赋值给module.exports。
exports.name ='test';console.log(module.exports.name);// testmodule.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{returnModule._load(id,this,/* isMain */false);// 加载模块}finally{ requireDepth--; }};
上述代码接收给定的模块路径,其中的 requireDepth 用来记载模块加载的深度。其中 Module 的类方法_load实现了 Node 加载模块的主要逻辑,下面我们来解析Module._load函数的源码实现,为了方便大家理解,我把注释加在了文中。
Module._load =function(request, parent, isMain){// 步骤一:解析出模块的全路径constfilename = Module._resolveFilename(request, parent, isMain);// 步骤二:加载模块,具体分三种情况处理// 情况一:存在缓存的模块,直接返回模块的 exports 属性constcachedModule = Module._cache[filename];if(cachedModule !==undefined)returncachedModule.exports;// 情况二:加载内建模块constmod = loadNativeModule(filename, request);if(mod && mod.canBeRequiredByUsers)returnmod.exports;// 情况三:构建模块加载constmodule=newModule(filename, parent);// 加载过之后就进行模块实例缓存Module._cache[filename] =module;// 步骤三:加载模块文件module.load(filename);// 步骤四:返回导出对象returnmodule.exports;};
加载策略
上面的代码信息量比较大,我们主要看以下几个问题:
模块的缓存策略是什么?
分析上述代码我们可以看到, _load加载函数针对三种情况给出了不同的加载策略,分别是:
情况一:缓存命中,直接返回。
情况二:内建模块,返回暴露出来的 exports 属性,也就是 module.exports 的别名。
情况三:使用文件或第三方代码生成模块,最后返回,并且缓存,这样下次同样的访问就会去使用缓存而不是重新加载。
Module._resolveFilename(request, parent, isMain) 是怎么解析出文件名称的?
我们看如下定义的类方法:
Module._resolveFilename =function(request, parent, isMain, options){if(NativeModule.canBeRequiredByUsers(request)) {// 优先加载内建模块returnrequest; }letpaths;// node require.resolve 函数使用的 options,options.paths 用于指定查找路径if(typeofoptions ==="object"&& options !==null) {if(ArrayIsArray(options.paths)) {constisRelative = request.startsWith("./") || request.startsWith("../") || (isWindows && request.startsWith(".\\")) || request.startsWith("..\\");if(isRelative) { paths = options.paths; }else{constfakeParent =newModule("",null); paths = [];for(leti =0; i < options.paths.length; i++) {constpath = options.paths[i]; fakeParent.paths = Module._nodeModulePaths(path);constlookupPaths = Module._resolveLookupPaths(request, fakeParent);for(letj =0; j < lookupPaths.length; j++) {if(!paths.includes(lookupPaths[j])) paths.push(lookupPaths[j]); } } } }elseif(options.paths ===undefined) { paths = Module._resolveLookupPaths(request, parent); }else{//...} }else{// 查找模块存在路径paths = Module._resolveLookupPaths(request, parent); }// 依据给出的模块和遍历地址数组,以及是否为入口模块来查找模块路径constfilename = Module._findPath(request, paths, isMain);if(!filename) {constrequireStack = [];for(letcursor = parent; cursor; cursor = cursor.parent) { requireStack.push(cursor.filename || cursor.id); }// 未找到模块,抛出异常(是不是很熟悉的错误)letmessage =`Cannot find module '${request}'`;if(requireStack.length >0) { message = message +"\nRequire stack:\n- "+ requireStack.join("\n- "); }consterr =newError(message); err.code ="MODULE_NOT_FOUND"; err.requireStack = requireStack;throwerr; }// 最终返回包含文件名的完整路径returnfilename;};
上面的代码中比较突出的是使用了_resolveLookupPaths和_findPath两个方法。
_resolveLookupPaths: 通过接受模块名称和模块调用者,返回提供_findPath使用的遍历范围数组。
// 模块文件寻址的地址数组方法Module._resolveLookupPaths =function(request, parent){if(NativeModule.canBeRequiredByUsers(request)) { debug("looking for %j in []", request);returnnull; }// 如果不是相对路径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); }returnpaths.length >0? paths :null; }// 使用 repl 交互时,依次查找 ./ ./node_modules 以及 modulePathsif(!parent|| !parent.id || !parent.filename) {constmainPaths = ["."].concat(Module._nodeModulePaths("."), modulePaths);returnmainPaths; }// 如果是相对路径引入,则将父级文件夹路径加入查找路径constparentDir = [path.dirname(parent.filename)];returnparentDir; };
_findPath: 依据目标模块和上述函数查找到的范围,找到对应的 filename 并返回。
// 依据给出的模块和遍历地址数组,以及是否顶层模块来寻找模块真实路径Module._findPath =function(request, paths, isMain){constabsoluteRequest = path.isAbsolute(request);if(absoluteRequest) {// 绝对路径,直接定位到具体模块paths = [""]; }elseif(!paths || paths.length ===0) {returnfalse; }constcacheKey = request +"\x00"+ (paths.length ===1? paths[0] : paths.join("\x00"));// 缓存路径constentry = Module._pathCache[cacheKey];if(entry)returnentry;letexts;lettrailingSlash = request.length >0&& request.charCodeAt(request.length -1) === CHAR_FORWARD_SLASH;// '/'if(!trailingSlash) { trailingSlash =/(?:^|\/)\.?\.$/.test(request); }// For each pathfor(leti =0; i < paths.length; i++) {constcurPath = paths[i];if(curPath && stat(curPath) <1)continue;constbasePath = resolveExports(curPath, request, absoluteRequest);letfilename;constrc = stat(basePath);if(!trailingSlash) {if(rc ===0) {// stat 状态返回 0,则为文件// File.if(!isMain) {if(preserveSymlinks) {// 当解析和缓存模块时,命令模块加载器保持符号连接。filename = path.resolve(basePath); }else{// 不保持符号链接filename = toRealPath(basePath); } }elseif(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, exts, isMain, request); }if(filename) {// 如果存在该文件,将文件名则加入缓存Module._pathCache[cacheKey] = filename;returnfilename; } }constselfFilename = trySelf(paths, exts, isMain, trailingSlash, request);if(selfFilename) {// 设置路径的缓存Module._pathCache[cacheKey] = selfFilename;returnselfFilename; }returnfalse;};
模块加载
标准模块处理
阅读完上面的代码,我们发现,当遇到模块是一个文件夹的时候会执行tryPackage函数的逻辑,下面简要分析一下具体实现。
// 尝试加载标准模块functiontryPackage(requestPath, exts, isMain, originalPath){constpkg = readPackageMain(requestPath);if(!pkg) {// 如果没有 package.json 这直接使用 index 作为默认入口文件returntryExtensions(path.resolve(requestPath,"index"), exts, isMain); }constfilename = path.resolve(requestPath, pkg);letactual = tryFile(filename, isMain) || tryExtensions(filename, exts, isMain) || tryExtensions(path.resolve(filename,"index"), exts, isMain);//...returnactual;}// 读取 package.json 中的 main 字段functionreadPackageMain(requestPath){constpkg = readPackage(requestPath);returnpkg ? pkg.main :undefined;}
readPackage 函数负责读取和解析 package.json 文件中的内容,具体描述如下:
function readPackage(requestPath) {constjsonPath = path.resolve(requestPath,"package.json");constexisting = packageJsonCache.get(jsonPath);if(existing !== undefined)returnexisting;// 调用 libuv uv_fs_open 的执行逻辑,读取 package.json 文件,并且缓存constjson = internalModuleReadJSON(path.toNamespacedPath(jsonPath));if(json === undefined) {// 接着缓存文件packageJsonCache.set(jsonPath,false);returnfalse; }//...try{constparsed = JSONParse(json);constfiltered = { name: parsed.name, main: parsed.main, exports: parsed.exports, type: parsed.type }; packageJsonCache.set(jsonPath, filtered);returnfiltered; }catch(e) {//...}}
上面的两段代码完美地解释 package.json 文件的作用,模块的配置入口( package.json 中的 main 字段)以及模块的默认文件为什么是 index,具体流程如下图所示:
模块文件处理
定位到对应模块之后,该如何加载和解析呢?以下是具体代码分析:
Module.prototype.load = function(filename) {// 保证模块没有加载过assert(!this.loaded);this.filename = filename;// 找到当前文件夹的 node_modulesthis.paths = Module._nodeModulePaths(path.dirname(filename));constextension = findLongestRegisteredExtension(filename);//...// 执行特定文件后缀名解析函数 如 js / json / nodeModule._extensions[extension](this, filename);// 表示该模块加载成功this.loaded =true;// ... 省略 esm 模块的支持};
后缀处理
可以看出,针对不同的文件后缀,Node.js 的加载方式是不同的,一下针对.js, .json, .node简单进行分析。
.js 后缀 js 文件读取主要通过 Node 内置 APIfs.readFileSync实现。
Module._extensions[".js"] =function(module, filename){// 读取文件内容constcontent = fs.readFileSync(filename,"utf8");// 编译执行代码module._compile(content, filename);};
.json 后缀 JSON 文件的处理逻辑比较简单,读取文件内容后执行JSONParse即可拿到结果。
Module._extensions[".json"] =function(module, filename){// 直接按照 utf-8 格式加载文件constcontent = 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){//...returnprocess.dlopen(module, path.toNamespacedPath(filename));};
从上面的三段源码,我们看出来并且可以理解,只有 JS 后缀最后会执行实例方法 _compile,我们去除一些实验特性和调试相关的逻辑来简要的分析一下这段代码。
编译执行
模块加载完成后,Node 使用 V8 引擎提供的方法构建运行沙箱,并执行函数代码,代码如下所示:
Module.prototype._compile =function(content, filename){letmoduleURL;letredirects;// 向模块内部注入公共变量 __dirname / __filename / module / exports / require,并且编译函数constcompiledWrapper = wrapSafe(filename, content,this);constdirname = path.dirname(filename);constrequire= makeRequireFunction(this, redirects);letresult;constexports =this.exports;constthisValue = exports;constmodule=this;if(requireDepth ===0) statCache =newMap();//...// 执行模块中的函数result = compiledWrapper.call( thisValue, exports,require,module, filename, dirname ); hasLoadedAnyUserCJSModule =true;if(requireDepth ===0) statCache =null;returnresult;};// 注入变量的核心逻辑functionwrapSafe(filename, content, cjsModuleInstance){if(patched) {constwrapper = Module.wrap(content);// vm 沙箱运行 ,直接返回运行结果,env -> SetProtoMethod(script_tmpl, "runInThisContext", RunInThisContext);returnvm.runInThisContext(wrapper, { filename,lineOffset:0,displayErrors:true,// 动态加载importModuleDynamically:asyncspecifier => {constloader = asyncESM.ESMLoader;returnloader.import(specifier, normalizeReferrerURL(filename)); } }); }letcompiled;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:asyncspecifier => {constloader = asyncESM.ESMLoader;returnloader.import(specifier, normalizeReferrerURL(filename)); } });returncompiled.function;}
上述代码中,我们可以看到在 _compile函数中调用了wrapwrapSafe函数,执行了__dirname / __filename / module / exports / require公共变量的注入,并且调用了 C++ 的 runInThisContext 方法(位于 src/node_contextify.cc 文件)构建了模块代码运行的沙箱环境,并返回了compiledWrapper对象,最终通过compiledWrapper.call方法运行模块。
结语
至此,Node.js 的模块系统分析告一段落,Node.js 世界的精彩和绝妙无穷无尽,学习的路上和诸君共勉。