这确实是前端历史上最漫长、最痛苦,但也最波澜壮阔的一场“统一战争”

这确实是前端历史上最漫长、最痛苦,但也最波澜壮阔的一场“统一战争”。

从 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 委员会宣布:我们有了官方的模块标准了!也就是 importexport

大家都以为天亮了,结果却是长达数年的混乱:

  1. 只是语法,不是实现: ES6 规范只定义了怎么写(Syntax),没定义浏览器和 Node.js 怎么加载(Loader)。
  2. 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 包。

第三阶段: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 有着降维打击级的优势,这些优势决定了它是未来的唯一真理:

  1. 静态分析与 Tree Shaking:
    因为 import 是静态的(写在文件头部,不能放在 if 里),打包工具(Rollup/Vite)可以在代码运行前就画出精确的依赖图。这让“摇树优化”(删掉没用的代码)成为可能。 CJS 做不到这一点,导致包体积无法缩减。

  2. 异步加载与网络友好:
    ESM 是为网络设计的。它支持异步加载,不会阻塞浏览器主线程。CJS 是为硬盘设计的,天生不适合浏览器。

  3. 万能通用(Isomorphic):
    ESM 是 JavaScript 语言标准(ECMA)。它是唯一能同时在浏览器、Node.js、Deno、Bun、Cloudflare Workers 上原生运行的格式。

结语

从 2015 到 2025,JavaScript 社区用了 10 年时间,付出了巨大的迁移成本,终于把地基换了一遍。

现在的开发者是幸福的,因为你们不需要再写 module.exports = ...,也不用再纠结 AMD/UMD 的区别。当你敲下 import 的那一刻,你享受的是无数前人在这场“拉锯战”中争取来的标准化红利