人人都会AI编程

5.1 CommonJS 规范核心机制

更新时间:2026-07-10

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 有不同的处理规则:

  1. 核心模块(如 'fs''http'):Node.js 内部已经编译好的二进制模块,加载优先级最高,速度最快。
  2. 相对路径或绝对路径模块(如 './utils''/home/user/mod'):直接根据路径查找。
  3. 非路径形式模块(如 'express'):按照 node_modules 目录层级递归查找。Node.js 会从当前文件所在目录开始,依次向上级目录查找 node_modules/express,直到根目录。

步骤二:文件定位

当确定了模块所在的目录后,Node.js 需要解析出具体的文件名:

  1. 若给定的是一个确切的文件名(含扩展名),直接使用。
  2. 若没有扩展名,Node.js 会依次尝试添加 .js.json.node 扩展名进行查找。
  3. 如果找到的是一个目录,则会根据该目录下的 package.json 中的 main 字段指定的文件进行加载。如果没有 package.jsonmain 字段,则默认尝试加载目录下的 index.jsindex.node

例如,require('./lib') 可能最终加载的是 ./lib/index.js

步骤三:编译执行

经过文件定位,确定了要加载的物理文件后,Node.js 读取文件内容,根据扩展名采用不同的编译方式:

  • .js 文件:先用函数包裹,编译为可执行的代码,然后注入 exportsrequiremodulefilenamedirname 等参数,再执行。
  • .json 文件:通过 JSON.parse 解析成对象并赋值给 module.exports
  • .node 文件:这是用 C/C++ 编译成的 Node.js 扩展(通过 node-gyp 等),直接通过 process.dlopen 加载。

包裹函数是 CommonJS 的核心魔法。每个模块文件实际上被包裹成如下形式:

(function(exports, require, module, __filename, __dirname) {
  // 文件原始内容
});

这样每个模块都拥有独立的作用域,变量不会污染全局,且能够通过 require 引入其他模块,通过 moduleexports 导出内容。参数 filenamedirname 分别对应当前文件的完整路径和所在目录。

步骤四:缓存返回

为了避免重复加载和无限递归,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 变量在模块作用域内是唯一的,所以 c1c2 共享了同一个闭包中的状态。

5.1.3 exportsmodule.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

流程解释:

  1. a.js 开始加载,将 done 设为 false,然后遇到 require('./b')
  2. Node.js 开始加载 b.js。此时 a.js 尚未执行完所有代码,但它的 module.exports 对象已经存在,并且目前只有一个 done: false 属性。
  3. b.js 执行 require('./a'),由于 a.js 正在加载中,不会重新加载,而是从缓存中取出 当前状态a 模块导出对象({ done: false })。
  4. b.js 接着执行,将自身的 done 设为 true,结束。
  5. 回到 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 的差异打下基础。