CommonJS 是 Node.js 最早的模块规范,也是目前 Node.js 中最广泛使用的模块系统。它定义了模块该如何定义、引用和导出。在 ES Modules 逐步推广之前,require() 和 module.exports 是 Node.js 开发者的日常。深入了解 CommonJS 核心机制,不仅有助于理解遗留代码,也是掌握模块化设计的基石。
5.1.1 CommonJS 模块的特点
CommonJS 被设计用于服务端环境,与浏览器端的 AMD 或 ES Modules 有几个本质区别:
- 同步加载:
require()会阻塞代码执行,直到模块完全加载并返回导出对象。在服务端,模块文件都在本地磁盘,同步 I/O 足够快,因此这是合理的。 - 运行时加载:模块的加载和导出的确定发生在代码实际执行到
require语句时,而不是提前静态分析。这带来了极大的灵活性,允许条件加载、动态路径拼接甚至循环引用。 - 输出值的拷贝:
module.exports输出的是一个普通的 JavaScript 对象,一旦导出,模块内部对值的修改不会影响到已导入该对象的其他模块(除非导出一个可变的引用类型对象,修改其属性会影响导入方)。后文会详细分析。
一个最简单的 CommonJS 模块如下:
// math.js
const add = (a, b) => a + b;
module.exports = { add };
// app.js
const math = require('./math');
console.log(math.add(1, 2)); // 3
5.1.2 require 加载的全流程
当代码中执行 require(x) 时,Node.js 会执行一套严格有序的步骤。整个流程可以概括为:路径解析 → 文件定位 → 编译执行 → 缓存返回。
步骤一:路径解析
require 接收的字符串 x 有不同的处理规则:
- 核心模块(如
'fs'、'http'):Node.js 内部已经编译好的二进制模块,加载优先级最高,速度最快。 - 相对路径或绝对路径模块(如
'./utils'、'/home/user/mod'):直接根据路径查找。 - 非路径形式模块(如
'express'):按照node_modules目录层级递归查找。Node.js 会从当前文件所在目录开始,依次向上级目录查找node_modules/express,直到根目录。
步骤二:文件定位
当确定了模块所在的目录后,Node.js 需要解析出具体的文件名:
- 若给定的是一个确切的文件名(含扩展名),直接使用。
- 若没有扩展名,Node.js 会依次尝试添加
.js、.json、.node扩展名进行查找。 - 如果找到的是一个目录,则会根据该目录下的
package.json中的main字段指定的文件进行加载。如果没有package.json或main字段,则默认尝试加载目录下的index.js或index.node。
例如,require('./lib') 可能最终加载的是 ./lib/index.js。
步骤三:编译执行
经过文件定位,确定了要加载的物理文件后,Node.js 读取文件内容,根据扩展名采用不同的编译方式:
.js文件:先用函数包裹,编译为可执行的代码,然后注入exports、require、module、filename、dirname等参数,再执行。.json文件:通过 JSON.parse 解析成对象并赋值给module.exports。.node文件:这是用 C/C++ 编译成的 Node.js 扩展(通过 node-gyp 等),直接通过process.dlopen加载。
包裹函数是 CommonJS 的核心魔法。每个模块文件实际上被包裹成如下形式:
(function(exports, require, module, __filename, __dirname) {
// 文件原始内容
});
这样每个模块都拥有独立的作用域,变量不会污染全局,且能够通过 require 引入其他模块,通过 module 和 exports 导出内容。参数 filename 和 dirname 分别对应当前文件的完整路径和所在目录。
步骤四:缓存返回
为了避免重复加载和无限递归,Node.js 会缓存已加载的模块。require.cache 中保存了 Module 实例,以全路径为键。当再次 require 同一个文件时,直接从缓存中取出 module.exports,避免再次执行模块代码。
缓存是整个 CommonJS 体系中最容易被忽视但又至关重要的机制。例如:
// counter.js
let count = 0;
module.exports = {
increment: () => ++count,
get: () => count
};
// main.js
const c1 = require('./counter');
const c2 = require('./counter');
c1.increment();
console.log(c2.get()); // 1, 因为 c1 和 c2 是同一个模块实例的导出对象
因为 counter 只被加载并执行了一次,count 变量在模块作用域内是唯一的,所以 c1 和 c2 共享了同一个闭包中的状态。
5.1.3 exports 与 module.exports 的真相
这是 CommonJS 使用中最容易出错的点。实际上,exports 只是 module.exports 的一个引用,最终导出的值由 module.exports 决定。Node.js 在模块包裹函数开头大致做了这样一件事:
const exports = module.exports; // 指向同一个对象
因此,给 exports 添加属性 能正常工作,因为修改的是同一对象:
// utils.js
exports.a = 1;
exports.b = 2;
// 等价于 module.exports.a = 1;
但如果直接给 exports 重新赋值,就会切断它与 module.exports 的引用,导致导出失败:
// wrong.js
exports = { a: 1 }; // 此时 exports 指向了新对象,但 module.exports 仍然是原来的空白对象
// 其他模块 require 时将得到一个空对象 {}
如果你想直接导出一个函数、类或全新的对象,必须使用 module.exports:
// greeter.js
module.exports = function(name) {
return `Hello ${name}`;
};
记忆口诀:当你只需要导出多个属性时,用 exports.xxx;当你需要替换整个导出对象时,用 module.exports。
5.1.4 循环引用的处理
CommonJS 允许循环引用,但开发者必须理解其行为,否则可能遇到 undefined 而非预期的对象。
考虑两个模块相互引用:
// a.js
console.log('a 开始加载');
exports.done = false;
const b = require('./b');
console.log('在 a 中, b.done =', b.done);
exports.done = true;
console.log('a 结束加载');
// b.js
console.log('b 开始加载');
exports.done = false;
const a = require('./a');
console.log('在 b 中, a.done =', a.done);
exports.done = true;
console.log('b 结束加载');
// main.js
const a = require('./a');
const b = require('./b');
console.log('在 main 中, a.done, b.done =', a.done, b.done);
执行 main.js 会输出:
a 开始加载
b 开始加载
在 b 中, a.done = false
b 结束加载
在 a 中, b.done = true
a 结束加载
在 main 中, a.done, b.done = true true
流程解释:
a.js开始加载,将done设为false,然后遇到require('./b')。- Node.js 开始加载
b.js。此时a.js尚未执行完所有代码,但它的module.exports对象已经存在,并且目前只有一个done: false属性。 b.js执行require('./a'),由于a.js正在加载中,不会重新加载,而是从缓存中取出 当前状态 的a模块导出对象({ done: false })。b.js接着执行,将自身的done设为true,结束。- 回到
a.js继续执行,此时b.done已经是true,再将自身的done改为true,结束。
关键点在于:循环引用时,模块可能只会获得另一个模块的部分导出值,即执行到当前时刻为止的 exports 快照。要避免因此产生的隐性 bug,通常建议在模块设计时尽量保持依赖无环,或者在循环中仅导出函数声明而非立即执行的变量值,因为函数声明会被提升。
5.1.5 热更新与缓存清理
在实际开发中,有时我们会希望“重新加载”一个模块(如配置文件变更),这可以通过删除缓存实现:
delete require.cache[require.resolve('./config')];
const newConfig = require('./config');
require.resolve 获取模块的全路径,然后从 require.cache 中删除,下一次 require 就会重新执行模块代码。需谨慎使用,因为引用该模块的其他模块并不会同步更新,可能导致状态不一致。
5.1.6 小结
CommonJS 核心机制是 Node.js 模块化的根基:
require通过路径解析、文件定位、编译执行和缓存返回,实现了同步加载。- 模块作用域隔离通过包裹函数提供,并注入五个关键变量。
module.exports是真正的导出接口,而exports只是其引用。- 缓存机制保证了单例,但也带来了状态共享;循环引用会产生部分加载的效果。
尽管 ES Modules 开始逐渐接管前端和后端模块化,但 CommonJS 在 Node.js 生态中依然广泛存在(几乎所有 npm 包至少同时支持 CommonJS)。透彻理解 require 的每一步,能帮助你更从容地排查模块加载错误、编写健壮的模块代码,并为学习 ES Modules 的差异打下基础。