在 CommonJS 规范中,一个文件就可以作为一个独立的模块,有自己的作用域,在这个文件内部定义的变量、函数等,都只属于这个模块,对其他模块是不可见的。如果想要其他模块能使用其内部的变量,就需要使用 module.exports 导出,然后在其他模块中使用 require()导入。
因为 Node 就是 CommonJS 规范的一种具体实现,以下我们主要使用 Node 的 CommonJS 模块来讲解 CommonJS 规范下模块化的思维。
一、常见问题解析
这里我们先抛出一些问题,然后跟着问题,再一步步去解析 Node 的 CommonJS 模块机制:
1. Node中的CommonJS模块是啥,一个模块中包含哪些信息?
2. exports和module.exports有什么联系和区别?
3. 怎么判断当前模块是根模块还是子模块?
4. 多次引入同一个模块时,这个模块内部的代码是否会多次执行?
5. 两个模块循环引用时,是否会造成死循环?
6. 模块的引入是同步的还是异步的?
7. require默认支持导入哪些文件类型?
8. 模块中的exports、require、module、__filename、__dirname从哪儿来,为什么可以不定义就直接用?
9. 为什么说模块内部的变量对其他模块不可见?
10. 调用require(id)时,传入的id的解析规则?
1. CommonJS 模块(module)
Node 中,有一个构造函数:Module
,我们所用的每一个模块,其实都是由这个构造函数 new 出来的一个module 实例,用变量module
表示。Module 构造函数的源码如下:
function Module(id = "", parent) {
// id通常传入的都是文件的绝对路径
this.id = id;
this.path = path.dirname(id);
this.exports = {
};
// 这个 updateChildren 的作用,就是把创建出来的模块实例,添加到父模块的children列表中
updateChildren(parent, this, false);
this.filename = null;
this.loaded = false;
this.children = [];
}
另外 Module 构造函数的原型上还有 3 个方法:
// 1. require:用于引入模块的
Module.prototype.require = function(id) {
};
// 2. load:用于加载模块的
Module.prototype.load = function(filename) {
};
// 3. _compile:用于编译、执行模块代码的
Module.prototype._compile = function(content, filename) {
};
稍后我们会详解这 3 个方法。我们可以看到,当调用new Module()
时,实例化出来的 module 模块,其结构大致如下:
module = {
id: ".", // 模块id,通常为文件的绝对路径,根模块会被重置为'.'
path: "", // 模块所在文件夹的路径
exports: {
}, // 导出的模块值,初始值为{}
parent: {
}, // 父模块(Node文档中说已弃用,不过实际的值还是可以获取到)
filename: "", // 模块文件的绝对路径(这个属性值是等到加载模块的时候添加上来的)
loaded: false, // 标识当前模块是否已经加载完毕
children: [], // 子模块列表
// paths的值,是从当前文件夹下,依次往上遍历,直到根目录,每级目录下的node_modules目录。
//(当require(path)传入的是第三方模块的时候会用到,这个属性值也是在等到加载模块的时候添加上来的)
paths: [
// 'D:\\WEB_NOTES\\modules\\Commonjs\\node_modules',
// 'D:\\WEB_NOTES\\modules\\node_modules',
// 'D:\\WEB_NOTES\\node_modules',
// 'D:\\node_modules'
],
__proto__: {
require: function(id) {
},
load: function(filename) {
},
_compile: function(content, filename) {
},
},
};
我们可以看到,在这个实例上,有一个exports
属性,初始值为空对象,module.exports
的值,就是我们最终导出的模块,然后在其他模块中使用require()
导入时,module.exports
就会被作为 require 函数的返回值返回出去。如下:
// moduleA.js
// 在此文件中导出模块
module.exports = {
name: "I am moduleA",
};
// index.js
// 在此文件中引入模块moduleA
const moduleA = require("./moduleA.js");
// 输出模块moduleA的值,我们可以看到moduleA的值就是我们在moduleA.js中导出的对象
console.log("moduleA: ", moduleA); // moduleA: { name: 'I am moduleA' }
2. module.exports 和 exports
用过 Node 的人,对这 2 个 api 应该都不会陌生,在实际使用场景中,这 2 个 api 的使用方式也是很容易混的,稍不注意就可能用错了,那么这 2 个 api 究竟有什么联系和区别呢?
其实在 Node 的源码中,有几行代码能很好的解释他们的关系:
// 1. 用 = 赋值,使 exports 成为 module.exports 的一份副本
// 其中`this`就是我们的 module 实例
const exports = this.exports; // 等价于 const exports = module.exports;
// 2. require函数的返回值如下(这里有做逻辑上的简化处理)
const require = function(id) {
// ...
return module.exports;
};
从上面我们就可以很清晰的看到: exports
与module.exports
都指向同一个地址,他们的初始值为一个空对象,但是要注意最终返回的是module.exports
,这也就意味着实际使用场景中会有如下几个需要注意的地方:
- 因为 exports 与 module.exports 都是对象,所以我们使用
exports.key = value
和module.exports.key = value
的形式给导出模块赋值,效果都是等价的,
// moduleA.js
module.exports.name = "I am moduleA";
exports.age = 20;
// index.js
const moduleA = require("./moduleA.js");
console.log("moduleA: ", moduleA); // moduleA: { name: 'I am moduleA', age: 20 }
我们可以看到,name 和 age 的值都正常导出了。
- 因为
require
函数最终返回的是module.exports
的值,所以如果我们使用了module.exports = newValue
的形式给其重新赋了值,就会导致module.exports
和exports
的联系断掉。这时,如果我们还使用了exports.key = value
的形式给导出模块赋值,就会导致exports.key
的值丢失:
// moduleA.js
exports.age = 20;
module.exports = {
score: 100,
};
// index.js
const moduleA = require("./moduleA.js");
console.log("moduleA: ", moduleA); // moduleA: { score: 100 }
所以,如果我们要使用module.exports = newValue
的形式导出模块,就不要再使用exports
。
- 不能使用
exports = value
的形式导出模块,不然也会导致module.exports
和exports
的联系断掉,致使exports = value
的 value 值丢失:
// moduleA.js
exports = 20;
module.exports.score = 100;
// index.js
const moduleA = require("./moduleA.js");
console.log("moduleA: ", moduleA); // moduleA: { score: 100 }
3. 怎么判断当前模块是根模块还是子模块?
在 require 函数上,有一个main
属性,它指向了当前模块引用链上的根模块,通过require.main === module
来判断,如果当前模块是根模块则返回 true,子模块返回 false。
4. 多次引用同一个模块(模块缓存)
在模块的引用机制内部,当一个模块成功加载一次之后,就会被写入缓存,具体缓存信息,可以打印require.cache
查看;第二次加载的时候,就会直接从缓存读取数据,而不会再次加载、执行模块内部的代码。所以,多次引入同一个模块时,这个模块内部的代码不会多次执行。如下:
// moduleA.js
module.exports.time = Date.now();
// index.js
const moduleA1 = require("./moduleA");
console.log("moduleA1 = ", moduleA1);
console.log("require.cache = ", require.cache);
const moduleA2 = require("./moduleA");
console.log("moduleA2 = ", moduleA2);
// 上面的打印结果输出如下:
// console.log("moduleA1 = ", moduleA1);
moduleA1 = {
time: 1606017634808 }
// console.log("require.cache = ", require.cache);
require.cache: {
'E:\\WEB_NOTES\\modules\\Commonjs\\index.js': {
id: '.',
path: 'E:\\WEB_NOTES\\modules\\Commonjs',
exports: {
},
parent: null,
filename: 'E:\\WEB_NOTES\\modules\\Commonjs\\index.js',
loaded: false,
children: [ [Module] ],
paths: [
'E:\\WEB_NOTES\\modules\\Commonjs\\node_modules',
'E:\\WEB_NOTES\\modules\\node_modules',
'E:\\WEB_NOTES\\node_modules',
'E:\\node_modules'
]
},
'E:\\WEB_NOTES\\modules\\Commonjs\\moduleA.js': {
id: 'E:\\WEB_NOTES\\modules\\Commonjs\\moduleA.js',
path: 'E:\\WEB_NOTES\\modules\\Commonjs',
exports: {
time: 1606017634808 },
parent: Module {
id: '.',
path: 'E:\\WEB_NOTES\\modules\\Commonjs',
exports: