速来报名!AICon北京站鸿蒙专场~ 了解详情
写点什么

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:496312

评论

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

手把手教你学Dapr - 7. Actors

MASA技术团队

C# .net 微软 微服务 dapr

SpringBoot:如何优雅地进行参数传递、响应数据封装、异常处理?

CRMEB

盘点 2021 征文大赛获奖名单公布!

InfoQ写作社区官方

盘点2021 热门活动

Markdown-it 原理解析

冴羽

前端 markdown vuepress 博客开发 markdown-it

【渗透技术】一个渗透测试工具人是怎样操作的

H

网络安全 渗透测试

『内存中的操作系统』内存虚拟化又是什么

soolaugust

操作系统 内存

使用轻量应用服务器部署Docsify在线文档平台

阿里云弹性计算

阿里云 轻量应用 征文投稿

阿里云弹性计算年度关键词:强劲、突破、开放

阿里云弹性计算

阿里云 弹性计算 计算巢 神龙架构 无影

普通开发者,如何成为Apache项目的commiter ?| 人物专访

云智慧AIOps社区

开源 程序员 算法 运维 时序数据

从 ClickHouse 到 ByteHouse:实时数据分析场景下的优化实践

字节跳动数据平台

数据库 字节跳动 Clickhouse 实时数据分析 bytehouse

中国AIOps们,你们究竟是在骗谁?

观测观测

AIOPS

详细图解Netty Reactor启动全流程

bin的技术小屋

微服务 网络编程 中间件 Java IO netty

深入解析 Apache Pulsar 系列(一):客户端消息确认

Apache Pulsar

开源 架构 云原生 Apache Pulsar 消息中间件

如何提高你的写作技巧

坚果

1月月更

全国社保基金理事会副理事长陈文辉一行莅临青藤调研

青藤云安全

淘宝小部件 Canvas 渲染流程与原理全解析

阿里巴巴终端技术

小程序 淘宝 渲染

手把手教你学Dapr - 9. 可观测性

MASA技术团队

C# .net 微软 微服务 dapr

项目动态|Apache Pulsar 2.7.4 版本发布

Apache Pulsar

开源 架构 云原生 中间件 Apache Pulsar

🏆【Alibaba中间件技术系列】「RocketMQ技术专题」Broker服务端自动创建topic的原理分析和问题要点指南

洛神灬殇

RocketMQ 消息队列 Alibaba技术 Apache RocketMQ 1月日更

技术分享| 探索视频感知编码

anyRTC开发者

音视频 WebRTC 视频编码 视频压缩 视频感知编码

手把手教你学Dapr - 6. 发布订阅

MASA技术团队

C# .net 微软 微服务 dapr

手把手教你学Dapr - 3. 使用Dapr运行第一个.Net程序

MASA技术团队

C# .net 微软 微服务 dapr

Blazor是春天还是寒风里的挣扎

MASA技术团队

C# .net 微软 微服务 blazor

C#8.0 可空引用类型

MASA技术团队

C# .net 微软 微服务 项目

详解 HDFS 底层交互原理

五分钟学大数据

hdfs 1月月更

JavaScript 使用 Markdown 制作 PPT

devpoint

markdown 1月月更 slidev

中国AIOps们,你们究竟是在骗谁?

Geek_f56666

云计算 AIOPS 云服务

MS Office 冷门却实用的技巧及软件安装包分享(文末有福利)

淋雨

Office office365

手把手教你学Dapr - 8. 绑定

MASA技术团队

C# .net 微软 微服务 dapr

中国AIOps们,你们究竟是在骗谁?

Geek_2749b8

云计算 AIOPS 云服务

如何定位并修复 HttpCore5 中的 HTTP2 流量控制问题

阿里巴巴云原生

阿里云 云原生 性能测试 PTS

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