Node.js实现了一个简单的模块加载系统。在Node.js中,文件和模块是一一对应的关系,可以理解为一个文件就是一个模块。其模块系统的实现主要依赖于全局对象module
,其中实现了exports
(导出)、require()
(加载)等机制。
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
两个方法,这两个方法可以其它模块中调用。
exports
与module.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中,该函数的语义被设计为足够的通用化,以支持各种常规的目录结构。这样,dpkg
、rpm
和npm
将不用于修改模块就能构建本地包。
以下是Node.js官方给出的一个目录结构建议:
假设,我们希望将一个包的指定版本放在如下目录中:
/usr/lib/node/<some-package>/<some-version>
包可能会依赖其它包。为了安装foo
包,可能需要安装指定版本的bar
包。而bar
可能也存在依赖关系,在某些情况下依赖关系可能会发生冲突或者形成循环依赖。
因为Node.js会解析加载模块的realpath
(真实路径),并在node_modules
中查找模块的依赖关系。这可能会和以下情形比较相似:
/usr/lib/node/foo/1.2.3/
-foo
包1.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 copy
给b.js
。然后b.js
就会停止加载,并将其exports
对象返回给a.js
模块。
这样main.js
就完成了a.js
、b.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.js
中require('./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.js
或index.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()
的功能,可以从最初的模块加载一个模块