写点什么

Node.js 模块系统源码探微

  • 2021-03-07
  • 本文字数:8320 字

    阅读完需:约 27 分钟

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');const a = require('/usr/app/module');const http = 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 {    return Module._load(id, this, /* isMain */false); // 加载模块  } finally {    requireDepth--;  }};
复制代码


上述代码接收给定的模块路径,其中的 requireDepth 用来记载模块加载的深度。其中 Module 的类方法 _load 实现了 Node 加载模块的主要逻辑,下面我们来解析 Module._load 函数的源码实现,为了方便大家理解,我把注释加在了文中。


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;  // 情况三:构建模块加载  constmodule = new Module(filename, parent);  // 加载过之后就进行模块实例缓存  Module._cache[filename] = module;    // 步骤三:加载模块文件  module.load(filename);   // 步骤四:返回导出对象  returnmodule.exports;};
复制代码


加载策略


上面的代码信息量比较大,我们主要看以下几个问题:


  1. 模块的缓存策略是什么?

    分析上述代码我们可以看到,_load 加载函数针对三种情况给出了不同的加载策略,分别是:

  2. 情况一:缓存命中,直接返回。

  3. 情况二:内建模块,返回暴露出来的 exports 属性,也就是 module.exports 的别名。

  4. 情况三:使用文件或第三方代码生成模块,最后返回,并且缓存,这样下次同样的访问就会去使用缓存而不是重新加载。

  5.  Module._resolveFilename(request, parent, isMain) 是怎么解析出文件名称的?

    我们看如下定义的类方法:


