在 ES Modules 成为统一标准之前,JavaScript 社区曾有过一段“模块化混战”的时期。而 Node.js 最初选择的 CommonJS 方案,至今仍是服务端模块化的基石,深刻影响了 npm 生态的整个包组织方式。理解 CommonJS,不仅能看懂无数开源项目的源码,也是衔接 ES Modules 的必经之路。
19.2.1 CommonJS 的核心思想
CommonJS 的出发点很简单:让每个文件都是一个独立的模块,拥有自己独立的作用域。模块通过 module.exports 向外暴露接口,通过 require 函数加载其他模块。
这和浏览器中直接使用 <script> 标签完全不同。在全局 <script> 时代,所有变量都往 window 上挂,很容易互相污染。CommonJS 则给每个文件一个“包装作用域”,内部定义的变量、函数、类默认都是私有的。
19.2.2 模块定义与导出:module.exports 与 exports
每个 Node.js 文件在运行前,都会被包裹在一个函数中:
function(exports, require, module, __filename, __dirname) {
// 你的代码实际被放在这里
}
这解释了为什么每个模块里都能直接使用 exports、require、module、filename、dirname 这几个“全局”变量——它们其实是这个包装函数的参数,不是真正的全局变量。
module.exports
这是模块暴露外部接口的“官方出口”。module 是一个代表当前模块的对象,其 exports 属性初始指向一个空对象。真正决定被别的模块 require 后得到什么内容的是 module.exports。
// module_a.js
module.exports = function() {
console.log('Hello from module a');
};
// module_b.js
const myFunc = require('./module_a');
myFunc(); // Hello from module a
你可以把 module.exports 替换为任何值:函数、对象、字符串、数字、类。但替换后,原来空对象的引用就断了。
exports 快捷方式
exports 仅仅是 module.exports 的一个引用,初始指向同一个空对象。因此你可以通过 exports 添加属性:
// 等价于 module.exports.sayHi = ...
exports.sayHi = function() { console.log('Hi'); };
exports.name = 'Alice';
但绝不能给 exports 直接赋值,因为那样会切断它和 module.exports 的联系:
// 错误示例:这不会改变模块的导出值!
exports = function() { console.log('Wrong!'); };
// 此时 module.exports 仍然是原始空对象
规则很简单:如果你只想导出几个命名出口,用 exports.xxx;如果要导出一个单一值(类、函数等),直接给 module.exports 赋值。
19.2.3 require 的工作方式
require 函数接收一个模块标识符(字符串),返回该模块的 module.exports 对象。标识符主要有三类形式:
- 相对路径和绝对路径:
./、../或/开头。用于加载自定义模块。 - 裸模块名:如
'fs'、'http',表示核心模块;或者一个安装在node_modules中的包名。 - 文件夹:如果路径指向一个文件夹,Node.js 会寻找该文件夹下的
index.js或package.json中main字段指定的入口。
require 查找模块的完整顺序,遵循一套精确的算法,可以从伪代码角度理解。
19.2.4 加载机制:从请求到执行(简化实现思路)
假设你有这样一行代码:
const x = require('./foo');
Node.js 会执行如下步骤(简化版):
- 解析路径:将
'./foo'转为绝对路径,并尝试添加.js、.json、.node扩展名,直到找到对应文件。 - 检查缓存:如果该绝对路径已存在于
require.cache对象中,直接返回缓存的module.exports,绝不会重复执行文件。 - 新建模块对象:若未缓存,创建一个新的
module对象,设置id为文件路径,exports为空对象。 - 存入缓存:在真正执行代码之前,就把这个空的模块对象放入缓存(后面会说明为什么)。
- 包装 & 执行:读入文件内容,用前面提到的包装函数包裹,然后调用该函数,传入
exports、require、module、filename、dirname。 - 返回导出值:执行完毕后,返回
module.exports(用户代码可能已修改它,不再是初始空对象)。
这对开发者来说意味着:一个模块被 require 多次,但里面的代码只会执行一次。第二次开始都直接返回缓存结果,这大幅提升了性能,也保证了单例行为。
19.2.5 缓存原理:为什么能避免循环引用死锁
CommonJS 的缓存机制是它区别于其他模块方案的核心特征之一,也是解决“循环引用”问题的关键。
缓存存储位置
require.cache 是一个普通对象,键是模块的绝对路径,值就是对应的 module 对象。你可以手动删除某个键来强制重新加载模块(比如测试时需要)。
循环引用场景
假设有两个文件互相引用:
// a.js
const b = require('./b');
console.log('b in a:', b.done);
module.exports = { done: true };
// b.js
const a = require('./a');
console.log('a in b:', a.done);
module.exports = { done: true };
执行 require('./a') 时发生了什么?
- 开始加载
a.js。 - 创建
a的模块对象,存入缓存(此时a.exports = {})。 - 执行
a.js代码。 - 遇到
require('./b'),转而加载b.js。 - 创建
b的模块对象,存入缓存。 - 执行
b.js代码。 - 在
b.js中又遇到require('./a')。此时a已经在缓存中,直接返回那个尚未执行完毕的a.exports(此时还是空对象{})。 b.js继续执行,打印a in b: undefined(因为a.exports.done还不存在)。b.js完成后,b.exports = { done: true }被设置,b模块完成。- 回到
a.js,const b = require('./b')得到完整{ done: true },打印b in a: true。 a.js最终设置module.exports = { done: true }。
运行结果:
a in b: undefined
b in a: true
关键洞察:CommonJS 解决循环引用的策略是“提前缓存未完成的模块对象”。这种做法不会死锁,但可能导致你拿到的是一个“部分完成”的导出对象。因此,实际开发中应尽量避免循环引用,如果实在需要,尽量在模块顶层导出导出值(而不是在函数执行中动态改变 module.exports),或者将对另一个模块的引用推迟到实际调用时(在函数内使用 require)。
19.2.6 缓存实战注意
- CircleCI、测试或开发中热重载时,可能需要清除缓存:
delete require.cache[require.resolve('./myModule')]。 - 缓存基于文件绝对路径,因此同一个文件从不同位置
require会得到相同对象,确保了共享状态的一致性。 - 对于 JSON 文件,CommonJS 会直接用
JSON.parse解析,返回值被缓存,第二次require也直接使用缓存。
19.2.7 CommonJS 与 ES Modules 的核心差异(过渡)
CommonJS 的运行机制是运行时加载:require 只是个函数,执行到这一行时才去加载模块,并且拿到的是值的拷贝(准确说是 module.exports 对象的引用,但如果导出的是基本类型则是值拷贝)。而 ES Modules 是编译时静态加载,import 命令会在代码编译阶段就确定依赖关系,并且拿到的是值的只读实时绑定。
这也解释了为什么 Node.js 中大部分代码仍然使用 CommonJS,但新的项目越来越倾向 ES Modules——对打包工具更友好,更适合 Tree Shaking。第19.4节将会详细剖析 ES Modules 的标准及二者差异。
掌握 CommonJS,是阅读 npm 包源码、理解 Node.js 底层原理的基础。它简单、直接,用缓存解决重读问题,用“提前缓存”避免循环死锁,虽然如今有更强大的方案,但这份设计思想依然闪烁着朴素的智慧。