在计算机程序的开发过程中,随着程序代码越写越多,在一个文件里代码就会越来越长,越来越不容易维护。为了编写可维护的代码,我们把很多函数分组,分别放到不同的文件里,这样,每个文件包含的代码就相对较少,很多编程语言都采用这种组织代码的方式。
在Node环境中,一个.js文件就称之为一个模块(module),有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。Node 应用由模块组成,采用
CommonJS 模块规范。
1、使用模块有什么好处?
最大的好处是大大提高了代码的可维护性。其次,编写代码不必从零开始。当一个模块编写完毕,就可以被其他地方引用。我们在编写程序的时候,也经常引用其他模块,包括Node内置的模块和来自第三方的模块。
使用模块还可以避免函数名和变量名冲突。相同名字的函数和变量完全可以分别存在不同的模块中,因此,我们自己在编写模块时,不必考虑名字会与其他模块冲突。
// example.js var x = 5; var addX = function (value) { return value + x; };
上面代码中,变量x和函数addX,是当前文件example.js私有的,其他文件不可见。如果想在多个文件分享变量,必须定义为
global对象的属性。
global.warning = true;
2、模块的基本使用
CommonJS规范规定,每个模块内部,
module变量代表当前模块。这个变量是一个对象,它的
exports属性(即
module.exports)是对外的接口。加载某个模块,其实是加载该模块的
module.exports属性。
'use strict';var s = 'Hello';function greet(name) { console.log(s + ', ' + name + '!');}module.exports = greet;
函数greet()是我们在hello模块中定义的,然后通过
module.exports接口把函数greet作为模块的输出暴露出去,这样其他模块就可以使用greet函数了。
require方法用于加载模块。我们再编写一个main.js文件,调用hello模块的greet函数:
'use strict';// 引入hello模块:var greet = require('./hello');var s = 'Michael';greet(s); // Hello, Michael!
注意到引入hello模块用Node提供的require函数:
var greet = require('./hello');
引入的模块作为变量保存在greet变量中,那greet变量到底是什么东西?其实变量greet就是在hello.js中我们用
module.exports = greet;输出的greet函数。所以,main.js就成功地引用了hello.js模块中定义的greet()函数,接下来就可以直接使用它了。
3、module对象
Node内部提供一个
Module构建函数。所有模块都是Module的实例。
function Module(id, parent) { this.id = id; this.exports = {}; this.parent = parent; // ...
每个模块内部,都有一个
module对象,代表当前模块。它有以下属性。
- module.id 模块的识别符,通常是带有绝对路径的模块文件名。
- module.filename 模块的文件名,带有绝对路径。
- module.loaded 返回一个布尔值,表示模块是否已经完成加载。
- module.parent 返回一个对象,表示调用该模块的模块。
- module.children 返回一个数组,表示该模块要用到的其他模块。
- module.exports 表示模块对外输出的值。
例如:
// example.js var jquery = require('jquery'); exports.$ = jquery; console.log(module);
输出的module:
{ id: '.', exports: { '$': [Function] }, parent: null, filename: '/path/to/example.js', loaded: false, children: [ { id: '/path/to/node_modules/jquery/dist/jquery.js', exports: [Function], parent: [Circular], filename: '/path/to/node_modules/jquery/dist/jquery.js', loaded: true, children: [], paths: [Object] } ], paths: [ '/home/user/deleted/node_modules', '/home/user/node_modules', '/home/node_modules', '/node_modules' ] }
如果在命令行下调用某个模块,比如
node something.js
,那么
module.parent
就是
null
。如果是在脚本之中调用,比如
require('./something.js')
,那么
module.parent
就是调用它的模块。利用这一点,可以
判断当前模块是否为入口脚本
。
if (!module.parent) { // ran with `node something.js` app.listen(8088, function() { console.log('app listening on port 8088'); }) } else { // used with `require('/.something.js')` module.exports = app; }
module.exports属性
module.exports
属性表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取
module.exports
变量。
exports变量
为了方便,Node为每个模块提供一个exports变量,指向module.exports。这等同在每个模块头部,有一行这样的命令。
var exports = module.exports;
所以,在对外输出模块接口时,也可以向exports对象添加方法。
exports.area = function (r) { return Math.PI * r * r; }; exports.circumference = function (r) { return 2 * Math.PI * r; };
注意,不能直接将exports变量指向一个值,因为这样等于切断了
exports
与
module.exports
的联系。
exports = function(x) {console.log(x)};
上面这样的写法是无效的,因为
exports
不再指向
module.exports
了。
下面的写法也是无效的。
exports.hello = function() { return 'hello'; }; module.exports = 'Hello world';
上面代码中,
hello
函数是无法对外输出的,因为
module.exports
被重新赋值了。
这意味着,如果一个模块的对外接口,就是一个单一的值,不能使用
exports
输出,只能使用
module.exports
输出。
如果你觉得,
exports
与
module.exports
之间的区别很难分清,一个简单的处理方法,就是放弃使用
exports
,只使用
module.exports
。
4、require命令
4.1 加载规则(详见node加载模块顺序)
4.2 模块的缓存
第一次加载某个模块时,Node会缓存该模块。require命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个module对象,以后再加载该模块,就直接从缓存取出对应的module对象,然后获取
module.exports
属性。
{ //module对象 id: '...', exports: { ... }, loaded: true, ... }
即使再次执行require命令,也不会再次执行该模块,而是到缓存之中取值。也就是说,
CommonJS 模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。
所有缓存的模块保存在
require.cache
之中,如果想删除模块的缓存,可以像下面这样写。
// 删除指定模块的缓存 delete require.cache[moduleName]; // 删除所有模块的缓存 Object.keys(require.cache).forEach(function(key) { delete require.cache[key]; })
注意,
缓存是根据绝对路径识别模块的
,如果同样的模块名,但是保存在不同的路径,
require
命令还是会重新加载该模块。
4.3 require.main
require
方法有一个
main
属性,可以用来判断模块是直接执行,还是被调用执行。
直接执行的时候(
node module.js
),
require.main
属性指向模块本身。
require.main === module // true
调用执行的时候(通过
require
加载该脚本执行),上面的表达式返回false。
5、模块的加载机制
CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
// lib.js var counter = 3; function incCounter() { counter++; } module.exports = { counter: counter, incCounter: incCounter, };
// main.js var mod = require('./lib'); console.log(mod.counter); // 3 mod.incCounter(); console.log(mod.counter); // 3
上面代码说明,lib.js模块加载以后,它的内部变化就影响不到输出的mod.counter了
(调用mod.incCounter()方法时,操作的是lib.js模块中的变量counter,而不是导出的变量counter)
。这是因为mod.counter是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。
// lib.js var counter = 3; function incCounter() { counter++; } module.exports = { get counter() { return counter }, incCounter: incCounter, };
上面代码中,输出的counter属性实际上是一个取值器函数。现在再执行main.js,就可以正确读取内部变量counter的变动了。
上面代码中,输出的counter
属性实际上是一个取值器函数。现在再执行main.js
,就可以正确读取内部变量counter
的变动了。
5.1 require的内部处理流程
require
命令是CommonJS规范之中,用来加载其他模块的命令。它其实不是一个全局命令,而是指向当前模块的
module.require
命令,而后者又调用Node的内部命令
Module._load
。
Module._load = function(request, parent, isMain) {};
在这个函数中需要执行以下几步:
1. 检查 Module._cache,是否缓存之中有指定模块
2. 如果缓存之中没有,就创建一个新的Module实例
3. 将它保存到缓存
4. 使用 module.load() 加载指定的模块文件,读取文件内容之后,
使用 module.compile() 执行文件代码
5. 如果加载/解析过程报错,就从缓存删除该模块
6. 返回该模块的 module.exports
采用
module.compile()
执行指定模块的脚本,逻辑如下。
Module.prototype._compile = function(content, filename) {};
在该函数中需要执行以下几步:
1. 生成一个require函数,指向module.require;
2. 加载其他辅助方法到require;
3. 一旦require函数准备完毕,整个所要加载的脚本内容,就被放到一个新的函数之中,这样可以避免污染全局环境。该函数的参数包括require、module、exports,以及其他一些参数。
(function (exports, require, module, __filename, __dirname) { // YOUR CODE INJECTED HERE! // 执行读取的hello.js代码: function greet(name) { console.log('Hello, ' + name + '!'); } module.exports = greet; // hello.js代码执行结束 });
4. 执行该函数
上面的第1步和第2步,require函数及其辅助方法主要如下。
- require(): 加载外部模块
- require.resolve():将模块名解析到一个绝对路径
- require.main:指向主模块
- require.cache:指向所有缓存的模块
- require.extensions:根据文件的后缀名,调用不同的执行函数
Module._compile方法是同步执行的,所以Module._load要等它执行完成,才会向用户返回module.exports的值。
5.2 模块的循环加载
CommonJS 模块的重要特性是加载时执行,即脚本代码在require的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。
我们来看一段代码:
// main.js var a = require('./a.js'); var b = require('./b.js'); console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);
// a.js exports.done = false; var b = require('./b.js'); console.log('在 a.js 之中,b.done = %j', b.done); exports.done = true; console.log('a.js 执行完毕');
//b.js exports.done = false; var a = require('./a.js'); console.log('在 b.js 之中,a.done = %j', a.done); exports.done = true; console.log('b.js 执行完毕');
当第一次require('a.js')时,执行a.js脚本。先输出一个done变量,然后加载另一个脚本文件b.js。注意,此时a.js代码就停在这里,等待b.js执行完毕,再往下执行。
b.js执行到第二行,就会去加载a.js,这时,就发生了“循环加载”。系统会去a.js模块对应对象的exports属性取值,可是因为a.js还没有执行完,从exports属性只能取回已经执行的部分,而不是最后的值。a.js已经执行的部分,只有一行。
exports.done = false;
因此,对于b.js来说,它从a.js只输入一个变量done,值为false。
然后,b.js接着往下执行,等到全部执行完毕,再把执行权交还给a.js。于是,a.js接着往下执行,直到执行完毕。
最后输出的结果:
$ node main.js 在 b.js 之中,a.done = false b.js 执行完毕 在 a.js 之中,b.done = true a.js 执行完毕 在 main.js 之中, a.done=true, b.done=true
上面的代码证明了两件事。一是,在b.js之中,a.js没有执行完毕,只执行了第一行。二是,main.js执行到第二行时,不会再次执行b.js,而是输出缓存的b.js的执行结果,即它的第四行
exports.done = true;
5.3 Node.js是如何实现模块
Node实现“模块”功能的奥妙在于JavaScript是一种函数式编程语言,它支持闭包。如果我们把一段JavaScript代码用一个函数包装起来,这段代码的所有“全局”变量就变成了函数内部的局部变量。
请注意我们编写的hello.js代码是这样的:
var s = 'Hello'; var name = 'world'; console.log(s + ' ' + name + '!');
Node.js加载了hello.js后,它可以把代码包装一下,变成这样执行:
(function () { // 读取的hello.js代码: var s = 'Hello'; var name = 'world'; console.log(s + ' ' + name + '!'); // hello.js代码结束 })();
这样一来,原来的全局变量s现在变成了匿名函数内部的局部变量。如果Node.js继续加载其他模块,这些模块中定义的“全局”变量s也互不干扰。所以,Node利用JavaScript的函数式编程的特性,轻而易举地实现了模块的隔离。