本节摘要:CommonJS 是 Node 默认的模块规范,核心是
require加载与module.exports导出。本节讲清模块解析顺序、缓存机制如何让每个模块天然成为单例、循环依赖发生时的半成品导出行为,以及它与事件循环的一个关键区别——模块加载是同步的。
JavaScript 原生没有「文件级作用域」这个概念——浏览器时代靠 IIFE 和命名空间硬撑,代码一大就互相污染。Node 面对的是成百上千个文件的服务端工程,必须先解决这个问题。2009 年定下的 CommonJS 规范给了答案:每个文件就是一个模块,有自己的作用域;想暴露什么就挂到 module.exports 上,想用什么就 require 进来。
// 最简单的模块:一个计数器 let count = 0; function add(n) { count += n; return count; } function current() { return count; } module.exports = { add, current };
使用方:
const counter = require('./counter'); counter.add(5); counter.add(3); console.log(counter.current()); // 8
当你写下 require('http') 和 require('./counter') 时,Node 走的是两条完全不同的路:
./、../ 或绝对路径开头):先按确切文件名找;找不到再依次补 .js、.json、.node 扩展名;如果是个目录,读它的 package.json 里 main 字段;再退回目录下的 index.js。node_modules/express,直到文件系统根。这正是你能在项目深层目录里直接 require 依赖、也能「就近」覆盖版本的原因。💡 关键直觉:查找路径向上穿透的规则,让同一个依赖可以在不同层级存在不同版本——这是灵活性,也是第 4 章「依赖地狱」的种子。
require 的返回值会被缓存到 require.cache 里,键是解析后的绝对文件名。第二次 require 同一模块时直接返回缓存对象,模块顶层代码不会重新执行。
// counter.js 再被别处 require 时,顶层的 let count = 0 不会重跑 // 所以整个进程里 counter 只有一份——天然单例
这个设计可以用 module 对象亲自验证:
console.log(module); // Module { // id: '.', // path: [...], // exports: {}, // filename: '...counter.js', // loaded: false, // ... // }
loaded 字段很值得注意:模块代码执行期间它是 false,执行完才变 true。这个细节直接决定了循环依赖的行为。
假设 a.js require 了 b.js,b.js 又回头 require 了 a.js。Node 的处理方式不是报错,而是立刻返回 a 此刻尚未完成的 exports——一个可能只有部分属性的半成品对象。
// a.js console.log('a 开始'); exports.name = 'a'; const b = require('./b'); console.log('b 里看到的 a:', b.sawA); // b.js console.log('b 开始'); const a = require('./a'); exports.sawA = a.name; // a 已执行到 exports.name 赋值之后,所以能取到
运行 a.js 的输出顺序是:a 开始 → b 开始 → b 里看到的 a: a。如果 b 在 a 赋值 exports.name 之前就去 require a,拿到的就是空对象。没有报错、没有警告,只有静默的 undefined——这类 bug 在大型项目里极难排查。
⚠️ 常见坑:循环依赖的典型症状是「某个 import 进来的值是 undefined,但单独看两个文件都没问题」。排查思路是画出依赖图找环,然后通过延迟 require(挪到函数内部)或抽公共模块来解环。
CommonJS 的 require 是同步的:模块顶层的代码会阻塞后续语句,直到依赖全部加载完成。这在服务端启动阶段不是问题——启动只发生一次;但如果在请求回调里动态 require 一个大模块,每个请求都要付一次同步加载的代价(好在有缓存,只有第一个请求付全款)。
后来的 ESM(第 7 章会展开)改成了异步加载与静态分析,支持顶层 await。两套体系的核心差异可以用一张表说清:
| 维度 | CommonJS | ESM |
|---|---|---|
| 加载时机 | 运行时同步加载 | 编译期静态分析 |
| 导出形态 | 值的拷贝(快照) | 绑定(实时映射) |
| 顶层 await | 不支持 | 支持 |
| 加载失败 | 运行时抛错 | 解析阶段即可发现 |
「值的拷贝」值得单独记:CommonJS 里导出的是一个数字,模块内部后来改了它,使用方看到的还是旧值;ESM 导出的是绑定,两边实时同步。这个差异在面试和真实 bug 里出镜率都很高。