Module._resolveFilename = function(request, parent, 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.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]);         }       }     }   } elseif (options.paths === undefined) {     paths = Module._resolveLookupPaths(request, parent);   } else {	//...   } } else {   // 查找模块存在路径   paths = Module._resolveLookupPaths(request, parent); } // 依据给出的模块和遍历地址数组,以及是否为入口模块来查找模块路径 const filename = 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 = newError(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 = ["."].concat(Module._nodeModulePaths("."), modulePaths);    return mainPaths;  }     // 如果是相对路径引入,则将父级文件夹路径加入查找路径  const parentDir = [path.dirname(parent.filename)];  return parentDir;};
复制代码


  • _findPath: 依据目标模块和上述函数查找到的范围,找到对应的 filename 并返回。


// 依据给出的模块和遍历地址数组,以及是否顶层模块来寻找模块真实路径Module._findPath = function(request, paths, isMain) { const absoluteRequest = path.isAbsolute(request); if (absoluteRequest) {  // 绝对路径,直接定位到具体模块   paths = [""]; } elseif (!paths || paths.length === 0) {   returnfalse; } 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);         }       } 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;     return filename;   } } const selfFilename = trySelf(paths, exts, isMain, trailingSlash, request); if (selfFilename) {   // 设置路径的缓存   Module._pathCache[cacheKey] = selfFilename;   return selfFilename; } returnfalse;};
复制代码


模块加载

标准模块处理


阅读完上面的代码,我们发现,当遇到模块是一个文件夹的时候会执行 tryPackage 函数的逻辑,下面简要分析一下具体实现。


// 尝试加载标准模块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 函数负责读取和解析 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 的执行逻辑,读取 package.json 文件,并且缓存  const json = internalModuleReadJSON(path.toNamespacedPath(jsonPath));  if (json === undefined) {    // 接着缓存文件    packageJsonCache.set(jsonPath, false);    returnfalse;  }  //...  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  内置  API  fs.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);  constrequire = makeRequireFunction(this, redirects);  let result;  const exports = this.exports;  const thisValue = exports;  constmodule = this;  if (requireDepth === 0) statCache = newMap();  	//...   // 执行模块中的函数	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,      // 动态加载      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 函数中调用了 wrapwrapSafe 函数,执行了 __dirname / __filename / module / exports / require 公共变量的注入,并且调用了 C++ 的 runInThisContext 方法(位于 src/node_contextify.cc 文件)构建了模块代码运行的沙箱环境,并返回了 compiledWrapper 对象,最终通过 compiledWrapper.call 方法运行模块。


结语


至此,Node.js 的模块系统分析告一段落,Node.js 世界的精彩和绝妙无穷无尽,学习的路上和诸君共勉。



头图:Unsplash

作者:神父

原文:https://mp.weixin.qq.com/s/4RjdGMxvuLIi-St__tqIHg

原文:Node.js 模块系统源码探微

来源:政采云前端团队 - 微信公众号 [ID:Zoo-Team]

转载:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

2021-03-07 23:496264

评论

发布
暂无评论
发现更多内容

Qwen2-Math 开源 AI 模型发布;阿里云推出首个域名 AI 大模型应用丨 RTE 开发者日报

声网

数据分析与决策支持:京东商品详情API的商业价值

技术冰糖葫芦

API Explorer api 货币化 API 接口 API 测试

扎克伯格说AI会让推荐系统变得更强大?一文读懂什么是智能推荐系统

爱AI的猫猫头

人工智能 音视频 推荐系统 办公效率 搜索系统

Java智能之Spring AI:5分钟打造智能聊天模型的利器

快乐非自愿限量之名

Java 人工智能 spring AI

顶级项目管理工具比较:找到你的最佳匹配

爱吃小舅的鱼

文档管理 文档管理软件

畅捷通基于Flink的实时数仓落地实践

Apache Flink

大数据 flink 实时数仓

邀请函 I 松下信息和望繁信科技邀您参加「数智时代下大数据应用的“道”与“术”」闭门会议

望繁信科技

大数据 数字化转型 解决方案 流程挖掘 流程智能

现成源码开发游戏直播软件:应对快速变化的技术和用户需求

软件开发-梦幻运营部

远程访问内网设备:对比IPsec VPN,SD-WAN异地组网更具优势

贝锐

运维 SD-WAN 远程运维 组网

小企业必备:顶级免费项目管理工具

爱吃小舅的鱼

项目管理 免费

蓝易云 - 关于Ingress-nginx的跨域配置

百度搜索:蓝易云

nginx 云计算 Linux 运维 HTTP

蓝易云 - 高防服务器如何抵御大规模攻击

百度搜索:蓝易云

云计算 运维 云服务器 香港服务器 高防服务器

智源未来选择 TDengine Cloud,解锁高效能源管理

TDengine

JNPF快速开发平台赋能数字办公方式转变

不在线第一只蜗牛

低代码 数字化转型 数字化办公

API可观察性对于现代应用程序的最大好处

幂简集成

API API 接口

掌握项目预算:顶级成本管理工具评测

爱吃小舅的鱼

项目成本管理

蓝易云 - 对于跨境电商来说海外云服务器选择哪家比较好?

百度搜索:蓝易云

云服务器 跨境电商 独立站 香港服务器 高防服务器

Pinterest:从 Druid 到 StarRocks,实现 6 倍成本效益比提升

StarRocks

Druid Pinterest

14点自动化经验

FunTester

JNPF快速开发平台让业务活起来

快乐非自愿限量之名

阿里巴巴拍立淘API返回值:商品关联推荐与交叉销售

技术冰糖葫芦

API Explorer api 货币化 API 接口 API 测试

有限元CAE分析厂商 热力学仿真CFD分析咨询

Geek_2d6073

寻找最佳开源知识管理软件:专家指南

爱吃小舅的鱼

开源 知识管理

蓝易云 - 怎么安排insert和update语句的执行顺序

百度搜索:蓝易云

云计算 运维 服务器 云服务器 INSERT

IoTDB 单机/双活/集群部署的区别和适用场景

Apache IoTDB

观测云突变告警,精准预测云原生的系统异常

观测云

云原生 监控告警

亚信安慧AntDB-T:使用Brin索引提升OLAP查询性能以及节省磁盘空间

亚信AntDB数据库

AntDB

鸿蒙 NEXT 应用开发训练营毕业总结

白开水又一杯

【活动预告】研讨会+开源集市,IoTDB “登录” GOTC 2024!

Apache IoTDB

全文彩印!人民邮电出版的“24小时学会黑客攻防”,讲的太好了!

我再BUG界嘎嘎乱杀

黑客 网络安全 安全 信息安全 网安

蓝易云 - route命令小结

百度搜索:蓝易云

云计算 Linux 运维 windows Route

Node.js 模块系统源码探微_语言 & 开发_政采云前端团队_InfoQ精选文章