人人都会AI编程

19.2 CommonJS 规范:Node.js 模块体系、加载机制、缓存原理

更新时间:2026-07-11

在 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.exportsexports

每个 Node.js 文件在运行前,都会被包裹在一个函数中:

function(exports, require, module, __filename, __dirname) {
    // 你的代码实际被放在这里
}

这解释了为什么每个模块里都能直接使用 exportsrequiremodulefilenamedirname 这几个“全局”变量——它们其实是这个包装函数的参数,不是真正的全局变量。

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 对象。标识符主要有三类形式:

  1. 相对路径和绝对路径./..// 开头。用于加载自定义模块。
  2. 裸模块名:如 'fs''http',表示核心模块;或者一个安装在 node_modules 中的包名。
  3. 文件夹:如果路径指向一个文件夹,Node.js 会寻找该文件夹下的 index.jspackage.jsonmain 字段指定的入口。

require 查找模块的完整顺序,遵循一套精确的算法,可以从伪代码角度理解。

19.2.4 加载机制:从请求到执行(简化实现思路)

假设你有这样一行代码:

const x = require('./foo');

Node.js 会执行如下步骤(简化版):

  1. 解析路径:将 './foo' 转为绝对路径,并尝试添加 .js.json.node 扩展名,直到找到对应文件。
  2. 检查缓存:如果该绝对路径已存在于 require.cache 对象中,直接返回缓存的 module.exports绝不会重复执行文件
  3. 新建模块对象:若未缓存,创建一个新的 module 对象,设置 id 为文件路径,exports 为空对象。
  4. 存入缓存:在真正执行代码之前,就把这个空的模块对象放入缓存(后面会说明为什么)。
  5. 包装 & 执行:读入文件内容,用前面提到的包装函数包裹,然后调用该函数,传入 exportsrequiremodulefilenamedirname
  6. 返回导出值:执行完毕后,返回 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') 时发生了什么?

  1. 开始加载 a.js
  2. 创建 a 的模块对象,存入缓存(此时 a.exports = {})。
  3. 执行 a.js 代码。
  4. 遇到 require('./b'),转而加载 b.js
  5. 创建 b 的模块对象,存入缓存。
  6. 执行 b.js 代码。
  7. b.js 中又遇到 require('./a')。此时 a 已经在缓存中,直接返回那个尚未执行完毕的 a.exports(此时还是空对象 {}
  8. b.js 继续执行,打印 a in b: undefined(因为 a.exports.done 还不存在)。
  9. b.js 完成后,b.exports = { done: true } 被设置,b 模块完成。
  10. 回到 a.jsconst b = require('./b') 得到完整 { done: true },打印 b in a: true
  11. 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 底层原理的基础。它简单、直接,用缓存解决重读问题,用“提前缓存”避免循环死锁,虽然如今有更强大的方案,但这份设计思想依然闪烁着朴素的智慧。