Node.js Modules模块系统

 2016年07月27日    231     声明


Node.js实现了一个简单的模块加载系统。在Node.js中,文件和模块是一一对应的关系,可以理解为一个文件就是一个模块。其模块系统的实现主要依赖于全局对象module,其中实现了exports(导出)、require()(加载)等机制。

  1. 模块加载
  2. 访问主模块
  3. 附:包管理技巧
  4. 总体来说…
  5. 模块缓存
  6. 核心模块
  7. 循环依赖
  8. 文件模块
  9. 文件夹做为模块
  10. node_modules文件夹加载
  11. 从全局文件夹加载
  12. 模块包装
  13. module对象

1. 模块加载

Node.js中一个文件就是一个模块。如,在foo.js中加载同目录下的circle.js

foo.js内容:

const circle = require('./circle.js');
console.log( `半径为 4 的圆面积为 ${circle.area(4)}`);

circle.js内容:

const PI = Math.PI;

exports.area = (r) => PI * r * r;

exports.circumference = (r) => 2 * PI * r;

circle.js中通过exports导出了area()circumference两个方法,这两个方法可以其它模块中调用。

exportsmodule.exports

exports是对module.exports的一个简单引用。如果你需要将模块导出为一个函数(如:构造函数),或者想导出一个完整的出口对象而不是做为属性导出,这时应该使用module.exports

如,bar.js引用做为构造函数导出的square

const square = require('./square.js');
var mySquare = square(2);
console.log(`The area of my square is ${mySquare.area()}`);

square.js导出:

module.exports = (width) => {
  return {
    area: () => width * width
  };
}


2. 访问主模块

当Node.js直接运行一个文件时,require.main属性会被设置为module本身。这样,就可通过这个属性判断模块是否被直接运行:

require.main === module

如,对于foo.js来说,通过node foo.js运行上面值是true;而通过require('./foo')时,确是false

module 提供了一个filename 属性,其值通常等于__filename。 所以,当前程序的入口点可以通过require.main.filename来获取。


3. 附:包管理技巧

require()用于加载模块。在Node.js中,该函数的语义被设计为足够的通用化,以支持各种常规的目录结构。这样,dpkgrpmnpm将不用于修改模块就能构建本地包。

以下是Node.js官方给出的一个目录结构建议:

假设,我们希望将一个包的指定版本放在如下目录中:

/usr/lib/node/<some-package>/<some-version>

包可能会依赖其它包。为了安装foo包,可能需要安装指定版本的bar包。而bar可能也存在依赖关系,在某些情况下依赖关系可能会发生冲突或者形成循环依赖。

