NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

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

评论

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

毕业总结

小智

架构训练营

TypeScript 之 Class(下)

冴羽

JavaScript typescript 翻译 大前端

日本公司诚招IT开发技术者

马农驾驾驾

Java c++ php Python 日语

架构实战 模块一作业

mj4ever

架构实战营

微信业务架构图&学生管理系统毕业架构设计

Spring

架构实战营

我粗心,有救吗?

Justin

心理学 成长 28天写作

学习总结

Anlumina

「架构实战营」

透过全球首个知识增强千亿大模型,看到中国AI差异化发展之路

脑极体

GrowingIO Terraform 实践

GrowingIO技术专栏

运维 SRE Terraform 项目实践 资源编排

「从0到1如何快速实现cli工具」

速冻鱼

大前端 cli JavaScrip 签约计划第二季 12月日更

记录-今年最骄傲的一件事(2)

将军-技术演讲力教练

Spring AOP(一) AOP基本概念

程序员历小冰

spring aop 28天写作 12月日更

实用机器学习笔记八:特征工程

打工人!

机器学习 算法 学习笔记 12月日更

Hoo虎符研究院 | Arweave调研报告

区块链前沿News

Arweave Hoo虎符 虎符交易所 虎符研究院 去中心化存储

微信业务架构图&&“学生管理系统”毕业架构设计

guodongq

「架构实战营」

架构实战营三期--模块一作业

木几丶

架构实战营 #架构实战营

模块一作业

whoami

「架构实战营」

架构实战营-模块1-作业

Pyel

「架构实战营」

技术架构演进的思考

gevin

架构演进

消费类电子线上问题定位,分析和解决落地

wood

硬件产品 28天写作 线上故障

学习总结 2021.12.09

mj4ever

总结

Python Qt GUI设计:菜单栏、工具栏和状态栏的使用方法(拓展篇—2)

不脱发的程序猿

Python qt GUI设计 Qt Creator 菜单栏、工具栏、状态栏

Rust 元宇宙 15 —— 细节和重构

Miracle

rust 元宇宙

第一周作业

lv

我所理解的微服务

gevin

微服务 微服务架构

架构实战营模块1课后作业

墨宝

Week1学习总结

guodongq

「架构实战营」

作业:架构实战营模块1

Poplar89

「架构实战营」

Git 报错:unable to update local ref

liuzhen007

28天写作 12月日更

彻底弄懂死锁

李子捌

Java、 28天写作 12月日更

第一模块作业

Anlumina

「架构实战营」

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