npm 模块加载机制详解

  • 更新时间:3年零223天前
  • 浏览量:4619
  • 发布人:思过崖

模块系统

Node.js有一个简单的模块加载系统。在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() 和 函数circumference(). 为了在你的入口模块中添加函数和对象,你可以添加它们到指定的导出对象中去。

由于模块都被function包裹着,因此模块中的本地变量都是私有的。在这个例子中,变量PI对于circle.js来说是不可见的。

如果你想让你模块导出的是一个函数[例如构造函数]或者是一个完整的对象,而不是一个属性,那么请使用module.exports而不是exports。

如下,bar.js引用了一个会导出构造函数的square模块。

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

在square.js定义的square模块:

  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来运行的时候,require.main指向的是它对应的模块。这也意味着你可以通过以下代码来判断文件是否被直接运行

  1. require.main === module

对于foo.js文件来说, 如果通过node foo.js来运行,那么返回的结果就为true,而如果通过require(‘./foo’)的方式来运行,那么返回的结果就为false。

由于每个模块都提供了一个文件名属性(一般来说,相当于__filename),因此我们能够通过检查require.main.filename来获取当前应用的入口文件。

备注:包管理建议

Node.js中通用性极强的require()函数可以兼容大量常见的目录结构规范。
例如dpkg,rpm以及npm都期望无需修改,即可从Node.js模块系统中构建原生的程序安装包。

下面,我们给出了关于目录结构规范的建议:

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

一个包也能够依赖于其他的包。为了安装foo包,你可能也必须安装指定版本的bar包,然而bar包自身可能也需要依赖。因此,在某些场合下,这些依赖可能会冲突或陷入死循环。

既然Node.js会检查它所要加载的任何一个包的真实路径,然后检查包之间的依赖关系,因此通过以下的结构进行布局,上述所说的问题是非常容易被解决的。

  • /usr/lib/node/foo/1.2.3/ - 1.2.3版本的foo包
  • /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包。
然后当bar包中的代码require(‘quux’)时,它将会加载/usr/lib/node/bar/4.3.2/node_modules/quux中的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()代码所在文件的绝对路径来定位进行包查找。因此包本身是可以存放在任何位置的。

混合模式

为了确定通过require()函数来加载时,调用的明确的文件名,我们可以使用
require.resolve()函数。

能将上述的方式搭配利用,需要的就是require.resolve方法体现出来的高水平算法。

文件的路径为Y,require(x):

  1. 如果X是核心模块
    a. 返回核心模块
    b. 结束代码
  2. 如果 X 以 ‘./‘ 或 ‘/‘ 或 ‘../‘开始
    a. 以文件的形式加载(Y + X)
    b. 以目录的形式加载(Y + X)
  3. 以NODE_MODULES的形式来加载(X, dirname(Y))
  4. 抛出异常 “not found”

以文件的形式加载的具体说明

1.如果存在文件x,那么就把x以javascript文本的形式来加载
1.如果存在文件x.js,那么就把x.js以javascript文本的形式来加载
3.如果存在文件x.json, 那么就解析 x.json 为一个 JavaScript 对象
4.如果存在文件x.node, 那么就把 x.node 作为一个二进制文件来加载

以目录的形式加载的具体说明

  1. 如果存在文件 X/package.json,
    a. 解析 X/package.json, 然后寻在main字段.
    b. let M = X + (json 的main字段)
    c. 以文件的形式加载(M)
  2. 如果存在文件X/index.js, 把 X/index.js 以javascript文本的形式来加载
  3. 如果存在文件X/index.json, 解析 X/index.json 为一个 JavaScript 对象
  4. 如果存在文件X/index.node, 把 X/index.node 作为一个二进制文件来加载

