文章

模块机制深度解析

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') 背后经历这几步:

  1. 路径解析:核心模块(fs/path…)→ 相对/绝对路径 → node_modules 逐级向上
  2. 文件定位:补后缀 .js / .json / .node;目录则找 package.json 的 main 或 index.*
  3. 缓存检查:命中直接返回 module.exports(所以模块只执行一次)
  4. 创建 Module 实例并先写入缓存:为循环依赖兜底
  5. 编译执行:.js 读文件后用 vm 包函数执行、.json 走 JSON.parse、.node 走 dlopen
  6. 返回 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:双轨时代怎么选

维度CommonJSESM
加载时机运行时同步 require编译期静态 import
导出语义module.exports 的值拷贝实时绑定(live binding)
循环依赖返回「不完整的早期导出」(可能 {})实时绑定,不会拿到 {}
Tree Shaking不支持支持(利于打包瘦身)
顶层 this指向 module.exportsundefined
__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 实时绑定」——把这些点住,模块的坑基本就绕过去了。

本文由作者按照 CC BY 4.0 进行授权