CommonJS 规范与实现
一句话概括
CommonJS 是 Node.js 的默认模块系统,核心三件事:require() 同步加载、module.exports 导出值拷贝、模块首次执行后缓存——六个字记牢:同步、缓存、值拷贝。
核心知识点
1. 基础:导出与导入
1
2
3
4
5
6
7
8
9
10
// math.js —— 导出
const add = (a, b) => a + b;
const SECRET = '外部不可见';
module.exports = { add };
// 或者:exports.add = add; (后面会讲区别)
// app.js —— 导入
const math = require('./math');
math.add(1, 2); // 3
math.SECRET; // undefined —— 没有导出,外部拿不到
每个文件是独立模块。只有挂在 module.exports 上的东西才对外可见。
2. module.exports vs exports — 经典陷阱
1
2
3
4
5
6
7
8
// ✅ 给 exports 加属性 —— 有效
exports.foo = 'bar';
// ❌ 给 exports 赋新值 —— 无效!
exports = { foo: 'bar' }; // exports 不再是 module.exports 的引用
// ✅ 直接给 module.exports 赋新值 —— 有效
module.exports = { foo: 'bar' };
原理: 模块执行前,Node 隐式做了 const exports = module.exports。所以给 exports 加属性 = 改 module.exports 上的属性;但给 exports 重新赋值 = 切断了引用,之后 require 返回的还是原来的 module.exports。
3. 缓存机制 — 只执行一次
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// counter.js
let count = 0;
module.exports = {
count, // 导出的是值拷贝!
inc: () => { count++; },
getCount: () => count,
};
// app.js
const c1 = require('./counter');
const c2 = require('./counter');
console.log(c1 === c2); // true —— 单例
c1.inc();
console.log(c2.getCount()); // 1 —— 共享内部状态
console.log(c1.count); // 0 —— 值拷贝不会更新!
三个要点:(1) 同文件 require 多次,只执行一次代码;(2) 返回的是缓存的 module.exports 对象;(3) 原始值导出的是快照,不会实时更新。
4. require 的查找路径
1
2
3
4
5
6
7
8
9
10
// 优先级从高到低:
// 1. 核心模块(fs、path、http……)
const fs = require('fs');
// 2. 相对/绝对路径(自动补后缀:.js → .json → .node → /index.js)
const util = require('./util');
// 3. node_modules(从当前目录逐级向上直到根目录)
const lodash = require('lodash');
// 查找顺序:./node_modules/lodash → ../node_modules/lodash → ... → /
5. 循环依赖 — 拿到的可能是半成品
1
2
3
4
5
6
7
8
9
10
// a.js
exports.done = false;
const b = require('./b');
exports.done = true;
// b.js
const a = require('./a');
console.log(a.done); // false —— a 还没执行完!
// 执行顺序:a 开始 → require(b) 跳 b → b 又 require(a) → 拿到缓存里的 { done: false } → b 结束 → a 继续
不会死循环,因为第一次 require('./a') 时就创建了缓存对象。但缓存对象里的值可能还没被赋完——这就是”半成品”。
其实你每天都在用
- 所有
require('fs')、require('path')— Node 标准库全走 CJS - webpack 打包的
__webpack_require__— 就是山寨版 CJS require,让你在浏览器跑 Node 风格代码 - babel / tsc 转译 import → require — 你的 ESM 源码跑在 Node 里靠这一层转译
.eslintrc.js/jest.config.js— 所有 Node 端配置文件都是 CJS 模块- 大部分 npm 包的
main字段 — 指向的就是 CJS 入口
常见误解
❌ 误区:「exports 和 module.exports 是同一个东西」 一开始
exports = module.exports成立。但只要有一方被重新赋值,等式就断了。require 拿走的永远只有module.exports,管你 exports 后来指向了哪。❌ 误区:「CJS 导出后值会实时更新」 不会。
module.exports = { count }是拷贝当前值。想要实时值,需要导出 getter:Object.defineProperty(module.exports, 'count', { get: () => count })。这一点是和 ESM live binding 的最本质区别。❌ 误区:「循环依赖一定有问题,应完全避免」 大项目中完全避免不现实。CJS 能处理,但要注意:不要在模块顶层使用另一个模块的导出——把使用延迟到函数调用时。
❌ 误区:「require 和 import 本质一样,只是语法不同」 完全不同。
require运行时同步读文件;import编译时静态解析。这决定了 CJS 可以做条件 require、ESM 可以做 Tree Shaking——设计哲学完全不同。
一句话总结
同步读文件、跑一次代码、缓存 module.exports——CommonJS 的思想简单到粗暴,但正因为简单,才能撑起整个 npm 生态十几年。