Node 模块系统

背景

像 Python 或者 Java 来编写应用程序时,首先它们拥有非常多的库,在开发时的效率能提高,对于文件 IO 和调用 OS 级别的操作都有标准的接口可用。对于 JavaScript 来说,它本身的规范非常弱,有以下几个缺陷

  • 没有模块

  • 没有标准库

  • 没有标准接口

  • 没有包管理

针对 JavaScript 的薄弱规范,Node 借鉴 CommonJS 实现了模块系统

CommonJS

  1. 模块引用

const math = require("math");
  1. 模块定义

const add = (a, b) => a + b;
exports.add = add;
  1. 模块标识

在进行模块引用时,const math = require('xxx')xxx就是这个模块的标识,它可以是一个字符串,也可以是一个路径。每个模块拥有独立的空间,互不干扰,不需要考虑变量污染和命名空间

Node 模块

Node 中的模块包括两类:Node 核心模块(path/fs/...)和文件模块(用户编写的模块和通过包管理安装的模块)

Node 模块引用/导出

模块引用与 CommonJS 相同,对于模块导出,一个模块中有一个exports对象用于导出当前模块的方法或者变量

  • exports

const add = (a, b) => a + b;
exports.add = add;
  • module.exports

const add = (a, b) => a + b;
module.exports = {
  add
};

在 Node 中,既可以使用exports,也可以使用module.exports来导出模块,这两种方法有区别。

对于module.exports来说,它指向一个对象,初始化指向{},你可以改变它的指向,例如上文,指向修改为一个新的对象,对象中有属性add

但是对于exports来说,它指向的是module.exports,实际上是 Node 提供了一个变量来方便访问module.exports。通过exports.xxx = xxx我们就可以对module.exports的属性进行修改,导出时依旧导出module.exports。所以!如果直接对exports进行修改,是不会有效果的。例如exports = {add: '123'};,它直接修改了exports变量的指向,从module.exports变成指向一个新对象,但是这样不会修改module.exports,所以导出时,还是导出了module.exports,新对象{add: '123'}并不会导出。

Node 模块实现

在 Node 中引用模块,会经历以下一个步骤:缓存-路径分析-文件定位-编译执行

  1. 缓存

如果模块在此前已经编译执行过,Node 会缓存编译执行后的结果,当再次引用时,路径分析-文件定位-编译执行这几个步骤都不会执行

  1. 路径分析

当缓存未命中时,会进行路径分析。路径分析会根据模块标识的分类进行不同方法的查找,模块标识大概可以分为以下三类:

  • 核心模块

核心模块在 Node 源代码编译过程中已经被编译成二进制代码,当路径匹配到核心模块时,文件定位-编译执行这几个步骤不会执行,直接使用 Node 编译后的二进制文件

  • ./或者/开始的路径形式的文件模块

当路径分析未匹配到核心模块,但是匹配到路径形式的文件模块时,Node 会先将路径转化成绝对路径,定位文件并编译执行,把绝对路径作为 key 和编译执行的结果存入缓存

  • 自定义模块,例如 npm 安装的模块

当路径分析都未匹配到核心模块和路径形式的文件模块时,Node 认为它是一个自定义模块,然后根据module.paths进行查找。module.paths是模块中的一个字符串数组,它存储了当前模块的模块路径。例如,我们有一个文件,它的路径是/a/b/c/test.js,它的module.paths的值是

[
  "/a/b/c/node_modules",
  "/a/b/node_modules",
  "/a/node_modules",
  "/node_modules"
];

所以,根据module.paths进行查找时,会先查找当前路径的下的node_modules文件夹,并进行文件定位,如果没有定位到,则去寻找父目录的node_modules文件夹进行文件定位,直至找到根目录的node_modules文件夹进行文件定位。如果直至根目录的node_modules文件夹都没有定位到文件,则抛出查找失败的错误。

  1. 文件定位

当匹配到路径形式的文件模块或者自定义模块时,Node 会去定位文件。在写模块标识符时,可以不写后缀名,这就依赖于 Node 文件定位的功能。 Node 文件定位会按照顺序先去寻找当前路径加扩展名是.js/.json/.node的文件。如果存在这个文件,就返回这个文件并对这个文件进行编译。 如果这三个扩展名都没有匹配成功,Node 认为这个路径是一个文件夹,然后 Node 去这个文件下按照顺序去寻找文件名是index.js/index.json/index.node的文件。如果存在这个文件,就返回这个文件并对这个文件进行编译。

  1. 编译和执行

当进行编译和执行时,文件类型有 3 种情况.js/.json/.node,根据这三种情况,Node 编译的策略也不同

  • js 文件

我们在 Node 中可以使用require/exports/module/__filename/__dirname就是因为在编译执行的时候 Node 将这些变量传了进来

首先使用 Node 的fs模块读取文件内容,然后用一个函数来包裹:

(function(exports, require, module, __filename, __dirname) {
  // 文件内容
});

通过这样实现了不同模块直接的作用域隔离,并传入了 CommonJS 模块规范以及文件名和所在文件夹名的变量。

  • node 文件

.node文件是通过 C/C++写的扩展文件,将模块的 exports 和扩展文件进行关联,它通过 Node 的process.dlopen()进行打开和执行。由于是使用 C/C++写的,所以不需要编译阶段,它的执行效率相对更高。 在 Windows 和*nix 系统下,dlopen()的实现方法不同,主要使用了libuv进行了封装

  • json 文件

首先通过 Node 的fs模块读取文件内容,然后通过

JSON.parse(/** 文件内容 **/);

得到结果,赋值给module.exports

最后更新于