Node模块详解(一)

介绍

本文是阅读《深入浅出Node.js》后对自己所学和理解的内容进行整理后完成的,结合Node源码为读者解读模块这一基础而又非常重要的一个机制是怎么实现和运转的。

此文为Node模块详解系列第一篇,主要介绍模块以及实现。

CommonJS规范

说到模块就先介绍下CommonJS规范,因为模块是它提出的,而Node也对规范有了很好的实现(尽管Node的npm管理器作者在13年宣称废弃它)。

引用维基百科的介绍:

CommonJS是一个项目,其目标是为JavaScript网页浏览器之外创建模块约定。

说白话,就是希望JS摆脱前端束缚,能够在任何地方使用,比如:

  • 服务端
  • 命令行工具
  • 桌面应用程序
  • 混合应用(Titanium和Adobe AIR等形式的应用(我也没懂zzz~))

详细了解去看CommonJS官网,这里不做详细介绍,我们重点关注模块。

CommonJS的模块规范

CommonJS对模块的定义为模块引用、模块定义和模块标识3个部分。

  1. 模块引用

    通过require()方法导入一个模块的API到当前上下文中。

    示例代码:

    1
    var http = require("http");
  2. 模块定义

    对应引入的功能,上下文提供了exports对象。在模块中,存在一个module对象,它代表模块自身,exports是module的属性,用于导出当前模块的方法或者变量,并且它是唯一导出的出口,module信息如下图:

    module信息

    在Node中,一个文件就是一个模块,将值传给exports对象即可导出。

  3. 模块标识

    模块标识其实就是传递给require()方法的参数,即导入模块的name。

模块的定义十分简单,接口也十分简洁。它的意义在于将类聚的方法和变量等限定在私有的作用域中,同时支持引入和导出功能以顺畅地连接上下游依赖。

Node模块实现

Node参考CommonJS规范进行模块设计,同时也增加了一些自身的特性,本文我们主要研究Node内部如何实现模块机制。

在Node中,模块主要分为两类

  • 核心模块:Node提供的模块(Node启动时直接加载)
  • 文件模块:用户编写的模块(运行时动态加载)

本文主要主要研究的是文件模块的加载编译流程,主要分为三个步骤:

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

路径分析和文件定位

顾名思义:找到要导入的模块。因为模块标识符情况比较多,所以在这里讲一下。标识符主要分为以下几类:

  • 核心模块:如http、fs等直接写
  • 相对路径文件模块:.或者..开头的
  • 绝对路径模块:以/开头的
  • 自定义模块:在node_modules目录中的模块

核心模块直接编译在node内部,导入最快,带路径的则要进行解析,解析流程如下:

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

在这个过程中,Node对CommonJS包规范进行了一定程度的支持。首先,Node在当前目录下查找package.json(CommonJS包规范定义的包描述文件),通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤。

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

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

内容引用书里的,本来想画个流程图,结果发现还不如直接看原文明白,毕竟这里不是我们的重点关注,也就不细讲了。

前面讲了这么多,终于讲到我们今天的重点:模块编译。

模块编译

上述步骤后,我们已经定位到文件,进入后续编译和执行环节。

注:本章主要结合源码进行分析,有兴趣可以下载源码学习(版本:v0.10.13-release)。

之前讲到过,一个文件就是一个模块,而一个(文件)模块也是一个对象,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
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 = [];
}

模块的载入以文件类型进行区分,不同的扩展名,载入方式也不同,分为以下四种情况:

  • js文件:通过fs模块同步读取文件后编译执行
  • node文件:用c/c++编写的扩展文件,通过dlopen()加载编译生成的文件
  • json文件:通过fs模块读取,使用JSON.parse()解析返回
  • 其他扩展名文件:当做.js文件载入

解析扩展名的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Module.prototype.load = function(filename) {
debug('load ' + JSON.stringify(filename) +
' for module ' + JSON.stringify(this.id));

assert(!this.loaded);
this.filename = filename;
this.paths = Module._nodeModulePaths(path.dirname(filename));

var extension = path.extname(filename) || '.js';
if (!Module._extensions[extension]) extension = '.js'; // 这里对其他扩展名以js文件对待
Module._extensions[extension](this, filename);
this.loaded = true;
};

接下来对这三种文件的载入以及编译进行详细讲解

.js文件

.js文件是我们主要关注的重点,它要比其他两种情况多一个包装的步骤。

使用过node的朋友都知道,每个模块都有__filename__dirname这两个变量的存在,并且模块也有自己单独的作用域,不同模块之间参数不会被相互污染,这个其实就是Node在编译前对模块文件进行了封装,代码如下:

1
2
3
4
5
6
7
8
NativeModule.wrap = function(script) {
return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};

NativeModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];

非常简单的代码,却实现了每个模块文件之间作用域的隔离,为模块注入了灵魂。

包装之后的代码会通过vm原生模块的runInThisContext()方法执行,返回一个具体的function对象。最后,将当前模块对象的exports属性、require()方法、module(模块对象自身),以及在文件定位中得到的完整文件路径和文件目录作为参数传递给这个function()执行,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
var content = NativeModule.require('fs').readFileSync(filename, 'utf8');
module._compile(stripBOM(content), filename);
};

// Returns exception if any
Module.prototype._compile = function(content, filename) {
var self = this;
// remove shebang
content = content.replace(/^\#\!.*/, '');
...
if (Module._contextLoad) {
// create wrapper function
var wrapper = Module.wrap(content); //这里进行的包装

var compiledWrapper = runInThisContext(wrapper, filename, true);
if (global.v8debug) {
if (!resolvedArgv) {
// we enter the repl if we're not given a filename argument.
if (process.argv[1]) {
resolvedArgv = Module._resolveFilename(process.argv[1], null);
} else {
resolvedArgv = 'repl';
}
}
// Set breakpoint on module start
if (filename === resolvedArgv) {
global.v8debug.Debug.setBreakPoint(compiledWrapper, 0, 0);
}
}
var args = [self.exports, require, self, filename, dirname]; //注意这里
return compiledWrapper.apply(self.exports, args);
};

这样一个js文件就被导入执行了,这里有一点要注意的是(上面注释有标明),exports原来是module的exports参数,为什么这么做,不直接传一个module对象或者将exports单独拿出来呢?原因主要有如下两点:

  • 达到require只引入一个类的效果
  • exports是形参引用,修改无效

.node文件

.node的模块文件实际上是编写C/C++模块之后编译生成的,所以并不需要编译操作,加载和运行通过调用process.dlopen()方法来实现,代码如下:

1
2
3
4
5
6
7
8
9
10
// Native extension for .node
Module._extensions['.node'] = function(module, filename) {
if (manifest) {
const content = fs.readFileSync(filename);
const moduleURL = pathToFileURL(filename);
manifest.assertIntegrity(moduleURL, content);
}
// Be aware this doesn't use `content`
return process.dlopen(module, path.toNamespacedPath(filename));
};

dlopen()在文件src/node.cc中有着相关实现,主要是依托于libuv库进行的封装,执行后.node中的对象传给exports返回给调用者。

.node文件模块因为是C/C++编译生成的,所以执行效率远高于.js文件模块,不过因为学习门槛高,流程复杂,大都更倾向于.js文件模块。

.json文件

最后说的是最简单的.json文件模块,调用fs模块同步读取文件内容后,通过调用内部方法JSON.parse()解析得到对象,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
const content = fs.readFileSync(filename, 'utf8');

if (manifest) {
const moduleURL = pathToFileURL(filename);
manifest.assertIntegrity(moduleURL, content);
}

try {
module.exports = JSON.parse(stripBOM(content));
} catch (err) {
err.message = filename + ': ' + err.message;
throw err;
}
};

因为导入的模块会进行缓存,二次导入的时候直接读取缓存中的对象,所以这种文件模块导入的方式要优于在代码中进行导入的实现。

总结

本文主要是我在学习《深入浅出Node.js》一书中的学习笔记,是模块系列的第一篇,这一部分代码与最新的没什么区别,后续的源码也会继续沿用书中的老版本,毕竟没有人带路去读最新的node源码实在头疼,理解了书中的思想后续在对升级后的代码进行理解相比会舒服很多(自以为🌚)。

-------------本文结束,感谢您的阅读-------------