Modules

Node.js 内置了模块加载系统,文件和模块具有一一对应的关系。举例来说,foo.js 文件加载了同一目录下的 circle.js 模块,则在 foo.js 中可以向如下代码所示加载外部模块:

  1. const circle = require('./circle.js');
  2. console.log( `The area of a circle of radius 4 is ${circle.area(4)}`);

circle.js 的内容:

  1. const PI = Math.PI;
  2. exports.area = (r) => PI * r * r;
  3. exports.circumference = (r) => 2 * PI * r;

circle.js 模块向外暴漏了两个方法:area() 和 circuference()。如果要将对象或函数置于模块的顶层作用域,则可以将它们挂载在 exports 对象下。

模块的本地变量和内部封装的方法都是私有的,在上面的代码中变量 PI 就是 circle.js 私有的变量。

如果你只想从模块输出一个包含一切的函数或对象,那么可以使用 module.exports 而不是 exports

在下面的代码中,bar.js 引用了 square 模块,该模块输出了一个构造函数:

  1. const square = require('./square.js');
  2. var mySquare = <a href="http://man7.org/linux/man-pages/man2/square.2.html">square(2)</a>;
  3. console.log(`The area of my square is ${mySquare.area()}`);

square 模块定义在 square.js 中:

  1. // assigning to exports will not modify module, must use module.exports
  2. module.exports = (width) => {
  3. return {
  4. area: () => width * width
  5. };
  6. }

require('module') 定义了 Node.js 的模块系统。

访问主模块

由 Node.js 直接访问的入口文件也被叫做主模块,此时 require.main 就等于该模块。这也即是说,你可以通过以下代码测试当前文件是否是主文件:

  1. require.main === module

假设有一个 foo.js 文件,如果运行 node foo.js,那么上述代码就会返回 true;如果运行 require('./foo'),那么上述代码就会返回 false。

因为 module 提供了 filename 属性(通常等于 __filename),所以当前项目的入口文件可以通过 require.main.filename 获得。

包管理技巧

Node.js 内置的 require() 函数设计之初就是为了支持各种常规的目录结构。类似 dpkg/rpm/npm 的包管理器有助于开发者无需修改 Node.js 的模块即可构建本地的包。

下面我们将给出一个建设性的目录结构:

假设我们有一个文件夹 /usr/lib/node/<some-package>/<some-version>,用于包含指定版本的包。

包之间可以相互依赖。为了安装包 foo,开发者有可能需要安装指定版本的 barbar 又有可能有其他的依赖,这些依赖甚至会存在冲突或相互引用。

