模块机制深度解析
CommonJS 与 ESM 双轨制:require 的加载全流程、exports 与 module.exports 的坑、循环依赖为何有时能跑有时炸,面试一问一个准。
一句话概括
Node.js 里「一个文件就是一个模块」,这套机制是 CommonJS(CJS)规范奠定的:require 加载、module.exports 导出,再配上模块缓存和「先缓存后执行」的循环依赖兜底。但随着 ESM(import/export)成为标准,我们现在处在 CJS 与 ESM 双轨并存的时代。
面试爱问它,是因为坑特别实在:exports = xxx 为什么失效、循环依赖为什么拿到 {}、CJS 和 ESM 到底差在哪。把这些搞清楚,你就不只是「会写 require」,而是真的懂 Node 是怎么组织代码的。
核心知识点
1. 一个文件就是一个模块:包装魔法
每个 .js 文件在被 Node 执行前,都会被包进一个函数里,这就是为什么 require、module、exports、__dirname、__filename 这些「全局变量」能用——它们其实是函数形参:
1
2
3
4
5
6
7
8
9
// Node 实际做的事(简化):
// (function (exports, require, module, __filename, __dirname) {
// // 你的文件内容
// })()
// utils.js
console.log(typeof require); // 'function'
console.log(typeof __dirname); // 'string'
module.exports = { add: (a, b) => a + b };
而且函数里的 this 指向 module.exports,所以模块之间天然隔离——你在文件顶层声明的变量不会污染全局。
2. exports vs module.exports:最常见的坑
exports 只是 module.exports 的一个引用,初始指向同一个空对象。直接给 exports 赋值会切断引用,导致外部啥也拿不到:
1
2
3
4
5
6
7
8
9
// ❌ 给 exports 直接赋值,切断了和 module.exports 的引用
exports = { foo: 1 };
// 外部 require 拿到的仍是 module.exports(还是 {}),拿不到 foo
// ✅ 要么往 exports 上加属性
exports.foo = 1;
// ✅ 要么整体替换 module.exports(语义更清晰,推荐)
module.exports = { foo: 1, bar: 2 };
一句话记住:最终导出以 module.exports 为准,两者混用时加属性用 exports.xxx,整体导出用 module.exports =。
3. require 的加载全流程(含缓存)
require('./utils') 背后经历这几步:
- 路径解析:核心模块(
fs/path…)→ 相对/绝对路径 →node_modules逐级向上 - 文件定位:补后缀
.js/.json/.node;目录则找package.json的main或index.* - 缓存检查:命中直接返回
module.exports(所以模块只执行一次) - 创建 Module 实例并先写入缓存:为循环依赖兜底
- 编译执行:
.js读文件后用 vm 包函数执行、.json走JSON.parse、.node走dlopen - 返回
module.exports
1
2
3
4
5
6
const utils = require('./utils');
const again = require('./utils');
console.log(utils === again); // true —— 第二次直接返回缓存
// 想热更新?清掉缓存即可(开发/测试用)
delete require.cache[require.resolve('./utils')];
4. 循环依赖:为什么有时能跑、有时是 {}
两个模块互相 require,Node 用「先缓存后执行」兜底:开始执行 A 时就把 A 的空 module.exports 塞进缓存,A 里 require(B) 触发 B,B 里又 require(A) 时拿到的是还没执行完的 A 的空对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// a.js
console.log('a 开始');
const b = require('./b');
console.log('a 拿到 b:', b);
module.exports = { name: 'A', fromB: b };
// b.js
console.log('b 开始');
const a = require('./a'); // 此时 a 还在执行,module.exports 还是 {}
console.log('b 拿到 a:', a); // {}
module.exports = { name: 'B', fromA: a };
// $ node a.js
// a 开始 → b 开始 → b 拿到 a: {} → a 拿到 b: { name:'B', fromA:{} }
解决方式:
1
2
3
4
5
6
7
// ✅ 延迟访问:等到 a 完全执行完,再取 b 的内容
module.exports = {
name: 'A',
getB() { return require('./b'); } // 此时 b 已就绪,不是 {}
};
// ✅ 更根本:把共同依赖抽成一个独立的 c.js,让 a、b 都去 require c
5. CommonJS vs ESM:双轨时代怎么选
| 维度 | CommonJS | ESM |
|---|---|---|
| 加载时机 | 运行时同步 require | 编译期静态 import |
| 导出语义 | module.exports 的值拷贝 | 实时绑定(live binding) |
| 循环依赖 | 返回「不完整的早期导出」(可能 {}) | 实时绑定,不会拿到 {} |
| Tree Shaking | 不支持 | 支持(利于打包瘦身) |
顶层 this | 指向 module.exports | undefined |
__dirname | 有 | 无(用 import.meta.url 推算) |
在 Node 里用 ESM:文件用 .mjs,或在 package.json 设 "type": "module";既想兼容又想分发包,可用 exports 字段同时指向 CJS 与 ESM 两份产物。
其实你每天都在用
require('express')/import express:就是模块系统在解析路径 + 读缓存- 写
module.exports = router:导出 Koa/Express 的路由模块 npm i lodash后直接 require:node_modules逐级向上查找的功劳- nodemon / 热更新:本质就是清掉
require.cache再重新加载 - Vite / Webpack 打包:把 CJS/ESM 模块图静态分析后合并
.mjs或package.json的type: module:切换到 ESM 的开关
常见误解(FAQ)
❌ 误区一:「import 是 require 的异步版本」
不是。ESM 同样是同步求值,区别在 import 是静态的(编译期就确定依赖、能 Tree Shaking),导出是「实时绑定」——改了原模块的变量,导入方立刻能看到新值。真正异步的是动态 import()。
❌ 误区二:「exports = xxx 就能改导出内容」
这是最高频的坑。直接给 exports 赋值只是让这个局部变量指向新对象,断开了和 module.exports 的引用,最终导出的还是 module.exports。要整体导出就用 module.exports =。
❌ 误区三:「模块里定义的变量会污染全局」
不会。每个文件都被包进一个函数作用域,顶层变量只是函数内的局部变量,互不可见,这正是模块化的意义。
❌ 误区四:「循环依赖一定会报错」
不会。CJS 用「先缓存后执行」兜底,所以循环依赖能跑,只是后加载的一方可能拿到不完整的早期导出(常常是 {});ESM 靠实时绑定更稳。但能跑 ≠ 设计好——紧耦合的相互引用本身就是坏味道,应抽公共模块或用延迟访问。
一句话总结
模块机制的本质就是「文件即模块、缓存即单例、引用要分清」:exports 只是 module.exports 的影子、循环依赖用「先缓存后执行」兜底、CJS 与 ESM 的最大差别是「值拷贝 vs 实时绑定」——把这些点住,模块的坑基本就绕过去了。