这确实是前端历史上最漫长、最痛苦,但也最波澜壮阔的一场“统一战争”。
从 2009 年 Node.js 诞生,到 2025 年彻底完成转型,这场**“CommonJS (CJS) vs ES Modules (ESM)”** 的拉锯战持续了整整 15 年。
这不仅仅是技术之争,更是一场关于标准话语权、向后兼容性与工程哲学的政治博弈。我们可以把这个过程看作是“两个平行宇宙的艰难融合”。
第一阶段:分裂的起源 (2009 - 2015)
“Node.js 被迫造轮子,浏览器在等待戈多”
-
Node.js 的困境: 2009 年,Ryan Dahl 创造 Node.js 时,JavaScript 官方(ECMAScript)压根没有“模块”的概念。JS 只是给网页写写脚本的,谁能想到要用它写服务器?
- 为了让开发者能把代码拆分成不同文件,Node.js 必须自己发明一套标准。
- 于是,CommonJS (CJS) 诞生了。它的标志是
require()和module.exports。 - 特点: 它是同步的。因为服务器读本地硬盘的文件很快,同步加载没问题。
-
浏览器的困境: 浏览器不能用 CJS。因为
require('jquery')在浏览器里意味着要发起网络请求。如果同步等待网络请求,网页就会卡死(白屏)。- 于是,社区发明了 AMD (RequireJS) 和 UMD。
- 局面: 前端写 AMD,后端写 CJS。同一个 JS 语言,分裂成了两种完全不兼容的写法。
第二阶段:一纸空文的“和平条约” (2015)
“ES6 发布,但没人能用”
2015 年,ECMAScript 6 (ES2015) 正式发布。TC39 委员会宣布:我们有了官方的模块标准了!也就是 import 和 export。
大家都以为天亮了,结果却是长达数年的混乱:
- 只是语法,不是实现: ES6 规范只定义了怎么写(Syntax),没定义浏览器和 Node.js 怎么加载(Loader)。
- Node.js 的抗拒: Node.js 团队看了 ES6 标准后非常头大。因为 ESM 本质是静态且异步的,而 Node.js 的 CJS 是动态且同步的。
- CJS:
if (true) { require('./a.js') }—— 代码跑起来才知道引不引用。 - ESM:
import a from './a.js'—— 代码运行前(编译时)就必须确定依赖关系。 - 冲突: Node.js 无法直接支持 ESM,否则会破坏现有的数百万个 CJS 包。
- CJS:
第三阶段:Node.js 的内战与“扩展名之乱” (2016 - 2019)
“要不要用 .mjs?要不要破坏兼容性?”
这是最艰难的时期。Node.js 内部爆发了激烈的争论:如何让 ESM 和 CJS 在同一个运行时里共存?
- 方案 A: 智能识别。读取文件内容,如果有
import就当 ESM,有require就当 CJS。- 失败原因: 性能太差,且有歧义。
- 方案 B(Michael Jackson Script): 强行区分扩展名。CJS 用
.js,ESM 用.mjs。- 社区反弹: 开发者极度反感
.mjs这个丑陋的后缀。大家都在问:“为什么我写的是 JS,却不能用 .js 后缀?”
- 社区反弹: 开发者极度反感
- 妥协方案(
package.json): 最终,Node.js 12+ 引入了一个决定性的字段:"type": "module"。- 如果你在
package.json里写了这一行,整个项目的.js文件都被视为 ESM。 - 这也是今天所有现代项目的标配。
- 如果你在
第四阶段:浏览器的倒逼与 Vite 的助攻 (2019 - 2022)
“浏览器原生支持,Vite 降维打击”
当 Node.js 还在纠结时,浏览器厂商(Chrome, Firefox, Safari)行动了。
<script type="module">被所有主流浏览器支持。- Vite 的出现是转折点: Vite 证明了在开发环境直接使用浏览器原生 ESM 可以达到极致的快。这给了开发者巨大的动力去拥抱 ESM。
- 压力转移: 以前是 Node.js 拖着不改,现在是前端开发者为了用 Vite,倒逼后端的工具库必须提供 ESM 版本。如果不提供,就被视为“过时”。
第五阶段:填平最后一公里 (2023 - 2025)
“require(esm) 的达成与统一”
虽然 ESM 成了主流,但还有一个巨大的痛点:CJS 和 ESM 的互操作性(Interoperability)。
-
痛点: 在 ESM 里可以
import cjs,但在 CJS 里不能require(esm)。因为require是同步的,而 ESM 加载被认为是异步的(Top-level await)。 -
这导致很多旧的 CJS 项目(比如旧的配置文件、CLI 工具)无法使用新的 Pure ESM 包。这被称为 "Pure ESM 之痛"(sindresorhus 等大佬带头只发 ESM 包,导致很多下游项目报错)。
-
2024-2025 的终局:
Node.js 团队终于攻克了最后的难关——同步require(esm)。- 通过对 V8 引擎和加载器的底层改造,Node.js 允许在一定条件下(没有顶层 await 时),让 CJS 代码同步加载 ESM 模块。
- 意义: 这打通了任督二脉。开发者不再需要关心这个包是 CJS 还是 ESM,直接引用即可。生态割裂被物理弥合。
总结:为什么 ESM 必须赢?
这场拉锯战之所以必须打,是因为 ESM 相比 CJS 有着降维打击级的优势,这些优势决定了它是未来的唯一真理:
-
静态分析与 Tree Shaking:
因为import是静态的(写在文件头部,不能放在 if 里),打包工具(Rollup/Vite)可以在代码运行前就画出精确的依赖图。这让“摇树优化”(删掉没用的代码)成为可能。 CJS 做不到这一点,导致包体积无法缩减。 -
异步加载与网络友好:
ESM 是为网络设计的。它支持异步加载,不会阻塞浏览器主线程。CJS 是为硬盘设计的,天生不适合浏览器。 -
万能通用(Isomorphic):
ESM 是 JavaScript 语言标准(ECMA)。它是唯一能同时在浏览器、Node.js、Deno、Bun、Cloudflare Workers 上原生运行的格式。
结语
从 2015 到 2025,JavaScript 社区用了 10 年时间,付出了巨大的迁移成本,终于把地基换了一遍。
现在的开发者是幸福的,因为你们不需要再写 module.exports = ...,也不用再纠结 AMD/UMD 的区别。当你敲下 import 的那一刻,你享受的是无数前人在这场“拉锯战”中争取来的标准化红利。