Node.js中的CommonJS模块系统
一、CommonJS 模块系统概述
在 Node.js 的生态中,CommonJS 模块系统扮演着极为重要的角色。它为开发者提供了一种将代码分割成可复用模块的方式,使得大型项目的代码组织和管理变得更加容易。
(一)模块化的意义
在没有模块化之前,开发大型项目时,代码往往都写在一个文件里,随着功能的增加,代码量迅速膨胀,变量命名冲突、代码维护困难等问题接踵而至。模块化就是将代码按照功能、用途等维度拆分成一个个独立的模块,每个模块都有自己独立的作用域,模块之间通过特定的接口进行交互。这样做不仅提高了代码的可维护性,还增强了代码的复用性,使得开发大型项目变得更加高效。
(二)CommonJS 的诞生背景
JavaScript 最初是作为浏览器端的脚本语言,其运行环境相对简单,不需要模块化系统。但随着 Node.js 的出现,JavaScript 开始在服务器端运行,应用规模越来越大,对模块化的需求也日益迫切。CommonJS 应运而生,它定义了一种简单易用的模块规范,Node.js 率先采用并实现了该规范,从而使得 Node.js 具备了强大的模块管理能力。
二、CommonJS 模块的基本结构
(一)模块定义
在 Node.js 中,每个 JavaScript 文件都可以看作是一个独立的模块。模块内部定义的变量和函数默认是私有的,外部无法直接访问。要想让模块对外暴露接口,需要使用 exports
或 module.exports
对象。
例如,创建一个名为 mathUtils.js
的模块,用于实现一些简单的数学运算:
// mathUtils.js
// 定义一个私有变量
let privateVar = 10;
// 定义一个私有函数
function privateFunction() {
console.log('This is a private function');
}
// 定义一个公有函数,通过 exports 对象暴露出去
exports.add = function(a, b) {
return a + b;
};
exports.subtract = function(a, b) {
return a - b;
};
在上述代码中,privateVar
和 privateFunction
是模块内部的私有成员,外部无法访问。而 add
和 subtract
函数通过 exports
对象暴露出去,其他模块可以引用并使用。
(二)模块引用
当我们定义好一个模块后,其他模块可以通过 require
方法来引用它。require
方法的参数是模块的标识符,通常是模块文件的路径(相对路径或绝对路径),如果是核心模块或已安装的 npm 模块,则直接使用模块名。
例如,创建一个 main.js
文件来引用 mathUtils.js
模块:
// main.js
const mathUtils = require('./mathUtils');
console.log(mathUtils.add(5, 3));
console.log(mathUtils.subtract(5, 3));
在上述代码中,通过 require('./mathUtils')
引用了 mathUtils.js
模块,并将其赋值给 mathUtils
变量。之后就可以通过 mathUtils
变量访问到 mathUtils.js
模块暴露出来的 add
和 subtract
函数。
三、exports 与 module.exports 的区别与联系
(一)exports 本质
exports
是一个普通的 JavaScript 对象,它在每个模块内部被自动创建。我们通过给 exports
对象添加属性和方法,来实现模块接口的暴露。例如在前面的 mathUtils.js
模块中,我们就是通过 exports.add
和 exports.subtract
来暴露函数的。
(二)module.exports 本质
module.exports
也是一个对象,它同样用于定义模块的对外接口。实际上,exports
是 module.exports
的一个引用,即 exports === module.exports
最初是 true
。
(三)区别与使用场景
虽然 exports
和 module.exports
一开始指向同一个对象,但如果直接给 exports
赋值一个新的对象,就会切断它与 module.exports
的引用关系。例如:
// 错误示例
exports = {
newFunction: function() {
console.log('This is a new function');
}
};
在上述代码中,给 exports
赋值了一个新对象,此时 exports
不再指向 module.exports
,外部通过 require
引入该模块时,无法获取到 newFunction
。
而正确的做法是使用 module.exports
来赋值新对象:
// 正确示例
module.exports = {
newFunction: function() {
console.log('This is a new function');
}
};
通常情况下,如果只是向模块外部暴露少量的函数或属性,使用 exports
会更简洁,例如 exports.add = function() {}
。但如果需要暴露一个完整的对象、函数或数组等复杂结构,建议使用 module.exports
,这样可以避免因 exports
赋值新对象而导致的引用问题。
四、模块的加载机制
(一)优先从缓存加载
Node.js 为了提高模块加载的效率,采用了缓存机制。当一个模块第一次被 require
时,Node.js 会将该模块的 exports 对象缓存起来。之后再次 require
同一个模块时,会直接从缓存中获取,而不会重新执行模块代码。
例如,创建一个 cachedModule.js
模块:
// cachedModule.js
console.log('This module is being loaded');
exports.message = 'Hello from cached module';
在 main.js
中多次引用该模块:
// main.js
const cachedModule1 = require('./cachedModule');
const cachedModule2 = require('./cachedModule');
console.log(cachedModule1.message);
console.log(cachedModule2.message);
运行 main.js
时,你会发现 This module is being loaded
只打印了一次,说明第二次 require
时直接从缓存中获取了模块。
(二)核心模块的加载
Node.js 内置了许多核心模块,如 fs
(文件系统)、http
(HTTP 服务器)等。加载核心模块非常简单,直接使用模块名即可,不需要指定路径。例如:
const fs = require('fs');
const http = require('http');
核心模块在 Node.js 启动时就已经加载并缓存,所以加载速度非常快。
(三)文件模块的加载
- 相对路径模块:如果
require
的参数是相对路径(以./
或../
开头),Node.js 会根据当前模块所在的目录来查找目标模块。例如,在src
目录下有main.js
和utils
目录,utils
目录下有helper.js
,在main.js
中引用helper.js
可以这样写:
const helper = require('./utils/helper');
- 绝对路径模块:使用绝对路径加载模块时,直接指定模块文件的完整路径。例如:
const absoluteModule = require('/Users/user/projects/myModule.js');
但在实际开发中,使用绝对路径不够灵活,相对路径更为常用。
(四)包模块的加载
当 require
的参数既不是核心模块,也不是相对路径或绝对路径时,Node.js 会将其视为包模块。包模块通常是通过 npm 安装的第三方模块。Node.js 会按照以下顺序查找包模块:
- 在当前目录的
node_modules
文件夹中查找。 - 如果没找到,向上级目录的
node_modules
文件夹查找,直到根目录。
例如,安装了 lodash
模块后,在项目中的任何模块都可以通过 const _ = require('lodash');
来引用它,Node.js 会自动在合适的 node_modules
目录中找到并加载该模块。
五、模块的作用域
(一)模块作用域的概念
每个模块都有自己独立的作用域,在模块内部定义的变量、函数等只在该模块内有效,不会污染全局作用域,也不会与其他模块的变量和函数发生命名冲突。
例如,在 module1.js
中定义一个变量 count
:
// module1.js
let count = 0;
function increment() {
count++;
console.log(count);
}
exports.increment = increment;
在 module2.js
中也定义一个同名变量 count
:
// module2.js
let count = 100;
function decrement() {
count--;
console.log(count);
}
exports.decrement = decrement;
在 main.js
中分别引用这两个模块:
// main.js
const module1 = require('./module1');
const module2 = require('./module2');
module1.increment();
module2.decrement();
这里 module1
和 module2
中的 count
变量互不干扰,各自在自己的模块作用域内独立存在。
(二)全局变量在模块中的表现
虽然模块有自己独立的作用域,但 Node.js 还是提供了一些全局变量,如 __dirname
(当前模块所在目录的绝对路径)、__filename
(当前模块的绝对路径)等。这些全局变量在每个模块内都可以直接使用,但它们的作用域仅限于模块内部,不会影响到其他模块或全局环境。
例如:
// 打印当前模块所在目录的绝对路径
console.log(__dirname);
// 打印当前模块的绝对路径
console.log(__filename);
此外,global
对象是 Node.js 中的全局对象,在浏览器端 JavaScript 中,全局对象是 window
。在模块内部可以通过 global
对象访问或定义全局变量,但不推荐这样做,因为可能会导致命名冲突和代码可维护性问题。
六、模块的循环引用
(一)循环引用的情况
在实际开发中,可能会出现模块之间的循环引用。例如,模块 A
引用模块 B
,而模块 B
又引用模块 A
。
假设有 a.js
和 b.js
两个模块:
// a.js
const b = require('./b');
console.log('In module A, b value:', b.value);
let value = 'A';
exports.value = value;
// b.js
const a = require('./a');
console.log('In module B, a value:', a.value);
let value = 'B';
exports.value = value;
在 main.js
中引用 a.js
:
// main.js
const a = require('./a');
当运行 main.js
时,会发现 In module A, b value: undefined
和 In module B, a value: undefined
。这是因为在循环引用中,当 a.js
引用 b.js
时,b.js
开始执行,而 b.js
又引用 a.js
,此时 a.js
还没有完全执行完毕,a.value
还未赋值,所以 b.js
中获取到的 a.value
是 undefined
。同理,a.js
中获取到的 b.value
也是 undefined
。
(二)解决循环引用的方法
- 调整模块结构:尽量避免模块之间形成循环引用,通过合理拆分模块、调整功能分布等方式来消除循环依赖。例如,可以将
a.js
和b.js
中相互依赖的部分提取到一个新的模块c.js
中,让a.js
和b.js
都依赖c.js
,而不是相互依赖。 - 使用中间变量:在模块中可以使用中间变量来暂存部分数据,避免在循环引用时直接获取未初始化的值。例如,在
a.js
中可以先定义一个中间变量,在模块执行完毕后再赋值给exports.value
:
// a.js
let tempValue;
const b = require('./b');
console.log('In module A, b value:', b.value);
tempValue = 'A';
setTimeout(() => {
exports.value = tempValue;
}, 0);
这样在 b.js
引用 a.js
时,虽然不能立即获取到 a.value
的最终值,但可以通过合理的逻辑处理,避免出现 undefined
的情况。不过这种方法相对复杂,且不够优雅,最好还是从根本上调整模块结构来解决循环引用问题。
七、CommonJS 模块与 ES6 模块的比较
(一)语法差异
- CommonJS:CommonJS 使用
exports
或module.exports
来暴露模块接口,通过require
来引入模块。例如:
// 暴露模块接口
exports.add = function(a, b) {
return a + b;
};
// 引入模块
const mathUtils = require('./mathUtils');
- ES6 模块:ES6 模块使用
export
关键字来暴露模块接口,使用import
关键字来引入模块。例如:
// 暴露模块接口
export function add(a, b) {
return a + b;
}
// 引入模块
import { add } from './mathUtils.js';
ES6 模块的语法更加简洁和直观,而且支持多种导出和导入方式,如默认导出、命名导出等。
(二)加载机制差异
- CommonJS:CommonJS 是运行时加载,即模块在
require
时才会被加载和执行。这意味着在模块加载之前,无法获取模块的信息,而且模块可以根据运行时的条件动态加载。例如:
if (someCondition) {
const moduleA = require('./moduleA');
} else {
const moduleB = require('./moduleB');
}
- ES6 模块:ES6 模块是编译时加载,在编译阶段就确定了模块的依赖关系和导出接口。这使得 ES6 模块的静态分析更容易,并且可以进行一些优化,如 tree - shaking(摇树优化,去除未使用的代码)。但 ES6 模块不支持动态加载,导入语句必须放在模块的顶层,不能在函数或条件语句中使用。例如:
// 错误,ES6 模块导入语句不能在条件语句中
if (someCondition) {
import { moduleA } from './moduleA.js';
}
(三)适用场景差异
- CommonJS:由于其动态加载的特性,更适合用于服务器端开发,如 Node.js 项目。在服务器端,模块的加载和执行通常是在运行时根据实际需求进行的,CommonJS 模块系统能够很好地满足这种需求。
- ES6 模块:ES6 模块的静态加载特性使其在前端开发中更具优势,尤其是在使用打包工具(如 Webpack)进行项目构建时,能够更好地进行代码优化和拆分。同时,现代浏览器也原生支持 ES6 模块,使得前端开发可以直接使用 ES6 模块进行模块化开发。
八、在实际项目中使用 CommonJS 模块系统
(一)项目结构组织
在实际项目中,合理的项目结构对于使用 CommonJS 模块系统至关重要。通常会将不同功能的模块放在不同的目录下,例如:
project/
├── src/
│ ├── controllers/
│ │ ├── userController.js
│ │ └── productController.js
│ ├── models/
│ │ ├── userModel.js
│ │ └── productModel.js
│ ├── utils/
│ │ ├── helper.js
│ │ └── logger.js
│ └── main.js
├── node_modules/
├── package.json
└── README.md
在上述项目结构中,controllers
目录存放与业务逻辑相关的控制器模块,models
目录存放数据模型模块,utils
目录存放一些通用的工具模块,main.js
是项目的入口文件。这种结构清晰的组织方式使得模块之间的关系一目了然,便于代码的维护和扩展。
(二)模块复用与依赖管理
- 模块复用:通过合理定义和暴露模块接口,可以实现模块的高度复用。例如,
utils/helper.js
模块中定义了一些通用的函数,如字符串处理、日期格式化等,项目中的其他模块都可以引用该模块来复用这些函数,避免了重复开发。 - 依赖管理:在 Node.js 项目中,使用
package.json
文件来管理项目的依赖。通过npm install
命令安装的第三方模块会被记录在package.json
的dependencies
或devDependencies
字段中。例如:
{
"name": "my - project",
"version": "1.0.0",
"dependencies": {
"express": "^4.17.1",
"mongoose": "^5.11.10"
},
"devDependencies": {
"jest": "^26.6.3"
}
}
这样,当项目在不同环境部署时,只需要在项目目录下执行 npm install
,npm 就会根据 package.json
中的依赖信息自动安装相应的模块,确保项目的正常运行。
(三)优化模块加载性能
- 合理使用缓存:由于 Node.js 本身已经提供了模块缓存机制,在开发中应尽量避免不必要的模块重复加载。例如,在一些频繁调用的函数中,如果需要引用模块,应将
require
语句放在函数外部,确保模块只被加载一次。 - 减少模块体积:对于一些较大的模块,可以通过代码拆分、去除无用代码等方式来减小模块体积,从而提高模块的加载速度。例如,将一些不常用的功能从主模块中拆分出来,在需要时再动态加载。
通过以上在实际项目中的应用,可以充分发挥 CommonJS 模块系统的优势,提高项目的开发效率和可维护性。