以NODE_MODULES的形式来加载(X, START)的具体说明

  1. let DIRS=NODE_MODULES_PATHS(START)
  2. for each DIR in DIRS:
    a. 以文件的形式加载(DIR/X)
    b. 以目录的形式加载(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

Caching

模块在每一次加载之后都会被缓存起来。这也就意味着在每一次使用require(‘foo’)时,返回的都是同一个对象,即使文件随后被修改过。

多次执行require(‘foo’)并不会导致模块代码被执行多次,这是一个很重要的功能。

而如果你确实是想让一个模块的代码被执行多次,那么就导出一个函数,然后多次调用那个函数。

模块缓存注意事项

模块缓存基于它们的resolved filename。由于被调用的模块的位置[从node_modules文件夹中加载]不同,所以模块可能会被解析为不同的文件名。
如果解析的结果是不同的文件,但却总是返回相同的对象,这并不是我们所希望的结果。

此外,在大小写不敏感的文件系统或操作系统上,不同的文件名可能会指向相同的文件,但缓存系统依旧会将它们视为不同的模块,并多次加载文件。例如,require(‘./foo’) 和 require(‘./FOO’)不管./foo或./FOO是否为同一个文件,都将会返回两个不同的对象。

核心模块

Node.js 中内置了一系列模块。这些模块在Node.js源码中定义,位于lib/文件夹中。

如果存在和内置模块名称相同的文件,那么通过require()方式来加载的时候,那么优先加载的将是内置模块。

递归

如果require()被循环调用,那么在return之前,将不会结束代码的执行。

考虑到以下情况:

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.js, 然后 a.js 接着加载 b.js. 接着, b.js 又尝试去加载 a.js。为了避免这种死循环, a.js 的样本返回了一个导出的对象给b.js 模块。然后 b.js 结束了加载, 导出了一个对象给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的文件。

如果要加载的模块路径以’./‘为前缀,那么就是文件的相对路径。

而如果模块的名称没有以’/‘, ‘./‘, 或 ‘../‘开头,那么该模块不是核心模块就是node_modules文件夹下的模块。

而如果给定的路径并不存在,那么require()就会抛出错误:MODULE_NOT_FOUND;

文件夹模块

将程序或代码库组织到一个私有的文件夹中,然后提供一个单一的入口点将是很便捷的,有三种方式可以将文件夹作为参数传递到require()函数中。

第一种就是在根目录下创建一个指定了main模块的 package.json文件。package.json文件有点像下面这样:

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

如果该文件并不位于./some-library下的文件夹, 那么 require(‘./some-library’) 将会试图去加载 ./some-library/lib/some-library.js.

这就是Node.js从package.json文件中识别出来的范围。

注意:如果package.json中main定义的入口文件不存在或不能解析成其他的文件,那么Node.js将会报出以下错误:

Error: Cannot find module ‘some-library’

如果在目录中并不存在package.json文件,那么Node.js将会试图去加载那个目录中的index.js或index.node文件。如果在上述的案例中找不到package.json文件,那么require(‘./some-library’)将会试图去加载:

  1. ./some-library/index.js
  2. ./some-library/index.node

从node_modules文件夹中去加载

如果传递给require()函数的模块识别符既不是原生模块,也没有以’/‘, ‘../‘, 或 ‘./‘开始, 那么 Node.js starts 将会尝试在当前模块的“父目录/node_modules”位置处进行加载。

如果在该位置依旧没有找到,那么将会不断的查找上一级目录,直到查找到文件系统的根目录。

例如,如果在’/home/ry/projects/foo.js’文件中require(‘bar.js’),那么Node.js依此要查找的位置是以下这些:

  1. /home/ry/projects/node_modules/bar.js
  2. /home/ry/node_modules/bar.js
  3. /home/node_modules/bar.js
  4. /node_modules/bar.js

这可以使程序的依赖局部化,防止它们互相冲突。

你可以通过在模块名称后面包含一个路径前缀来加载一个指定文件或模块衍生出来的子模块。例如,require(‘example-module/path/to/file’)将会解析example-module相对位置处的resolve path/to/file。前缀路径同样遵循模块加载的语法。

从全局文件夹中进行加载

如果NODE_PATH环境变量被设置为通过冒号来连接的绝对路径的列表,而且根据上述的搜索规则,从任何地方都查找不到相应的模块,那么Node.js将会从这些绝对路径中搜索模块。(注意在window系统中,NODE_PATH变量中的路径是以分号来连接的)

尽管目前NODE_PATH功能依旧被支持,但由于Node.js已经为局部的依赖模块建立了一个比较完善的生态系统,因此这种功能不建议再使用。

有的时候,如果开发人员没有意识到自己必须要设置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文件夹中,这将会使加载更快更可靠。

模块对象

{Object}
在每一个模块中,模块中的变量对于当前模块的对象来说都是可用的。
通过导出一个全局化的模块,module.exports是可以获取到的。
一个模块对于其他模块来说,既是局部也是全局的。

module.children

<Array>
被这个模块加载的模块对象

module.exports

<Object>
由模块系统创建的module.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会立即自执行,这在任何回调中使用都是无效的:

x.js:

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

y.js:

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

exports alias

模块中的变量在module.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. }

个人建议,如果你总是很困惑exports与module.exports的区别,那么请忽视exports,仅仅使用module.exports方法。

module.filename

<String>
完整的模块文件名称

module.id

<String>
模块识别符,通常是完整的模块文件名称

module.loaded

<Boolean>
模块加载状态

module.parent

<Object> Module object
这个模块加载的第一个模块

module.require(id)

id <String>
Return: <Object> 解析到的模块module.exports的对象