背景

去年下半年,我在微信书架里加入了许多技术书籍,各种类别的都有,断断续续的读了一部分。

没有计划的阅读,收效甚微。

新年伊始,我准备尝试一下其他方式,比如阅读周。每月抽出1~2个非连续周,完整阅读一本书籍。

这个“玩法”虽然常见且板正,但是有效。

已读完书籍《架构简洁之道》。

当前阅读周书籍《深入浅出的Node.js》

模块机制

CommonJS 规范

CommonJS 规范的提出,主要是为了弥补当前 JavaScript 没有标准的缺陷,希望 JavaScript 能够在任何地方运行。

Node 借鉴 CommonJS 的 Modules 规范实现了一套非常易用的模块系统。

CommonJS 对模块的定义十分简单,主要分为模块引用、模块定义和模块标识3个部分。

  • 模块引用:在 CommonJS 规范中,存在 require() 方法,这个方法接受模块标识,以此引入一个模块的API到当前上下文中。
  • 模块定义:在模块中,还存在一个 module 对象,它代表模块自身,而 exports 是 module 的属性。在 Node 中,一个文件就是一个模块,将方法挂载在exports对象上作为属性即可定义导出的方式
  • 模块标识:模块标识其实就是传递给 require() 方法的参数,它必须是符合小驼峰命名的字符串,或者以.、..开头的相对路径,或者绝对路径。它可以没有文件名后缀.js。

Node 的模块实现

在 Node 中,模块分为两类

  • 核心模块:这类模块是 Node 提供的模块。核心模块部分在Node源代码的编译过程中,编译进了二进制执行文件。
  • 文件模块:这类模块是用户编写的模块。文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。

在 Node 中引入模块的步骤如下:

  1. 路径分析
  2. 文件定位
  3. 编译执行

模块加载的详细过程如下:

  1. 优先从缓存加载。Node 对引入过的模块都会进行缓存,以减少二次引入时的开销。 Node 缓存的是编译和执行之后的对象。不论是核心模块还是文件模块,require() 方法对相同模块的二次加载都一律采用缓存优先的方式。
  2. 路径分析和文件定位。对于不同的标识符,模块的查找和定位有不同程度上的差异。
  • 模块标识符分析
  • 核心模块:核心模块的优先级仅次于缓存加载,它在 Node 的源代码编译过程中已经编译为二进制代码,其加载过程最快。
  • 路径形式的文件模块:以.、..和/开始的标识符,这里都被当做文件模块来处理。在分析文件模块时,require() 方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以使二次加载时更快。
  • 自定义模块:自定义模块指的是非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能是一个文件或者包的形式。
  • 文件定位

从缓存加载的优化策略使得二次引入时不需要路径分析、文件定位和编译执行的过程,大大提高了再次加载模块时的效率。

  1. 模块编译。编译和执行是引入文件模块的最后一个阶段。定位到具体的文件后,Node 会新建一个模块对象,然后根据路径载入并编译。

对于不同的文件扩展名,其载入方法也有所不同,具体如下所示:

  • .js 文件:通过 fs 模块同步读取文件后编译执行。
  • .node 文件:这是用 C/C++ 编写的扩展文件,通过 dlopen() 方法加载最后编译生成的文件。
  • .json 文件:通过 fs 模块同步读取文件后,用 JSON.parse() 解析返回结果。
  • 其余扩展名文件:它们都被当做 .js 文件载入。

核心模块

Node 的核心模块在编译成可执行文件的过程中被编译进了二进制文件。

Node 的核心模块分为 C/C++ 编写的和 JavaScript 编写的两部分,其中 C/C++ 文件存放在 Node 项目的 src 目录下,JavaScript 文件存放在 lib 目录下。

C/C++ 扩展模块

C/C++ 扩展模块属于文件模块中的一类。

C/C++ 模块通过预先编译为 .node 文件,然后调用 process.dlopen() 方法加载执行。

阅读周·深入浅出的Node.js | 模块机制,代码的易用与安全从约束开始_缓存

C/C++ 扩展模块与 JavaScript 模块的区别在于加载之后不需要编译,直接执行之后就可以被外部调用了,其加载速度比 JavaScript 模块略快。

