node.js的模块实现

在Node中引入模块,需要经历如下三个步骤。

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

在Node中,模块分为两类:一类是Node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。

  • 核心模块在部分Node源代码的编译过程中,编译进了二进制执行文件。在Node进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的。
  • 文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。

优先从缓存加载

前端浏览器会缓存静态脚本文件以提高性能。Node对引入过的模块都会进行缓存,以减少二次引入时的开销。不同的地方在于,浏览器仅仅缓存文件,而Node缓存的是编译和执行之后的对象。

不论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级。不同之处在于核心模块的缓存检查先于文件模块的缓存检查。

路径分析和文件定位

1. 模块标识符分析

require()方法接受一个标识符作为参数。在Node实现中,正是基于这样一个标识符进行模块查找的。模块标识符在Node中主要分为以下几类。

  • 核心模块,如http、fs、path等
  • .或..开始的相对路径文件模块
  • 以/开始的绝对路径文件模块
  • 非路径形式的文件模块,如自定义的connect模块

核心模块

核心模块的优先级仅次于缓存加载,它在Node的源代码编译过程中已经编译为二进制代码,其加载过程最快。

路径形式的文件模块

.、../开始的标识符,这里都被当做文件模块来处理。在分析路径模块时,require()方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以便二次加载时更快。

由于文件模块给Node指明了确切的文件位置,所以在查找过程中可以节约大量时间,其加载速度慢于核心模块。

自定义模块

自定义模块指的是非核心模块,也不是路径形式的标识符。它是一种特殊的文件模块,可能是一个文件或者包的形式。这类模块的查找是最费时的,也是所有方式中最慢的一种。

模块路径

模块路径是Node在定位文件模块的具体文件是制定的查找策略,具体表现为一个路径组成的数组。

1
2
3
4
5
6
7
8
9
// module_path.js
console.log(module.paths);

➜ my-node node module_path.js
[ '/opt/learn/node/my-node/node_modules',
'/opt/learn/node/node_modules',
'/opt/learn/node_modules',
'/opt/node_modules',
'/node_modules' ]

模块路径的生成规则如下:

  • 当前文件目录下的 node_modules 目录

  • 父目录下的 node_modules 目录

  • 父目录的父目录下的 node_modules 目录

  • 沿路径向上逐级递归,直到根目录下的 node_modules 目录

它的生成方式与JavaScript的原型链或作用域的查找方式十分类似。在加载的过程中,Node会逐个尝试模块路径中的路径,直到找到目标文件为止。这是自定义模块的加载速度是最慢的原因。

2.文件定位

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

在文件的定位过程中,还有一些细节需要注意,主要包括文件拓展名的分析、目录和包的处理。

文件拓展名分析

require()在分析标识符的过程中,会出现标识符中不包含文件拓展名的情况。CommonJS模块规范也允许在标识符中不包含文件拓展名,这种情况下,Node会按 .js、.json、.node的次序补足拓展名,依次尝试。

在尝试的过程中,需要调用fs模块同步阻塞式地判断文件是否存在。因为Node是单线程的,所以这里是一个引起性能问题的地方。

Tips:

  1. 如果是 .node.json 文件,在传递给require()的标识符中带上拓展名,会加快一点速度。

  2. 同步配合缓存,可以大幅度缓解Node单线程中阻塞式调用的缺陷。

目录分析和包

在分析标识符的过程中,require()通过分析文件拓展名之后,可能没有查找到对应文件,但却得到一个目录,这在引入自定义模块和逐个模块路径进行查找时经常会出现,此时Node会将目录当做一个包来处理。

Node在当前目录下查找package.json,通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。如果文件名缺少拓展名,将会进入拓展名分析步骤。

如果main属性指定的文件名错误,或者压根没有package.json文件。Node会将index当作默认文件名,然后依次查找index.js、index.json、index.node。

如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查找。如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。

模块编译

在 Node中,每个文件模块都是一个对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;

if (parent && parent.children) {
parent.children.push(this);
}

this.filename = null;
this.loaded = false;
this.children = [];
}

编译和执行是引入文件模块的最后一个阶段。定位到具体的文件后,Node会新建一个模块对象,然后根据路径载入并编译。对不同的文件拓展名,其载入方法也有所不同。

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

每一个编译成功的模块都会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引入的性能。