Node.js 首先会查找模块的 realpath(即遇到软链接会解析为真是链接),然后查找存储依赖的 node_modules 目录,具体的查找过程如下所以:

  1. /usr/lib/node/foo/1.2.3/,foo 包,版本为 1.2.3.
  2. /usr/lib/node/bar/4.3.2/,foo 的依赖包 bar
  3. /usr/lib/node/foo/1.2.3/node_modules/bar,软链接 /usr/lib/node/bar/4.3.2/ 解析后获得真实地址
  4. /usr/lib/node/bar/4.3.2/node_modules/*,bar 所以来的包的软链接

因此,即使遇到循环引用,或者依赖冲突,每一个模块都能得到可用的特定版本的依赖。

当开发者在 foo 中使用 require('bar') 请求加载 bar 后,系统会解析 bar 的软链接,获取真实的地址 /usr/lib/node/foo/1.2.3/node_modules/bar。然后如果在 bar 解析到了 require('quux'),那么系统会继续解析软链接拿到真实路径 /usr/lib/node/bar/4.3.2/node_modules/quux

此外,为了优化模块查找效率,我们最好将模块置于 /usr/lib/node_modules/<name>/<version> 而不是直接置于 /usr/lib/node

为了在 Node.js 的 REPL 中使用模块,最好将 /usr/lib/node_modules 添加给换进变量 $NODE_PATH。因为模块查找的 node_modules 文件夹使用的都是相对路径,且调用基于文件的真实路径执行 require(),所以包实际上可以置于任意位置。

通过 require() 加载的模块可以通过 require.resolve() 函数获取模块的真实路径。

下面是用伪代码演示的 require.resolve() 解析过程:

  1. require(X) from module at path Y
  2. 1. If X is a core module,
  3. a. return the core module
  4. b. STOP
  5. 2. If X begins with './' or '/' or '../'
  6. a. LOAD_AS_FILE(Y + X)
  7. b. LOAD_AS_DIRECTORY(Y + X)
  8. 3. LOAD_NODE_MODULES(X, dirname(Y))
  9. 4. THROW "not found"
  10. LOAD_AS_FILE(X)
  11. 1. If X is a file, load X as JavaScript text. STOP
  12. 2. If X.js is a file, load X.js as JavaScript text. STOP
  13. 3. If X.json is a file, parse X.json to a JavaScript Object. STOP
  14. 4. If X.node is a file, load X.node as binary addon. STOP
  15. LOAD_AS_DIRECTORY(X)
  16. 1. If X/package.json is a file,
  17. a. Parse X/package.json, and look for "main" field.
  18. b. let M = X + (json main field)
  19. c. LOAD_AS_FILE(M)
  20. 2. If X/index.js is a file, load X/index.js as JavaScript text. STOP
  21. 3. If X/index.json is a file, parse X/index.json to a JavaScript object. STOP
  22. 4. If X/index.node is a file, load X/index.node as binary addon. STOP
  23. LOAD_NODE_MODULES(X, START)
  24. 1. let DIRS=NODE_MODULES_PATHS(START)
  25. 2. for each DIR in DIRS:
  26. a. LOAD_AS_FILE(DIR/X)
  27. b. LOAD_AS_DIRECTORY(DIR/X)
  28. NODE_MODULES_PATHS(START)
  29. 1. let PARTS = path split(START)
  30. 2. let I = count of PARTS - 1
  31. 3. let DIRS = []
  32. 4. while I >= 0,
  33. a. if PARTS[I] = "node_modules" CONTINUE
  34. c. DIR = path join(PARTS[0 .. I] + "node_modules")
  35. b. DIRS = DIRS + DIR
  36. c. let I = I - 1
  37. 5. return DIRS

缓存

系统第一次加载模块时会缓存这些模块,这也就是说,每次调用 require('foo') 都会得到相同的返回对象。

缓存有一个很重要的特性的,那就是多次调用 require('foo') 并不会让该模块多次运行。根据该特性,当模块返回结束对象之后,即使其他依赖存在循环引用也无所谓。

如果你想多次调用模块的某块代码,最好的方法是从该模块向外输出一个函数,在外部多次调用该函数。

模块缓存警告

缓存是基于模块的文件名进行解析的,所以如果调用位置不同,加载的模块就有可能不同,也就是说在不同的文件中,无法保证 require('foo') 每次都返回相同的对象。

此外,在对大小写敏感的操作系统中,不同的文件名有可能指向相同的文件,但缓存仍将其视为不同的模块,继而会多次重载该模块。比如,require('./foo')require('./FOO') 会返回两个不同的对象,系统并不会检查 ./foo./FOO 是否会指向同一个文件。

核心模块

Node.js 内置了一些已经编译成二进制文件的模块,有关这些模块的详细介绍请参考本文的相应章节。

核心模块由 Node.js 源代码定义和实现,保存在 lib/ 文件夹中。

require() 函数总是优先加载核心模块,举例来说,即使存在 http 文件,require('http') 也总会返回一个 Node.js 内建的 HTTP 模块。

循环引用

require() 出现循环引用时,引用的模块内部可能尚未执行完就返回了值。

我们假设有三个文件,其中 a.js

  1. console.log('a starting');
  2. exports.done = false;
  3. const b = require('./b.js');
  4. console.log('in a, b.done = %j', b.done);
  5. exports.done = true;
  6. console.log('a done');

b.js

  1. console.log('b starting');
  2. exports.done = false;
  3. const a = require('./a.js');
  4. console.log('in b, a.done = %j', a.done);
  5. exports.done = true;
  6. console.log('b done');

main.js

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

在上面的代码中,main.js 引用了 a.jsa.js 引用了 b.js,当系统继续解析 b.js 时,发现 b.js 有引用了 a.js。为了避免无限循环引用,系统就会将一个 a.js 的不完全拷贝返回给 b.js 模块,然后 b.js 完成相应的解析,最后将 exports 对象提供给 a.js 模块。

main.js 加载这两个模块后完成相应的操作,输出结果如下:

  1. $ node main.js
  2. main starting
  3. a starting
  4. b starting
  5. in b, a.done = false
  6. b done
  7. in a, b.done = true
  8. a done
  9. in main, a.done=true, b.done=true

如果你的项目中存在模块的循环引用,建议据此解决。

文件模块

如果根据文件名没有找到模块,那么 Node.js 就会尝试使用不同的扩展名去加载模块,比如 .js.json,最后是 .node

.js 文件会被解析为 JavaScript 文本文件,.json 会被解析为 JSON 文本文件,.node 文件会被解析为被 dlopen 解析过的插件模块。

/ 开头的路径为文件的绝对路径,举例来说,对于 require('/home/marco/foo.js'),系统会查找 /home/marco/foo.js

./ 开头的路径为文件的相对路径,表示相对于当前文件所在的目录,举例来说,对于 foo.js 文件中的 require('./circle'),系统会在 foo.js 所在目录下查找 cicle.js

对于不以 /./../ 开头的路径,系统会从核心模块或 node_modules 目录查找模块。

如果指定的路径不存在,require() 会抛出一个 Error 实例,该实例具有一个值为 MODULE_NOT_FOUNDcode 属性。

文件夹即模块

将程序和依赖打包到同一个文件夹内并提供一个入口文件,是一种非常便捷的打包方式。这里有三种方式使用 require() 加载此类模块。

第一种方式是在根目录创建一个 package.json 文件,用于指定 main 模块:

  1. {
  2. "name" : "some-library",
  3. "main" : "./lib/some-library.js"
  4. }

如果该模块位于 ./some-library,那么 require('./some-libaray') 就会尝试加载 ./some-library/lib/some-library.js

Node.js 可以正确解析 package.json 配置文件。

如果模块的根目录下没有 package.json,Node.js 就会尝试加载根目录下的 index.jsindex.node 文件。举例来说,在某个模块的根目录下没有 package.json,那么 require('./some-library') 就会尝试加载:

  • ./some-library/index.js
  • ./some-library/index.node

从 node_module 加载模块

如果传给 require() 的不是一个原生模块,且不以 /./../ 开头,那么 Node.js 就会从当前模块的父级目录查找 node_modules,并尝试从 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

这种方式有助于限制依赖的作用范围,避免冲突。

开发者还可以通过添加后缀加载指定的文件或子模块,举例来说,require('example-module/path/to/file') 会加载 example-module 模块下的 path/to/file 文件。模块后缀的路径解析方式和上述模块路径的惊喜方式一致。

从全局文件夹加载

Node.js 如果在上述所有地方都找不到模块的话,就会检索环境变量 NODE_PATH 下是否存在,在大多数的系统中,NODE_PATH 中的路径以冒号分隔,而在 Windows 中,NODE_PATH 以逗号分隔。

创建环境变量 NODE_PATH 的本意是在上述的模块检索算法失效后检索更多的 Node.js 路径。

虽然现在 Node.js 还支持环境变量 NODE_PATH,但该变量已经越来越不重要了,这是因为 Node.js 圈约定俗成的使用本地存放依赖模块。如果模块和 NODE_PATH 绑定的话,会让那些不知道 NODE_PATH 的用户茫然不知所措。此外,当模块的依赖关系发生变化时,那么检索 NODE_PATH 就会加载不同版本的模块,甚至是与预期完全不同的模块。

除了 NODE_PATH,Node.js 还会检索以下位置:

  1. $HOME/.node_modules
  2. $HOME/.node_libraries
  3. $PREFIX/lib/node

这里的 $HOME 是用户的主页目录,$PREFIX 是 Node.js 配置的 node_prefix

上述处理方式大都是由于历史原因形成的。强烈建议开发者将依赖存储于 node_modules 文件夹,这将有助于提高系统的解析速度,增强模块的稳定性。

module 对象

  • 对象

在每一个模块中,变量 module 就是一个引用当前模块的对象。为了简便起见,可以使用 exports 替代 module.exportsmodule 实际上并不是一个全局对象,而是存在于每一个模块的本地变量。

module.children

  • 数组

该属性的值包含了当前模块所加载的 module 对象。

module.exports

  • 对象

module.exports 对象由模块系统所创建。很多开发者想要让模块是类的实例,那么就可以将期望的对象赋值给 module.exports。注意,如果赋值给了 exports,实际上只是简单地重新绑定到了本地的 exports 变量,这有可能会发生意料之外的结果。

举例来说,假设我们有一个模块 a.js

  1. const EventEmitter = require('events');
  2. module.exports = new EventEmitter();
  3. // Do some work, and after some time emit
  4. // the 'ready' event from the module itself.
  5. setTimeout(() => {
  6. module.exports.emit('ready');
  7. }, 1000);

在其他文件中加载该模块:

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

注意,module.exports 的赋值语句必须立即执行,而不能将其置于任何的回调函数之中。在下面的情况下,module.exports 的赋值操作是无效的:

  1. // x.js
  2. setTimeout(() => {
  3. module.exports = { a: 'hello' };
  4. }, 0);

加载 x.js

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

exports 和 module.exports

模块中的变量 exports 最开始的时候是对 module.exports 的引用。如果开发者给 exports 替换成了对其他变量的引用,那么两者之间就没有任何关系了:

  1. function require(...) {
  2. // ...
  3. ((module, exports) => {
  4. // Your module code here
  5. exports = some_func; // re-assigns exports, exports is no longer
  6. // a shortcut, and nothing is exported.
  7. module.exports = some_func; // makes your module export 0
  8. })(module, module.exports);
  9. return module;
  10. }

如果你无法理解 exportsmodule.exports 之间的关系,建议你忽略 exports,一切都使用 module.exports

module.filename

  • 字符串

该属性表示模块解析后的文件名。

module.id

  • 字符串

该属性表示模块的标识符,通产来说就是模块解析后的文件名。

module.loaded

  • 布尔值

该属性表示模块是已经加载完成还是处于加载过程中。

module.parent

  • 对象,模块对象

该属性表示第一个加载该模块的文件。

module.require(id)

  • id,字符串
  • 返回值类型:对象,模块解析后返回的 module.exports

module.require() 方法提供了一种类似原始模块调用 require() 的模块加载方式。

注意,开发者必须获得 module 对象的引用才可以这么做。因为 require() 需要返回 module.exports,而 module 只代表特定的模块,所以必须显式导出 module 才能使用它。