1. 为什么你总会在 CJS 和 ESM 之间"卡住"?
很多开发者都遇到过这种困惑:
- 为什么
require和import能同时存在? - 为什么 CJS 能动态
require(),而 ESM 不能? - 为什么
module.exports和export default看起来完全不是一回事?
本文通过问题驱动的方式,让我们一起看懂这两者的本质,才能理解为什么 Node.js、TypeScript、构建工具和 NPM 生态里,很多问题都会围绕它们展开。
2. 多维度深度对比 CJS vs ESM
维度 1:模块加载机制
-
CommonJS :
require()是运行时同步调用的函数,允许放置在条件分支或函数内部:typescript// CommonJS:同步动态 require if (process.env.NODE_ENV === 'development') { const devTools = require('./dev-tools'); devTools.init(); } -
ESM:
-
静态导入 (
import ... from ...) :必须写在模块顶层,引擎在代码执行前完成静态解析并构建依赖图谱:typescript// ESM:顶层静态导入 import { initDevTools } from './dev-tools.js'; -
动态导入 (
import()) :提供import()函数用于按需加载。与 CJS 同步的require()不同,import()返回 Promise(异步加载):typescript// ESM:动态条件导入(返回 Promise) if (process.env.NODE_ENV === 'development') { const devTools = await import('./dev-tools.js'); devTools.init(); }
-
问题:为什么 CJS 能直接写 require / module / exports,而 ESM 没有这些"怪东西"?
这其实是两套模块系统的底层设计差异决定的。
CJS 的这几个符号,本质上不是 JavaScript 语言原生语法,而是 Node 在运行时注入的"包装器参数"。每个 CommonJS 文件在加载时,都会被包装成类似这样的函数:
javascript
(function (exports, require, module, __filename, __dirname) {
// 你的代码
});
因此,下面这些东西在 CJS 中会"神奇地出现":
javascript
require
module
exports
__filename
__dirname
它们并不是 ECMAScript 标准规定的语言术语,而是 Node 的模块加载器给每个文件偷偷塞进去的运行时环境。
所以你会看到 CJS 的代码中可以直接写:
javascript
const fs = require('node:fs');
module.exports = { fs };
而这套机制的代价是:
- 模块解析与加载更依赖运行时实现;
require()可以动态发生在任何位置;- 代码很难被静态分析器准确识别和剪枝。
ESM 则完全不同:它是 ECMAScript 的语言级标准,不依赖 Node 运行时向每个文件注入这些私有对象。ESM 的设计目标是"模块语法标准化",因此更强调:
import/export是语言级声明;- 依赖关系在编译/解析阶段就可确定;
- 更好地支持静态分析、Tree-Shaking、跨环境兼容。
所以你在 ESM 里看不到 require、module、exports 这类 Node 私有对象,因为它们根本不是 ESM 的标准语法。
ESM 里要获取文件路径时,通常用的是:
javascript
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
而不是直接使用 CJS 里的 __filename;对应地,模块入口判断也通常要用:
javascript
import { pathToFileURL } from 'node:url';
const isMain =
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href;
一句话概括:
CJS 的
require/module/exports更像"Node 注入的运行时魔法",而 ESM 的import/export更像"语言标准中的模块语法"。
维度 2:Tree-Shaking 可靠性分析
1. 为什么 CommonJS 难以被 Tree-Shaking?
CommonJS 的 module.exports 本质是一个动态的 JavaScript 对象。导出属性支持运行时赋值、条件修改或解构。打包工具(如 Webpack、Rollup)在静态分析阶段无法判断属性是否会被反射(如 Object.keys())、动态计算或在运行时改变。
javascript
// math.js (CommonJS)
function add(a, b) { return a + b; }
function unusedSquare(n) { return n * n; }
// 运行时动态赋值:打包工具无法静态确认 unusedSquare 是否被使用
const exportedMethods = { add };
if (process.env.ENABLE_MATH_EXTRA) {
exportedMethods.unusedSquare = unusedSquare;
}
module.exports = exportedMethods;
消费端:
javascript
// app.js
const math = require('./math');
console.log(math.add(1, 2));
打包工具扫描 app.js 时,只能识别到获取了整个 module.exports 对象。出于引用完整性考虑,打包工具必须保留 math.js 的完整代码。
相对地,ESM 采用静态导出与不可变符号绑定(Live Bindings),打包工具能准确识别未被 import 的符号并清理:
typescript
// math.ts (ESM)
export function add(a: number, b: number) { return a + b; }
export function unusedSquare(n: number) { return n * n; } // 未被 import 则构建时剔除
2. CommonJS 如何实现按需加载?(以 antd v4 为例)
由于 CommonJS 无法自动做到函数级别的 Tree-Shaking,传统的 CommonJS UI 库或工具库(例如 Ant Design v4)采用了以下两种手段进行手动/插件化标记与按需加载:
-
子路径独立导出(Subpath Imports) : 将组件拆分为独立文件,避免直接
require('antd')加载全量代码:javascriptconst Button = require('antd/lib/button'); -
Babel 插件自动重写(
babel-plugin-import) : 为了改善开发体验,antd v4 推荐使用babel-plugin-import插件。开发者源码中依旧写:javascript// 源码 import { Button, DatePicker } from 'antd'; // 转换后 import Button from 'antd/lib/button'; import DatePicker from 'antd/lib/date-picker'; -
package.json中的"sideEffects"声明 : 打包工具读取"sideEffects": false告知打包流程:"未直接引用的子导出无副作用,可跳过处理"。
维度 3:NPM 生态与 Pure ESM 兼容隔离
在 Node.js v22 之前,大量 NPM 基础库(如 node-fetch v3+、chalk v5+、execa v6+)升级为 Pure ESM(不再提供 CJS 产物),导致 CJS 项目中调用出现隔离:
ESM 项目完全兼容 CommonJS 模块
CommonJS 项目调用报错 ERR_REQUIRE_ESM Pure ESM 模块
在旧版 Node.js 中使用 require('node-fetch') 会直接报错:
text
Error [ERR_REQUIRE_ESM]: require() of ES Module node-fetch not supported.
!NOTE 版本演进与破局提示 :在 Node.js v22.0.0+ 中,官方推出了
require(ESM)特性,已部分打破了这一硬性隔离屏障。其原理是Node 在 CJS 的require()里插入了一层"ESM 适配器",让 CJS 能调用 ESM 模块,但前提是 ESM 不能带有异步初始化(Top-Level Await)。
维度 4:循环依赖与 Live Bindings(实时绑定)
1. 值拷贝 (CJS) vs 实时绑定 (ESM)
- CommonJS :
module.exports导出的是执行时刻的值拷贝(针对基本类型)。内部变量变更后,外部导入处不会同步。 - ESM :导出的是内部变量的只读实时绑定(Live Binding)。由于模块单例运行,跨文件
import的变量会同步更新最新值。
javascript
// CJS:值拷贝,无法跨文件同步
// counter.cjs
let count = 0;
function increment() { count++; }
module.exports = { count, increment };
// appA.cjs
const { count, increment } = require('./counter.cjs');
console.log(count); // 0
increment(); // 修改了 counter.cjs 内部的 count,但导出对象的 count 依然是 0
console.log(count); // 仍然是 0 ❌ (无法同步)
// appB.cjs
const counter = require('./counter.cjs');
console.log(counter.count); // 仍然是 0 ❌ (不同文件 require 同样无法同步)
javascript
// ESM:Live Binding,跨文件实时同步
// counter.mjs
export let count = 0;
export function increment() { count++; }
// appA.mjs
import { count, increment } from './counter.mjs';
increment();
console.log('AppA count:', count); // 1
// appB.mjs (在 AppA 后引入)
import { count } from './counter.mjs';
console.log('AppB count:', count); // 1 (实时同步)
2. 循环依赖处理
- CommonJS :依赖动态
require()的同步执行。发生循环引用时,可能返回未执行完的空exports对象{},引发TypeError。 - ESM:代码执行前先构建依赖图谱。只要变量在访问时已完成初始化,即能通过 Live Binding 读取,避免因返回空对象而崩溃。
维度 5:ESM 是如何做到兼容 CJS的?
常见疑问 :如果在 ESM 模块中
import或require了一个内部使用__dirname/__filename的 CommonJS 依赖或本地模块,能否正常兼容运行?
答案:100% 完全兼容!
-
模块包装器(Module Wrapper)隔离 :Node.js 在加载 CommonJS 模块时,始终会使用经典的 CJS 函数包装器将该 CJS 源码包裹执行:
javascript(function (exports, require, module, __filename, __dirname) { // CJS 文件的实际代码,__dirname 是此处注入的形参/局部变量 }); -
独立作用域解析 :CJS 模块内部的
__dirname属于该模块独立的闭包作用域,其值完全由该 CJS 文件在磁盘上的真实物理路径决定,与谁调用/导入了它(无论是 ESM 还是 CJS)毫无关系。因此,ESM 引入任何包含__dirname的 CJS 代码均不会发生作用域冲突或兼容性报错。
| 特性 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 路径变量 | 原生 __dirname / __filename |
无原生变量,需基于 import.meta.url 转换 |
| Top-Level Await | ❌ 不支持(必须包裹在 async 函数内) |
✅ 支持(可在顶层直接 await) |
| 扩展名规范 | 允许省去扩展名 (require('./foo')) |
现代 Node.js 强制保留扩展名 (import './foo.js') |
在 ESM 中实现 __dirname 与 __filename
typescript
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__filename); // /path/to/project/src/index.js
console.log(__dirname); // /path/to/project/src
局限性:
- 打包至非 Node.js 环境(如 Cloudflare Workers、浏览器)时,
import.meta.url可能被替换为绝对 URL,且node:path无法直接运行。 - 全量打包(Bundle)为单文件后,
import.meta.url指向构建后的产物文件路径,丢失原源码文件物理层级。
3. "源码写 ESM,TSC 编译为 CommonJS" 的陷阱
配置 "module": "CommonJS" 让 TypeScript 编译产物存在以下问题:
- 无法解决第三方 Pure ESM 依赖 :TSC 仅转换项目自身代码为
require()。当源码含有import fetch from 'node-fetch'时,编译后变为const fetch = require('node-fetch'),运行时依然引发ERR_REQUIRE_ESM。 - Top-Level Await 编译失败 :TSC 无法将 ESM 的顶层
await语法转换为同步的 CJS 代码。
解决方案 :若需交付 CJS 部署包,应使用 esbuild 或 tsup 进行 Bundling,将第三方 ESM 依赖解包并打入产物中。
4. 问题三:在 ESM 中引用 CommonJS 你需要注意什么?
Node.js 原生支持在 ESM 中导入 CommonJS 模块,但需要注意以下两种导入语法的区别:
1. 默认导入 (Default Import)
Node.js 会自动将 CJS 的 module.exports 挂载至 ESM 的 default 导出:
typescript
import bencode from 'bencode';
const decoded = bencode.decode(buffer);
2. 具名导入 (Named Import)
Node.js 使用 C++ 词法分析器 cjs-module-lexer 扫描 CJS 的 exports.foo = ... 结构。
注意:CJS 具名导入无法实现 Tree-Shaking,且存在隐患。
-
为何不推荐具名导入 :
cjs-module-lexer仅做词法匹配,非语法解析。遇到动态赋值(exports[key] = ...)、Object.assign或导出对象二次加工时,词法分析会失败,抛出SyntaxError: Named export 'foo' not found。此外,不同打包工具的分析规则与原生cjs-module-lexer可能不一致。 -
推荐方案 :采用
默认导入 + 局部解构:typescriptimport bencode from 'bencode'; const { decode, encode } = bencode;
澄清:"默认导入 + 解构" 会导致 Tree-Shaking 失效吗?
CommonJS 的 module.exports 本质是动态对象,打包工具无法对其做函数级死代码剔除。具名导入 CJS 仅是语法糖,打包工具编译后依然载入整个 CJS 模块。因此,使用默认导入解构不会增加体积包袱。
5. ESM 与 V8 字节码缓存 (Bytecode Cache)
- 冷启动性能 :Node.js 对 CJS 和 ESM 均支持 V8 字节码内存与磁盘缓存。Node.js v22+ 启用
NODE_COMPILE_CACHE=1或process.enableCompileCache()可自动将编译字节码写入磁盘缓存。 - 代码加密保护 :
bytenode依赖 CJS 函数包装器,不支持 ESM 源码直接生成.jsc。纯 ESM 项目需先使用esbuild打平为单文件 CJS,再通过 V8 字节码工具编译。
6. 模块选型建议
- 新建 TypeScript / 服务端 / CLI 项目 :首选全链路纯 ESM(配置
"type": "module"与"module": "NodeNext")。 - 维护既有 CommonJS 项目 :升级至 Node.js v22+ 利用
require(ESM);涉及 Top-Level Await 依赖时使用动态import()。 - 需兼容旧版 Node.js 环境 :使用 TypeScript +
esbuild/tsup交付 Bundled CJS 产物。
7. 全面理解 require.main === module
这句代码看起来像"比较两个对象是否相等",但它其实是 Node.js CommonJS 模块系统中的一个非常关键判断:
javascript
require.main === module
它的意思是:
"当前这个模块是不是程序启动时的入口模块?"
如果为 true,说明这个文件就是被 node xxx.js 直接执行的那个入口;如果为 false,说明它只是被其它文件 require() 进来的依赖模块。
1. require 不是"只有函数",它本身也是对象
在 Node.js 中,require 看起来像一个函数:
javascript
require('./foo.js');
但它其实是一个"函数对象",除了能调用之外,还挂着很多属性:
javascript
console.log(typeof require); // 'function'
console.log(require.main); // 入口模块 或 undefined
console.log(require.cache); // 缓存对象
console.log(require.resolve('./foo.js')); // 解析路径
也就是说:
require是一个函数- 它同时也是一个对象
require.main是它身上的一个属性
这和 JavaScript 里"函数也是对象"是完全一致的。
2. module 是当前模块的描述对象,不是导出对象
module 是 Node 自动注入给每个 CommonJS 文件的对象,代表"当前模块"。
它最核心的字段包括:
javascript
console.log(module.id);
console.log(module.filename);
console.log(module.parent);
console.log(module.children);
console.log(module.loaded);
console.log(module.exports);
其中:
module.exports:最重要,决定这个模块对外暴露什么module.filename:当前文件的绝对路径module.parent:谁依赖了它module.children:它依赖了哪些模块module.loaded:是否完成加载
注意:module 不是"导出值本身",而是"当前模块的容器对象";真正对外导出的是 module.exports。
3. require.main 指向的是"程序入口模块"
Node.js 在启动时,会记录一个"主模块"对象,挂在 require.main 上:
javascript
console.log(require.main === module); // true 仅在入口文件中
举例:
javascript
// app.js
console.log(require.main === module); // true
当你执行:
bash
node app.js
app.js 就是入口模块,所以它里面的 module 和 require.main 指向同一个对象。
但如果 app.js 被另一个模块引入:
javascript
// index.js
require('./app.js');
那么在 app.js 内部:
javascript
console.log(require.main === module); // false
因为它不是入口,而只是被其它代码要求加载的模块。
4. 为什么它常被用来做"脚本入口判断"?
这是最经典的 CommonJS 用法:
javascript
if (require.main === module) {
startServer();
} else {
module.exports = { startServer };
}
它的目的非常清晰:
- 如果这个文件是直接运行的脚本,就启动程序
- 如果它被别的模块
require(),就只暴露接口,不要自动执行
这是一种非常常见的"库/脚本双模式"设计。
5. module.exports 与 exports 的关系
许多人容易混淆:
javascript
module.exports = { a: 1 };
和:
javascript
exports.a = 1;
两者都能输出东西,但它们并不是同一个引用。
核心注意点:
module.exports是真正的导出对象exports实际上是module.exports的快捷别名(初始化时指向同一个对象)- 一旦你重新赋值
module.exports = ...,exports就不再和它等价
javascript
exports.foo = 1; // 正常追加属性
module.exports = { bar: 2 }; // 直接替换整个导出对象
这个细节经常是 CommonJS 新手踩坑的地方。
6. require.main === module 的本质:判断"我是不是入口"
把它抽象成一句话:
javascript
require.main === module
等价于:
"当前这个模块是不是 Node 进程启动时的主模块?"
它并不表示"这个对象里有个 main 字段",而是:
require是一个带属性的函数对象require.main是它身上的入口引用module是当前模块对象- 如果两者相同,说明当前文件就是启动入口
7. ESM 中如何实现类似逻辑?
ESM 没有 require / module 这两个变量,因此不能直接写:
javascript
require.main === module
在 ESM 中,通常使用的是:
javascript
import { pathToFileURL } from 'node:url';
const isMain =
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
console.log('我是入口文件');
}
它的语义是:
"当前模块的 URL,是否等于 Node 启动脚本的 URL?"
也就是在 ESM 里模拟"是不是主入口"这个判断。
8. 一句话总结
require.main === module 的真实含义就是:
这个文件是不是被直接执行启动的入口文件,而不是被别人
require()/import进来的模块。
它背后依赖的知识点包括:
require是函数对象,并挂有main、cache等属性module是当前模块对象,并有exports、filename、children等字段module.exports才是模块真正暴露出去的内容- ESM 没有
require.main,所以需要借助import.meta.url和process.argv[1]去模拟类似逻辑
这也是为什么它在 CommonJS 里如此经典:它非常直观地刻画了"脚本入口"和"可复用模块"的边界。