使用 C/C++ 扩展模块的一个好处在于可以更灵活和动态地加载它们,保持 Node 模块自身简单性的同时,给予 Node 无限的可扩展性。

模块调用栈

各种模块之间的调用关系如下图:

阅读周·深入浅出的Node.js | 模块机制,代码的易用与安全从约束开始_缓存_02

C/C++ 内建模块属于最底层的模块,它属于核心模块,主要提供API给 JavaScript 核心模块和第三方 JavaScript文件模块调用。

JavaScript 核心模块主要扮演的职责有两类:一类是作为 C/C++ 内建模块的封装层和桥接层,供文件模块调用;一类是纯粹的功能模块,它不需要跟底层打交道,但是又十分重要。

文件模块通常由第三方编写,包括普通 JavaScript 模块 和 C/C++ 扩展模块,主要调用方向为普通 JavaScript 模块调用扩展模块。

包与NPM

在第三方模块中,模块与模块之间仍然是散列在各地的,相互之间不能直接引用。包和 NPM 则是将模块联系起来的一种机制。

  • 包结构:包实际上是一个存档文件,即一个目录直接打包为.zip或tar.gz格式的文件,安装后解压还原为目录。
  • 包描述文件与 NPM:包描述文件用于表达非代码相关的信息,它是一个 JSON 格式的文件——package.json,位于包的根目录下,是包的重要组成部分。而 NPM 的所有行为都与包描述文件的字段息息相关。
  • NPM 常用功能:借助NPM,可以帮助用户快速安装和管理依赖包。

前后端共用模块

JavaScript 在 Node 出现之后,比别的编程语言多了一项优势,那就是一些模块可以在前后端实现共用,这是因为很多 API 在各个宿主环境下都提供。

1、模块的侧重点

前后端 JavaScript 分别搁置在 HTTP 的两端,它们扮演的角色并不同。

浏览器端的 JavaScript 需要经历从同一个服务器端分发到多个客户端执行,而服务器端 JavaScript 则是相同的代码需要多次执行。前者的瓶颈在于带宽,后者的瓶颈则在于 CPU 和内存等资源。前者需要通过网络加载代码,后者从磁盘中加载,两者的加载速度不在一个数量级上。

2、AMD 规范

AMD 规范是 CommonJS 模块规范的一个延伸,它的模块定义如下:

define(id? , dependencies? , factory);

它的模块 id 和依赖是可选的,与 Node 模块相似的地方在于 factory 的内容就是实际代码的内容。下面的代码定义了一个简单的模块:

define(function() {
  var exports = {};
  exports.sayHello = function() {
    alert('Hello from module: ' + module.id);
  };
  return exports;
});

3、CMD 规范

CMD 模块更接近于 Node 对 CommonJS 规范的定义:

define(factory);

在依赖部分,CMD 支持动态引入,示例如下:

define(function(require, exports, module) {
  // The module code goes here
});

require、exports 和 module 通过形参传递给模块,在需要依赖模块时,随时调用 require() 引入即可。

4、兼容多种模块规范

为了让同一个模块可以运行在前后端,在写作过程中需要考虑兼容前端也实现了模块规范的环境。为了保持前后端的一致性,类库开发者需要将类库代码包装在一个闭包内。

总结

我们来总结一下本篇的主要内容:

  • CommonJS 规范的提出,主要是为了弥补当前 JavaScript 没有标准的缺陷,希望 JavaScript 能够在任何地方运行。
  • Node 通过模块规范,组织了自身的原生模块,弥补 JavaScript 弱结构性的问题,形成了稳定的结构,并向外提供服务。
  • NPM 通过对包规范的支持,有效地组织了第三方模块,这使得项目开发中的依赖问题得到很好的解决,并有效提供了分享和传播的平台,借助第三方开源力量,使得 Node 第三方模块的发展速度前所未有。
  • Node 出现后,一些模块可以在前后端实现共用,这是因为很多 API 在各个宿主环境下都提供。

作者介绍非职业「传道授业解惑」的开发者叶一一。《趣学前端》、《CSS畅想》等系列作者。华夏美食、国漫、古风重度爱好者,刑侦、无限流小说初级玩家。如果看完文章有所收获,欢迎点赞👍 | 收藏⭐️ | 留言📝。