我去年参加过一次技术方案评审,两个开发者为了模块化选型差点吵起来。一个说"我要把整个项目迁到 ES Module,老 CommonJS 该淘汰了",另一个说"我们生产环境跑了六年的 CommonJS,你凭什么说它不行"。我当时坐在中间,脑子里闪过的其实是另一个问题——这两个人说的根本不是一个东西。CommonJS 诞生于 Node.js 的服务端场景,ES Module 是 ECMAScript 标准给出的语言级方案,两者连"加载模块"这件事的底层逻辑都不一样,单纯说谁好谁坏没有意义。
这篇文章我想把两者从头到尾拆开讲一遍:为什么会有这两套系统、它们的加载机制究竟差在哪、循环依赖这个老大难问题两边怎么处理、在 Node.js 项目里混用它们有哪些坑,以及最实际的——新项目到底该选哪个。内容面向写过 require 或者 import、但没系统捋过模块化机制的 JavaScript 开发者,看完你应该能自己做选型,而不是跟着网上争论站队。
1. 模块化之争的前世今生:CJS 和 ESM 各自解决了什么问题
1.1 script 标签时代与 IIFE:模块化需求是怎么被逼出来的
在 CommonJS 出现之前,浏览器里写 JavaScript 基本靠多个 script 标签按顺序加载。你今天写一个工具函数,明天同事也写一个同名函数,后加载的覆盖先加载的,查 bug 查到抓狂。更麻烦的是依赖顺序完全靠人肉维护——jQuery 必须在插件之前加载,插件必须在业务代码之前加载,顺序一乱全线崩溃。
那个年代的前端开发者用各种办法自救。比较经典的是 IIFE(立即执行函数表达式),把变量关进函数作用域里,只把要暴露的东西挂到 window 上:
javascript复制(function (global) {
var count = 0;
function increment() {
count++;
}
global.Counter = { count: function () { return count; }, increment: increment };
})(window);
这样一来全局变量少了,但依赖管理仍然没有根治。后来陆续出现了 AMD、CMD、RequireJS、SeaJS 这些浏览器端方案,各自设计了一套异步模块加载机制,但终究是社区自发的"补丁",不是语言层面的答案。
1.2 CommonJS 的诞生逻辑:给 Node.js 一个"服务器端"的答案
2009 年 Node.js 刚出来的时候,作者面临一个核心问题:Node 是运行在服务器上的 JavaScript 环境,它必须能管理大量文件依赖,而且服务器读本地文件速度很快,根本不需要像浏览器那样担心网络延迟。
于是 CommonJS 被设计出来,核心思路非常朴素:每个文件是一个模块,模块内部定义的变量不会污染全局;要暴露内容就赋值给 module.exports;要使用别的模块就调用 require()。并且 require 是同步的——执行到哪一行,就同步加载那个文件,加载完继续往下走。服务器启动时读的都是本地磁盘文件,同步加载完全没问题,反而让代码执行顺序极其好理解。
javascript复制// math.cjs
module.exports = {
add: function (a, b) {
return a + b;
}
};
// main.cjs
const math = require('./math.cjs');
console.log(math.add(1, 2));
这套机制简单、直接、可靠,随 Node.js 一起迅速铺开。npm 生态里绝大多数老包都是这种模块格式。
1.3 ES Module 为什么是"标准答案":浏览器与语言的统一
CommonJS 好用,但它有两个天然问题:第一,它是 Node.js 社区标准,不是 ECMAScript 语言标准,浏览器端没法原生使用;第二,require 接收的可以是一个变量,模块路径在运行时才确定,这让工具链做不了静态分析。
2015 年 ECMAScript 6 正式把模块机制写进语言标准,也就是 ES Module。它设计成静态结构:import 和 export 必须写在模块顶层,导入导出的名称在编译阶段就完全确定。这种设计带来两个直接收益:一是浏览器可以在网络下载阶段并行解析模块依赖,天然支持异步;二是打包工具可以静态分析哪些导出被使用了,没用的代码直接丢掉。同时语法上做到了浏览器和 Node.js 统一,一套 import/export 通吃。
所以你会发现,CJS 和 ESM 的差异根源不在语法好看难看,而在设计目标:CJS 是给"服务器运行时"设计的同步方案,ESM 是给"语言标准和浏览器"设计的静态化异步方案。这个根子决定了后面所有行为差异。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 加载时机与执行语义:同步 vs 异步、静态 vs 动态
2.1 require 的"运行时加载"到底意味着什么
require 本质上是一个运行时函数,执行到它那一行才会去加载模块。这意味着你可以把它写在 if 条件里,放在函数内部,甚至用变量动态拼接模块路径:
javascript复制if (process.env.NODE_ENV === 'production') {
const logger = require('./logger.prod.cjs');
logger.init();
} else {
const logger = require('./logger.dev.cjs');
logger.init();
}
这个能力在写工具脚本时非常方便。而且 CommonJS 模块第一次被 require 后,Node 会把它缓存起来(存在 require.cache 里),之后的 require 直接返回缓存对象,不会重复执行模块内部代码。如果你改过模块文件想强制刷新缓存,还得手动删除 require.cache 里的对应条目。
但"灵活"的另一面是"不可预测"。因为模块路径和导出字段都可以在运行时动态决定,打包工具(Webpack、Rollup 等)在做静态分析时,面对 CJS 模块往往需要做额外转换,甚至只能保守处理——把所有代码都打包进去,因为它不知道你到底会用到哪个导出。
2.2 import 的"编译期决议"如何影响你的代码
ES Module 的 import 是静态声明,不是函数。它会被提升到模块顶部,并且在模块真正执行前,JavaScript 引擎就会先解析出完整的依赖关系图。所以 import 语句不能写在 if 或函数里,这是语法层面就禁止的:
javascript复制// 这是语法错误
if (someCondition) {
import { readFile } from 'node:fs';
}
如果确实想按条件动态加载模块,ESM 提供了独立的 import() 函数,返回一个 Promise,可以在任何地方调用:
javascript复制if (someCondition) {
const { readFile } = await import('node:fs');
readFile('/path/to/file', 'utf8', (err, data) => {});
}
静态声明的意义在于,引擎在运行代码之前就已经知道所有模块依赖,可以做三件 CJS 做不到的事:并行加载模块文件、验证导入导出名称是否匹配、建立稳定的模块执行顺序。前端打包工具也是靠这个"静态可见性"才能实现 Tree Shaking——未使用的导出在构建阶段就被安全删掉。
2.3 live binding 与值拷贝:一个 counter 例子讲透
先看一段 ESM 代码:
javascript复制// counter.mjs
export let count = 0;
export function increment() {
count++;
}
javascript复制// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1
在 ESM 里,count 是一个实时绑定(live binding)。increment 函数修改了模块内部的 count,外部 import 到的 count 会同步变化,因为 import 导入的是"绑定引用",不是值的快照。
同样的逻辑,换成 CJS:
javascript复制// counter.cjs
let count = 0;
function increment() {
count++;
}
module.exports = { count, increment };
javascript复制// main.cjs
const { count, increment } = require('./counter.cjs');
console.log(count); // 0
increment();
console.log(count); // 0
第二次打印还是 0。因为在 require 执行的那一刻,module.exports 对象上挂载的 count 属性已经固定成了当时的原始值 0,increment 是闭包函数,它内部确实改了 count,但外部解构拿到的 count 属性已经和内部变量脱钩了。你拿到的是一份导出瞬间的快照。
这个差异在大型项目里会造成非常隐蔽的 bug。有同事抱怨"模块里的状态在外面怎么看不到",十有八九就是这种快照问题。ESM 的 live binding 在语义上更精确——导入方看到的一定是模块内部当前的真实状态。
我把两者的核心差异整理成一张表,方便对照:
| 维度 | CommonJS | ES Module |
|---|---|---|
| 加载方式 | 运行时同步加载 | 编译期静态解析,运行时异步加载 |
| 语法 | require / module.exports | import / export |
| 依赖路径 | 可以动态拼接变量 | 静态字符串(动态用 import()) |
| 导出值 | 值快照(拷贝) | live binding(实时引用) |
| 缓存 | require.cache | 模块映射表(不可直接操作) |
| 顶层 this | module.exports | undefined |
| 严格模式 | 默认非严格 | 自动启用严格模式 |
| Tree Shaking | 基本不可用 | 天然支持 |
| 条件加载 | 支持 | 不支持(只能用 import()) |
| 循环依赖 | 给出部分加载的模块对象 | 通过绑定引用,但可能抛 TDZ 错误 |
3. 语法与运行时对象:不只是 import/export 写法不同
3.1 导出和导入的写法对比
CJS 的导出有几种写法,容易踩坑的是 exports 和 module.exports 的混用。简单说,exports 只是 module.exports 的别名,如果你给 exports 重新赋值,就等于切断了这个别名,原来的 module.exports 还是空对象:
javascript复制// 错误示范
exports.a = 1;
exports = { b: 2 }; // 这行让 exports 指向了新对象,module.exports 仍然是 { a: 1 }
// 正确做法
module.exports = { a: 1, b: 2 };
ESM 的导出则更清晰,具名导出和默认导出分开:
javascript复制// utils.mjs
export const VERSION = '1.0.0';
export function format(str) { return str.trim(); }
export default class Utils {
static helper() {}
}
导入时默认导入和具名导入可以混用,但默认导入只有一个。比较特殊的一个点是:ESM 中 import 导入的绑定是只读的。也就是说你读到模块导出的变量,但不能给这个绑定重新赋值:
javascript复制import { VERSION } from './utils.mjs';
VERSION = '2.0.0'; // TypeError: Assignment to constant variable
这其实是个很合理的设计,模块的导出应该由模块自身维护,外部只能消费,不能随意修改。
3.2 __dirname 消失后怎么办
CJS 里每个模块都有 __dirname 和 __filename,指出当前文件所在目录和完整路径,这是 Node 注入的基本变量。但 ESM 里这两个变量不存在,因为 ESM 是标准化的模块系统,不能依赖 Node 注入的运行时变量。替代方案是 import.meta.url:
javascript复制import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
每次迁移到 ESM 都要先补这段样板,不然读文件、拼路径全部报错。我在迁移一个老项目时,全局搜 __dirname 搜出来五十多处,一次性替换成工具函数后清爽很多。更推荐的做法是封装成一个常量模块,全项目统一引:
javascript复制// globals.mjs
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const currentFile = fileURLToPath(import.meta.url);
export const currentDir = dirname(currentFile);
3.3 JSON 模块导入和顶层 await:ESM 的现代能力
CJS 里直接 require('./data.json') 就能拿到对象,这是 Node 内建支持。ESM 里就没这么顺了——早期直接 import JSON 会报错。现在的做法是用 import attributes,但语法在不同 Node 版本间有变化,用之前要查文档。更稳妥的方案是用 fs 自己读:
javascript复制import { readFileSync } from 'node:fs';
const data = JSON.parse(readFileSync(new URL('./data.json', import.meta.url), 'utf8'));
另一个 ESM 独有能力是顶层 await。CJS 模块顶层不能用 await,必须包一层 async 函数。ESM 由于模块本身支持异步加载,可以直接在顶层写 await:
javascript复制// data.mjs
const response = await fetch('https://api.example.com/data');
export const data = await response.json();
这个能力在初始化阶段需要拉远程配置时非常有用。但注意不要滥用,顶层 await 会阻塞依赖该模块的其他模块执行,加载链路长的话会影响启动速度。
3.4 Node 原生 ESM 的"挑剔"之处
在 Node.js 里用 ESM,你会发现它比 CJS 苛刻很多。最典型的是相对导入必须写完整扩展名:
javascript复制// 这样会报错
import { helper } from './utils';
// 必须写成
import { helper } from './utils.mjs';
因为 ESM 的解析算法和 CJS 不同,不会自动尝试 .js、.json、.node 这些扩展名。浏览器原生 ESM 也有同样要求,这是为了让加载器不依赖文件系统探测,直接通过完整 URL 加载。用了几年 ESM 之后我已经习惯了,但在和别人协作时,这可能成为最常见的编译报错来源。
4. 循环依赖:两者处理哲学的"高下之分"
4.1 CJS 循环依赖会发生什么:半成品对象
循环依赖在大型项目里是绕不开的现实问题。两个模块互相引用,谁先被加载,谁就只能拿到对方的"半成品"。看一个最经典的例子:
javascript复制// a.cjs
console.log('a 开始');
const b = require('./b.cjs');
console.log('a 调用 b.done:', b.done);
exports.done = true;
console.log('a 结束');
javascript复制// b.cjs
console.log('b 开始');
const a = require('./a.cjs');
console.log('b 调用 a.done:', a.done);
exports.done = true;
console.log('b 结束');
运行 node a.cjs,输出是:
code复制a 开始
b 开始
b 调用 a.done: undefined
b 结束
a 调用 b.done: true
a 结束
b.cjs 在执行时 require 了 a.cjs,但此时 a.cjs 只执行到第一行,exports 上还没有 done 属性,所以 a.done 是 undefined。这就是 CJS 循环依赖的经典问题——导出对象是动态填充的,加载到一半的模块就是一个半成品对象。
4.2 ESM 循环依赖为什么是严格报错而不是给脏数据
同样的逻辑换成 ESM:
javascript复制// a.mjs
console.log('a 开始');
import { done as bDone } from './b.mjs';
console.log('a 调用 b.done:', bDone);
export const done = true;
console.log('a 结束');
javascript复制// b.mjs
console.log('b 开始');
import { done as aDone } from './a.mjs';
console.log('b 调用 a.done:', aDone);
export const done = true;
console.log('b 结束');
运行 node a.mjs,你会看到:
code复制b 开始
ReferenceError: Cannot access 'done' before initialization
为什么连"a 开始"都没打印?因为 ESM 的执行流程分三个阶段:解析(parse)→ 实例化(link)→ 求值(evaluation)。入口 a.mjs 解析完,发现依赖 b.mjs,于是先去加载 b.mjs;b.mjs 又依赖 a.mjs,但 a.mjs 已经在实例化阶段建立了所有导出绑定(只是值还没赋值)。所以 b.mjs 可以引用 a.mjs 导出的 done,但此时 done 还处在暂存死区(TDZ)里,一访问就抛 ReferenceError。
对比下来你会发现一个很有意思的哲学差异:CJS 在循环依赖时给你一个 undefined 的"半成品",程序继续往下跑,bug 可能要到业务层才暴露;ESM 直接抛错,把问题暴露在模块加载的最早期。从工程角度,报错永远比静默的脏数据容易排查。ESM 这种"先实例化后求值"的设计,保证了你拿到的引用一定是同一个绑定,而不像 CJS 那样拿到的是某个中间状态的拷贝。
4.3 还在写循环模块?架构问题的信号
虽然 ESM 对循环依赖的语义处理更严谨,但我不建议你把循环依赖当成一种可以安心使用的特性。循环依赖几乎总是说明模块职责划分不合理。A 依赖 B,B 依赖 A,通常意味着它们共享的公共逻辑应该抽到第三个模块里。
我在项目里会刻意遵循几条规则:核心逻辑模块不依赖具体业务模块;数据模型和工具函数保持无副作用;如果两个模块必须互相调用,考虑用事件总线、依赖注入或回调函数打破直接引用。实在没法避免的循环,加注释说明为什么,并在测试里确保初始化顺序正确。
5. Node.js 环境里的互操作:把两个体系拧在一起的实用方案
5.1 package.json 的 type 字段与 .mjs/.cjs 扩展名
现实世界不是非黑即白,很多 Node.js 项目里 CJS 和 ESM 会长期共存。Node.js 用两种方式区分一个 .js 文件的模块类型:扩展名和最近的 package.json 里的 type 字段。
| 文件扩展名 | package.json 中 type 字段 | 实际模块类型 |
|---|---|---|
| .js | 未设置或 commonjs | CommonJS |
| .js | module | ES Module |
| .cjs | 任意 | CommonJS |
| .mjs | 任意 | ES Module |
规则很简单:.mjs 和 .cjs 是强制显式声明,不受 package.json 影响;.js 则由最近一层 package.json 的 type 决定。新项目想全面使用 ESM,直接在 package.json 里写 "type": "module" 即可;存量项目不想动,保持默认 commonjs 就好。
5.2 动态 import() 与 createRequire:双向打通
CJS 模块里想加载 ESM 模块,旧版本的 Node 只能用动态 import()。因为 CJS 是同步加载,ESM 是异步的,require 无法直接同步加载 ESM:
javascript复制// 在 CJS 模块里
async function loadEsmModule() {
const mod = await import('./esm-module.mjs');
return mod;
}
反过来,ESM 模块里想用 require 加载 CJS 包,Node 提供了 createRequire 方法:
javascript复制import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const oldPackage = require('some-old-cjs-package');
这个方法很有用,尤其是当你正在渐进式迁移,不想一次性把所有依赖都换成 import 时,createRequire 可以当"逃生舱"。不过它本质上是绕过机制,迁移完成后还是应该清理掉。
5.3 条件导出:让你的 npm 包同时兼容两种消费方式
如果你在维护一个 npm 包,最稳妥的做法是同时提供 CJS 和 ESM 两个入口,让使用者按自己的模块系统选择。package.json 的 exports 字段支持条件导出:
json复制{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
这样 import 和 require 都能正确加载各自的版本。构建常见方案是源码用 ESM,发布前用 tsup / rollup / esbuild 打出 .mjs 和 .cjs 两种产物。
这里有个大坑叫double package hazard。当同一个包同时被打包成 CJS 和 ESM 两份,并且被应用里不同模块分别用 require 和 import 加载时,实际上会加载两份独立的模块实例。模块内部的全局状态被复制成两份,instanceof 判断失效,共享对象的引用断裂,排查起来极其痛苦。尽量避免在同一个应用里同时通过两种方式加载同一个库,如果无法避免,确保库的设计是无状态或单例模式。
5.4 在 ESM 里导入 CJS 包:默认导入与具名导入的坑
ESM 导入 CJS 包时,默认导入拿到的就是 module.exports 这个整体对象:
javascript复制import oldPackage from 'some-old-cjs-package';
// oldPackage 相当于 require('some-old-cjs-package')
具名导入则是 Node 通过 cjs-module-lexer 对 CJS 源码做静态分析来识别导出的。大多数规范的 module.exports = { a, b } 或 exports.a = ... 都能被识别出来:
javascript复制import { a, b } from 'some-old-cjs-package';
但如果 CJS 包的导出是动态生成的,比如用循环或者模板字符串拼属性名,静态分析就会失效,具名导入拿到 undefined,只能退回默认导入再解构:
javascript复制import oldPackage from 'some-old-cjs-package';
const { a, b } = oldPackage;
我曾经被一个老包坑过,文档说支持具名导出,但实际 module.exports 是运行时生成的,import 之后所有具名绑定全是 undefined,排查了很久才想到去看生成的模块代码。遇到行为异常时,优先怀疑这种静态分析失败的场景。
6. 选型与迁移策略:到底什么时候用哪个
6.1 Tree Shaking 与工程链:ESM 对构建工具的"友好度"
现代前端工程链(Webpack、Vite、Rollup)都深度依赖 ESM 的静态结构来做优化。Tree Shaking 能成立,前提就是模块的导入导出在编译期完全确定——哪个导出被使用、哪个没被使用,打包器一目了然,没用的代码就能安全剔除。
CJS 在这方面天然吃亏,因为 module.exports[someVariable] = ... 这种动态导出让打包器没法判断最终导出集合。Webpack 对 CJS 模块常用"魔法注释"或者自动分析做转换,但总有边界情况导致打包结果偏大。只要你用前端构建工具,优先写 ESM 基本是共识。
Node.js 服务端对 Tree Shaking 的需求弱一些,因为没有"减少客户端体积"的压力。但 ESM 的静态结构让 Node 在启动时也能提前建立模块依赖图,配合现代 V8 引擎做优化,整体趋势是 ESM 逐渐追平甚至反超 CJS 的启动性能。早期确实有人测出 Node 的 ESM 启动明显慢于 CJS,但在 Node 20 之后的版本里,这个差距已经大幅缩小。如果你的应用有极端性能要求,建议用实际项目做一次基准测试,别盲目抄网上结论。
6.2 三种典型场景的选择参考
| 场景 | 推荐模块系统 | 理由 |
|---|---|---|
| 全新 Node.js 服务端项目 | ESM | Node 已成熟支持,现代技术栈默认选择 |
| 浏览器前端项目 | ESM | 原生支持、Tree Shaking、与构建工具无缝配合 |
| 通用 npm 库 | 双格式(exports 条件导出) | 同时服务 CJS 和 ESM 消费者 |
| 存量 CJS 老项目 | 保持 CJS,渐进迁移 | 稳定优先,避免大范围回归 |
| 浏览器端 CI/CD 脚本 | CJS | 简单直接,兼容性最好 |
6.3 渐进迁移 CJS 到 ESM 的实操清单
如果你有一个老项目决定迁移到 ESM,别指望一次切换成功。我的做法是按下面的顺序渐进推进:
- 升级 Node.js 到当前 LTS 版本(至少 Node 20+),确保 ESM 特性都是稳定支持的。
- 在 package.json 里添加
"type": "module",观察哪些文件立刻报错。 - 全局搜索 __dirname、__filename,替换为 import.meta.url 方案(封装成公共模块)。
- 搜索 require( 的调用,优先把静态的 require 替换成 import,动态的 require 替换成 import()。
- 检查 JSON 导入,CJS 的 require('./data.json') 换成文件读取方案或 import attributes。
- 检查循环依赖,用 madge 这类工具扫描模块依赖关系,尽量先理清再迁移。
- 清理 createRequire 临时方案,确保最终代码不依赖"逃生舱"。
- 完整跑一遍测试和构建,重点观察打包体积和启动时间变化。
其中最容易忽略的是第 5 步 JSON 导入,很多项目只在配置加载时用了一次 json,迁移时漏掉就报找不到模块。第 6 步循环依赖如果架构没理清楚,迁移到 ESM 后那些原本只是"半成品 undefined"的问题会直接变成启动时抛错,反而倒逼你把模块结构修正——这其实是好事。
最后聊点个人经验。我现在的新项目默认用 ESM,不是因为"新"就一定好,而是构建工具、框架生态、Node 官方都已经全面拥抱 ESM,顺着生态走最省力。但我不会为了迁而迁——存量 CJS 项目如果稳定运行、没有明显的工程化痛点,保留 CJS 完全合理。真正要做的判断,不是"哪个语言特性更好",而是"我的运行环境、工具链、团队习惯,更匹配哪一个"。模块化是代码组织的地基,地基喜欢稳定,不喜欢盲目折腾。
