写点什么

Node.js 模块系统源码探微

  • 2021 年 3 月 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 年 3 月 07 日 23:491749

评论

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

如何使用TCP/IP开发网络程序

华为云开发者社区

Java TCP 网络协议 通信 客户端

fil矿机挖矿收益怎么计算?fil矿机挖矿效率怎么提升?

fil矿机挖矿收益怎么计算 fil矿机挖矿效率怎么提升

不可错过的mysql,redis,nginx视频讲解

hanaper

在PyQt中构建 Python 菜单栏、菜单和工具栏

华为云开发者社区

Python

少侠留步!赠你一份阿里内部广为流传的23种设计模式核心笔记

Java~~~

Java 架构 面试 分布式 架构师

又收到一份大厂Java研发岗offer,这是已经拿下的第五个大厂意向书了

Java技术那些事

Java 编程 程序员 面试 计算机

逮虾户!清华架构师吐血整理出这份多线程并发指南,带你弯道超车

Java~~~

Java 架构 面试 分布式 多线程

睡觉前看看吧,有用——35K成功入职:蚂蚁金服面试Java后端经历

Crud的程序员

Java 面试 架构笔记

罗马道ROW质押挖矿系统开发技术搭建

薇電13242772558

醍醐灌顶Nginx 原理和架构

hanaper

在阿里离职后,从内部带走的这份485页面试合集,在GitHub上火了

Java~~~

Java 架构 面试 架构师

Alibaba内网“疯狂”传阅的P8开源出的SpringBoot入门到进阶小册

Java~~~

Java spring 架构 面试 Spring Boot

ipfs是什么项目是国家许可的吗?ipfs国家认可吗?

IPFS国家认可吗 ipfs是什么项目 ipfs是国家许可的吗

铁山靠!阿里P9架构师写的这份JDK源码笔记,竟直接带火了GitHub

Java~~~

Java 架构 jdk 面试 架构师

【微服务技术专题】Netflix动态化配置服务-微服务配置组件变色龙Archaius

互联网架构师小马

阿里的新“宠儿”!终于有人总结出了Spring源码从初级到高级手册

Java~~~

Java spring 架构 面试 Spring Cloud

全网震动!阿里员工人手一份却禁止外传的P5-P9Java进阶学习路线

Java~~~

Java 架构 面试 分布式 高并发

基于语音情感识别的应用和挑战,详解华为云的语音情感识别方案

华为云开发者社区

学习 语音 情感识别 跨模态知识迁移 跨模态

高薪offer有什么秘籍,不妨来此观看

hanaper

GitHub上这份阿里的Java高并发核心手册,即使再过20年依然“NB”

Java~~~

Java 架构 面试 分布式 高并发

通过wireshark体验IP层分包

她的男人是程序员

面试过程中,遇到刁钻问题大家是怎么处理的?

hanaper

工作3年,斩获头条offer居然花了一个月时间(Java岗)

程序员改bug

Java 架构 编程语言

Github星标百万!终于有人将Spring技术精髓收录成册

Java 编程 架构 面试 架构师

细节炸裂!阿里P8高管总结出这份1500页的Java编程思想(第六版)

Java~~~

Java 编程 架构 面试 网络

跳槽阿里天猫,难度系数超高的Java六面(附答案)

程序员改bug

Java 架构 面试

靶向蛋白质组技术研发进入快车道,北鲲云超算为技术顺利推进提供有力支撑

北鲲云

腾讯高级架构师熬夜整理:中间件MyCat实战笔记,开放下载

Java技术那些事

Java 程序员 计算机 mycat 8月日更

30天面试突击:JVM+Redis+Spring全家桶+分布式架构,定级P7

Crud的程序员

Java 程序员 架构 编程语言

Linux如何进行GPIO读写操作的?

华为云开发者社区

Linux value GPIO GPIO开发 sysfs

深入了解RocketMQ之Broker

邱学喆

broker 执行器 ConfigManager 处理器

如何行之有效地参与开源?

如何行之有效地参与开源?

Node.js 模块系统源码探微-InfoQ