写点什么

Node.js 模块系统源码探微

2021 年 3 月 07 日

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

评论

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

手抖了

shengjk1

随笔杂谈

你看脸吗?

shengjk1

随笔杂谈

每个大火的“线上狼人杀”平台,都离不开这个新功能

ZEGO即构

游戏 RTC 社交

普通工程师简史

郭华

低/零代码会让程序员失业吗?

代码制造者

程序员 低代码 零代码 信息化 编程开发

流媒体云时代的声与色,融云铺就的桥与路

脑极体

网站域名备案怎么做?有哪些快速备案的方法?

姜奋斗

网站 备案 网站搭建 域名解析 网站平台

别让非理性思维毁了你的人生

看山

随笔杂谈 非理性 认知偏差 自控术

浅析Python中的列表和元组

王坤祥

Python python升级

LeetCode题解:88. 合并两个有序数组,for循环合并数组+sort排序,JavaScript,详细注释

Lee Chen

LeetCode 前端进阶训练营

我国开启“逆袭战”,区块链的盛夏来了?

CECBC区块链专委会

云计算 区块链技术

JAVA位运算

彭阿三

Java 位运算

SpringBoot系列(三):SpringBoot特性_SpringApplication类(自定义Banner)

xcbeyond

Java 微服务 springboot Banner

《深度工作》学习笔记(完)

石云升

读书笔记 时间管理 专注 深度工作

Kafka和RocketMQ底层存储之那些你不知道的事

yes的练级攻略

kafka RocketMQ 零拷贝 Mmap

解析中美数字货币竞争战略 | 构建属于“人类命运共同体”的货币体系

CECBC区块链专委会

数字货币 人民币

害怕

shengjk1

随笔杂谈

美丑平等

shengjk1

随笔杂谈

你可能不知道的iPython使用技巧

王坤祥

Python

DSN 主流项目调研 3——Orbit数据库的故事

AIbot

区块链 分布式存储 IPFS 分布式文件 Orbit

易观CTO郭炜:如何构建企业级大数据Ad-hoc查询引擎

易观大数据

数据平台、大数据平台、数据中台……你确定能分得清吗?

华为云开发者社区

大数据 数据中台 开发者 数据湖 数据

憋再@官方了,头像加国旗,10行代码给你安排!

王坤祥

Python python升级

奋斗在一线大城市的年轻人的生活工作实录(工厂蓝领篇)

Learun

程序员 软件开发 故事 企业信息化 短片小说

一文搞懂Flink rocksdb中的数据恢复

shengjk1

大数据 flink源码

Django查看操作数据库的执行命令

BigYoung

数据库 django 操作

关于微服务架构的一些思考

俊俊哥

微服务

流量明星翻车的“直播卖房”,为什么众盟做成了?

脑极体

熬得住,人生路

shengjk1

随笔杂谈

DSN 主流项目调研 2——Sia和SAFE Network

AIbot

区块链 分布式存储 分布式文件存储 Sia SAFENetwork

Cobra 命令自动补全指北

郭旭东

go cobra

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