一、Node.js 中的 CommonJS 模块化规范
在 Node.js 环境中,CommonJS 被广泛用作其模块化的规范标准。这一规范在组织和管理代码方面发挥着至关重要的作用,为开发者提供了一种清晰、高效的方式来构建复杂的应用程序。
根据 CommonJS 的模块化规范,每一个单独的 JavaScript 文件都可以被视为一个独立的模块。每个模块都拥有自己独特的作用域,这意味着在一个模块内部定义的变量,无法直接在其他模块中被读取和访问。这种设计有助于提高代码的封装性和可维护性,避免了变量名冲突等常见问题。
如果想要在不同的模块之间进行有效的通信和数据共享,就需要借助 CommonJS 提供的模块导出与导入语法。通过这种方式,各个模块可以选择性地将自己的部分功能暴露给其他模块,同时也可以引入其他模块提供的功能,从而实现代码的复用和协同工作。
二、模块的导出与导入机制
在 CommonJS 规范中,模块可以通过变量exports向外暴露自己的应用程序编程接口(API)。需要特别注意的是,exports只能是一个对象,这个对象的属性就是该模块向外暴露的 API。例如,在一个模块中,可以定义一些常量或函数,并将它们添加到exports对象中,以便其他模块可以访问这些功能。
在另一个模块中,可以使用全局函数require来引入其他模块导出的exports对象。这样,就可以在当前模块中使用被引入模块提供的功能。基本语法如下所示:
假设在a.js文件中,我们想要导出一个包含name和add属性的对象。其中,name是一个常量,add是一个函数。
const name = 'Jack';
const add = (a, b) => a + b;
module.exports = {
name,
add
};
在b.js文件中,可以通过require函数引入a.js模块导出的对象。
const api = require('./a');
console.log(api);
// { name: 'Jack', add: [Function: add] }
这里需要注意的是,引入模块时使用的变量名可以随意取,不一定非得是api。通常情况下,也可以通过解构赋值的形式,根据实际需求有选择地进行导入。例如:
const { name } = require('./a');
console.log(name);
// Jack
三、多次导入的行为分析
接着上述例子,如果在b.js文件中多次导入a模块,会发生什么情况呢?两次导入的会是同一个对象吗?让我们通过代码来进行验证。
const api1 = require('./a');
const api2 = require('./a');
console.log(api1 === api2);
// true
如上述代码所示,在b.js中两次引入a模块,并判断两个导入的变量是否相等。结果输出为true,这表明如果多次引用同一个模块,实际上导入的是同一个对象。
实际上,在第一次导入某个模块时,Node.js 会执行要导入的文件,并在内存中缓存一个对象。这个对象包含了模块的相关信息,其中exports就是要导入的对象。当再次导入相同模块时,Node.js 不会再次执行该模块的代码,而是直接从内存中获取这个已经缓存的exports对象。这个缓存对象的结构大致如下:
{
id: '...', // 模块名
exports: {... }, // 模块输出的接口
loaded: true, // 模块的脚本是否执行完毕
...
}
四、导入模块的可更改性探讨
导入模块的过程,实际上就是执行一遍要导入的模块,然后将其输出的exports对象作为require函数的返回值。从本质上来说,这就是一个普通的赋值语句。如果使用var或let进行声明,那么这个导入的对象是支持更改的。例如:
let api1 = require('./a');
api1 = {};
console.log(api1);
// {}
五、循环引用的处理方式
如果在代码中出现了某模块被循环加载的情况,Node.js 会如何处理呢?根据 CommonJS 的规范,在这种情况下,只输出已经执行的部分,未执行的部分不会输出。
例如,假设在a.js中引入b.js,同时在b.js中又引入a.js,形成了循环引用。那么,这种情况下会报错吗?如果不报错,运行的结果又会是怎样的呢?让我们通过代码来进行分析。
以下是a.js的代码:
const b = require('./b');
console.log('b', b);
const name = 'Jack';
const age = 18;
module.exports = {
name,
age
};
以下是b.js的代码:
const a = require('./a');
console.log('a', a);
const id = '001';
module.exports = {
id
};
运行node a.js,控制台输出如下:
plaintext
Copy
a {}
b { id: '001' }
可以看到,没有报错,正常运行。这是因为前面提到的原因:若出现某模块被循环加载,只输出已经执行的部分,未执行的不会输出。
下面简单分析一下这个过程:
当运行node a.js时,首先进入到a模块。a模块一开始就导入b模块,那么就会先执行一遍b.js,然后返回b.js导出的exports对象给a模块中的常量b。
执行b.js时,一开始又导入了a.js。但是此时a.js并未执行完毕,只会返回已执行的部分。由于a.js导出的是name、age,而此时a.js只执行到第 1 行,所以实际上此时导入a.js,只会返回空对象,因为name、age还未执行到。所以require('./a')返回空对象,赋值给常量a,故打印出空对象。
接着继续执行b.js,将剩下的代码执行完毕,正常导出了exports对象,其中包含了id属性。
执行完毕后,又回到了a.js的第一行中,require('./b')返回b导出的exports对象,包含了id属性。所以,下面能正常打印出{ id: '001' }。
接着在a.js中,将剩余的代码执行完毕。
六、思考题分析
现在有a.js、b.js、c.js三个文件,执行node a.js,控制台将会输出什么呢?
以下是a.js的代码:
const b = require('./b');
console.log(exports.x);
exports.x = 'x';
require('./c');
以下是b.js的代码:
const a = require('./a');
console.log(a);
a.x = 'y';
以下是c.js的代码:
const a = require('./a');
console.log(a.x);
答案分析如下:
首先执行node a.js,进入a.js文件。在a.js中,首先引入b.js模块。此时进入b.js文件,在b.js中又引入a.js模块。由于a.js还未执行完毕,只会返回已执行的部分,此时exports.x还未定义,所以在b.js中打印出的a为{}。接着在b.js中,将a.x赋值为'y'。
回到a.js中,由于exports.x一开始未定义,所以打印出undefined(题目中打印exports.x,实际上等价于打印undefined,因为exports.x此时未定义,这里假设题目中打印exports.x是打印undefined的笔误)。然后将exports.x赋值为'x'。接着引入c.js模块。
在c.js中,引入a.js模块,由于已经执行过a.js,直接从缓存中获取a.js的模块对象,此时a.x为'x',所以打印出'x'。
综上所述,执行node a.js,控制台输出的结果为{}、y、x。