因为Node.js会解析加载模块的realpath(真实路径),并在node_modules中查找模块的依赖关系。这可能会和以下情形比较相似:

  • /usr/lib/node/foo/1.2.3/ - foo1.2.3版本的内容.
  • /usr/lib/node/bar/4.3.2/ - foo所依赖的bar包的内容
  • /usr/lib/node/foo/1.2.3/node_modules/bar - /usr/lib/node/bar/4.3.2/的符号连接
  • /usr/lib/node/bar/4.3.2/node_modules/* - bar所依赖的符号链接

因此即便存在循环依赖或依赖冲突,每个模块仍然可以获取到它所依赖的包的一个可用版本。

foo包中的代码调用require('bar')时,会获得符号链接/usr/lib/node/foo/1.2.3/node_modules/bar所指向的版本。 然后,当bar包中的代码调用require('queue'),将会获得符号链接/usr/lib/node/bar/4.3.2/node_modules/quux所指向的版本。

另外,为了进一步优化模块搜索过程,不要将包直接放在/usr/lib/node目录中,而是放在/usr/lib/node_modules/<name>/<version>目录中。这样在依赖的包找不到的情况下,就不会一直查找/usr/node_modules/node_modules目录了。

为了使模块可以在Node.js的REPL中可用,可能需要将/usr/lib/node_modules目录添加到$NODE_PATH环境变量中。由于在node_modules目录中搜索模块使用的是相对路径(基于调用require()的文件所在真实路径),因此包本身可以放在任何位置。


4. 总体来说…

Node.js最终会使用require.resolve()函数,来获取require加载的模块的确切文件名。

下面是使用伪代码介绍require.resolve的工作过程:

require(X) from module at path Y
1. If X is a core module,
   a. return the core module
   b. STOP
2. If X begins with './' or '/' or '../'
   a. LOAD_AS_FILE(Y + X)
   b. LOAD_AS_DIRECTORY(Y + X)
3. LOAD_NODE_MODULES(X, dirname(Y))
4. THROW "not found"

LOAD_AS_FILE(X)
1. If X is a file, load X as JavaScript text.  STOP
2. If X.js is a file, load X.js as JavaScript text.  STOP
3. If X.json is a file, parse X.json to a JavaScript Object.  STOP
4. If X.node is a file, load X.node as binary addon.  STOP

LOAD_AS_DIRECTORY(X)
1. If X/package.json is a file,
   a. Parse X/package.json, and look for "main" field.
   b. let M = X + (json main field)
   c. LOAD_AS_FILE(M)
2. If X/index.js is a file, load X/index.js as JavaScript text.  STOP
3. If X/index.json is a file, parse X/index.json to a JavaScript object. STOP
4. If X/index.node is a file, load X/index.node as binary addon.  STOP

LOAD_NODE_MODULES(X, START)
1. let DIRS=NODE_MODULES_PATHS(START)
2. for each DIR in DIRS:
   a. LOAD_AS_FILE(DIR/X)
   b. LOAD_AS_DIRECTORY(DIR/X)

NODE_MODULES_PATHS(START)
1. let PARTS = path split(START)
2. let I = count of PARTS - 1
3. let DIRS = []
4. while I >= 0,
   a. if PARTS[I] = "node_modules" CONTINUE
   c. DIR = path join(PARTS[0 .. I] + "node_modules")
   b. DIRS = DIRS + DIR
   c. let I = I - 1
5. return DIRS


5. 模块缓存

模块在第一次加载后会被缓存。这意味着每次调用require('foo'),都会返回同一个对象。

多次调用require('foo'),未必会导致模块中代码的多次执行。这是一个重要的功能,借助这一功能,可以返回部分完成的对象;这样,传递依赖也能被加载,即使它们可能导致循环依赖。

如果你希望一个模块多次执行,那么就应该输出一个函数,然后调用这个函数。

模块缓存的注意事项

模块的基于其解析后的文件名进行缓存。由于调用的位置不同,可能会解析到不同的文件(如,需要从node_modules文件夹加载的情况)。所以,当解析到其它文件时,就不能保证require('foo')总是会返回确切的同一对象。

另外,在不区分大小的文件系统或系统中,不同的文件名可能解板到相同的文件,但缓存仍会将它们视为不同的模块,会多次加载文件。如:require('./foo')require('./FOO')会返回两个不同的对象,无论'./foo''./FOO'是否是同一个文件。


6. 核心模块

Node.js有一些核心模块(原生模块),这些模块被编译成了二进制,并随Node.js的安装而安装。

这些模块被定义在Node安装路径的lib/目录下。核心模块的加载优先级最高,如通过require('http')加载HTTP模块时,无论是否有自定义的HTTP模块,被加载的总是编译好的HTTP模块。


7. 循环依赖

require()存在循环调用时,模块在返回时可能并不会被执行。

如,现在a.js

console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');

b.js

console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');

main.js

console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done=%j, b.done=%j', a.done, b.done);

首先main.js会加载a.js,接着a.js又会加载b.js。这时,b.js又会尝试去加载a.js。为了防止无限的循环,a.js会返回一个unfinished copyb.js。然后b.js就会停止加载,并将其exports对象返回给a.js模块。

这样main.js就完成了a.jsb.js两个文件的加载。输出如下:

$ node main.js
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done=true, b.done=true


8. 文件模块

当加载文件模块时,如果按文件名查找未找到。那么Node.js会尝试添加.js.json的扩展名,并再次尝试查找。如果仍未找到,那么会添加.node扩展名再次尝试查找。

对于.js文件,会将其解析为JavaScript文本文件;而.json会解析为JOSN文件文件;.node会尝试解析为编译后的插件文件,并由dlopen进行加载。

路径解析

当加载的文件模块使用'/'前缀时,则表示绝对路径。如,require('/home/marco/foo.js')会加载/home/marco/foo.js文件。

而使用'./'前缀时,表示相对路径。如,在foo.jsrequire('./circle')引用时,circle.js必须在相同的目录下才能加载成功。

当没有'/''./'前缀时,所引用的模块必须是“核心模块”或是node_modules中的模块。

如果所加载的模块不存在,require()会抛出一个code属性为'MODULE_NOT_FOUND'的错误。


9. 文件夹做为模块

可以把程序和库放到一个单独的文件夹中,并提供一个入口来指向它。有三种方法,可以使文件夹作为require()的参数来加载。

  • 通过package.json文件加载
  • 通过index.js文件加载
  • 通过index.json文件加载

在文件夹的根目录中定义一个package.json文件,并在其main节点中指定程序入口:

{ "name" : "some-library",
  "main" : "./lib/some-library.js" }

如上所示,如果文件放在./some-library目录下,那么require('./some-library')最终会加载./some-library/lib/some-library.js文件。

如果目录中没有package.json文件,那么Node.js会尝试路径下加载index.jsindex.json文件。


10. 从node_modules文件夹加载

如果require()中的模块名不是一个本地模块,也没有以'/''../'、或'./'。那么Node.js会从当前模块的父目录开始,尝试在它的/node_modules文件夹中加载相应模块。如果没有找到,会继续查找上级目录,直到到达顶层目录。

如,位于'/home/ry/projects/foo.js'中的文件调用了require('bar.js'),那么Node.js会依次查找:

/home/ry/projects/node_modules/bar.js
/home/ry/node_modules/bar.js
/home/node_modules/bar.js
/node_modules/bar.js


11. 从全局文件夹加载

如果NODE_PATH环境变量设置为一个以冒号分割的绝对路径的列表,当找不到模块时Node.js会从这些路径中搜索模块。

NODE_PATH外,Node.js还会搜索以下路径:

$HOME/.node_modules
$HOME/.node_libraries
$PREFIX/lib/node

之所以会这么做,是因为一些历史原因造成的。建议总是将依赖的模块放在node_modules,这样加载速度更快也更可靠。


12. 模块包装

在模块代码被执行前,Node.js会使用一个包装函数对其包装:

(function (exports, require, module, __filename, __dirname) {
// Your module code actually lives in here
});

在这一过程中,Node.js会实现以下功能:

  • 使顶级变量(var、const或let)处于模块内,而不是全局对象
  • 提供一些全局变量,实际是特定的模块。如:
    • 模块及导出对象,即:引用对象可以从模块获取的导出值
    • 便捷变量名__filename__dirname,包含文件名及目录的绝对路径


13. module对象

module在每个模块中表示对当前模块的引用。 而module.exports又可以通过全局对象exports来引用。module并不是一个全局对象,而更像一个模块内部对象。

module.children

这个模块引入的所有模块对象

module.exports

module.exports通过模块系统创建。有时它的工作方式与我们所想的并不一致,有时我们希望模块是一些类的实例。因此,要将导出对象赋值给module.exports,但是导出所需的对象将分配绑定本地导出变量,这可能不是我们想要的结果。

如,有模块a.js

const EventEmitter = require('events');

module.exports = new EventEmitter();

// Do some work, and after some time emit
// the 'ready' event from the module itself.
setTimeout(() => {
  module.exports.emit('ready');
}, 1000);

我们可以在另一个文件中,像下面这样引入:

const a = require('./a');
a.on('ready', () => {
  console.log('module a is ready');
});

需要注意,分配给module.exports的导出值必须能立刻获取到,当使用回调时其不能正常执行。

如,x.js

setTimeout(() => {
  module.exports = { a: 'hello' };
}, 0);

y.js

const x = require('./x');
console.log(x.a);

exports别名

exports可以做为module.exports的一个引用。和任何变量一样,如果为它分配新值,其旧值将会失效:

function require(...) {
  // ...
  ((module, exports) => {
    // Your module code here
    exports = some_func;        // re-assigns exports, exports is no longer
                                // a shortcut, and nothing is exported.
    module.exports = some_func; // makes your module export 0
  })(module, module.exports);
  return module;
}

module.filename - 模块解析后的完整文件名

module.id - 用于区别模块的标识符,通常是完全解析后的文件名。

module.loaded - 模块是否加载完毕

module.parent - 父模块,即:引入这个模块的模块

module.require(id)

module.require提供了类似require()的功能,可以从最初的模块加载一个模块