从
require到import,Node.js 花了 14 年。这场漫长的迁移尚未结束,但终局已隐约可见。
一、问题的根源:一次"先上车后补票"的技术债
2009 年,Node.js 诞生时,ECMAScript 还没有官方的模块系统。Ryan Dahl 不得不自创 CommonJS(CJS) ------require 和 module.exports 这对组合,成了 Node 开发者最熟悉的语法。
2015 年,ES6 正式发布 import/export 语法,浏览器和 Node 都意识到:这才是 JavaScript 的未来。但问题在于:Node 已经跑了 6 年,npm 上积累了数十万个 CJS 包。不可能一刀切。
于是 Node 团队选择了最务实的方案:兼容并存。2019 年 Node 13 正式支持 ESM,但留下了两个致命的设计:
- 文件扩展名决定模块类型 :
.js默认 CJS,.mjs强制 ESM,.cjs强制 CJS package.json的type字段 :"type": "module"让.js变成 ESM,否则默认 CJS
这个设计本意是"平滑过渡",实际却制造了更大的混乱。
二、现状:开发者每天都在面对的三种痛苦
痛苦 1:"我的项目到底用什么?"
新建项目时,第一个问题不是"用什么框架",而是"模块化选 CJS 还是 ESM"。
选 CJS?新库已经不给 CJS 入口了。选 ESM?老依赖可能出问题。更魔幻的是,一个项目里可能同时存在三种文件:
go
project/
├── src/
│ ├── index.js # 看 package.json 的 type 决定格式
│ ├── utils.mjs # 强制 ESM
│ └── legacy.cjs # 强制 CJS
├── package.json # "type": "module" 或 不写
└── tsconfig.json # "module": "NodeNext" / "CommonJS" / "ES2020" ?
TypeScript 的编译目标还要再乘上几种组合,调试时_source map_指向的可能是转换后的代码,栈追踪行号对不上,开发者花在"配置模块化"上的时间,往往超过写业务逻辑。
痛苦 2:"import 一个 CJS 包,为什么解构不出来?"
Node 确实实现了 ESM 加载 CJS 的兼容,但有一个隐蔽的坑:命名导出。
javascript
// 某个 CJS 包的内部实现
exports.foo = function() {};
exports.bar = function() {};
// 你在 ESM 项目里这样写:
import { foo, bar } from 'legacy-pkg'; // ❌ undefined
// 必须这样:
import pkg from 'legacy-pkg';
const { foo, bar } = pkg; // ✅ 正常
原因是 CJS 的 exports 是运行时动态对象,ESM 的静态分析无法提前识别命名导出。Node 只能把整个 module.exports 包装成 default 导出。这不是 bug,是设计上的根本差异。
痛苦 3:"库作者的双格式地狱"
负责任的 npm 包作者,现在要在 package.json 里写这样的配置:
json
{
"name": "modern-lib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}
构建流程要跑两次:一次用 Rollup/esbuild 生成 ESM,一次生成 CJS。类型定义文件(.d.ts)还要想办法兼容两种格式。很多小库作者直接放弃,只发 ESM 版本,留下 CJS 用户在 GitHub Issue 里骂街。
三、Node 团队的应对:没有银弹,只有补丁
Node 并非没有尝试解决问题。过去几年,官方推出了几项关键改进:
1. createRequire ------ ESM 里的逃生舱
javascript
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
// 在 ESM 文件里临时用 require,解决某些 CJS 包的兼容问题
const legacy = require('some-old-package');
这是一个务实的妥协:承认 CJS 生态不可能消失,给 ESM 用户开一扇后门。
2. 更智能的 ESM Loader
Node 20+ 改进了 ESM 加载 CJS 的转换逻辑,对 module.exports = function() 这种最常见的情况,默认导出更稳定。命名导出的问题虽然还在,但至少不会直接报错。
3. package.json 的 exports 字段标准化
exports 让库作者可以精确控制不同模块系统的入口,比过去的 main/module 双字段更清晰。但它也引入了新的复杂度:条件导出(node、import、require、default)的优先级规则,足以让新手崩溃。
四、未来:终局是 ESM,但 CJS 会成为"永久遗产"
判断未来不需要猜,看几个信号就够了:
信号 1:新工具默认 ESM
Vite、Vitest、Nuxt、Astro、Deno、Bun......新一代工具链要么只支持 ESM,要么把 ESM 作为默认。甚至 Node 自己的文档,示例代码也优先用 import。
信号 2:Node 官方表态
Node 核心维护者多次在 Issue 中明确表示:ESM 是战略性方向,CJS 进入维护模式。这意味着新特性(如顶层 await、import 断言)优先给 ESM,CJS 不再进化。
信号 3:时间站在 ESM 这边
CJS 生态的"沉默大多数"是那些不再维护的老包。它们不会自己变,但也不会增长。新包几乎都是 ESM,存量 CJS 包的比例每年递减。这是一个缓慢的"自然死亡"过程。
终局预测(2026-2032)
| 阶段 | 时间 | 特征 |
|---|---|---|
| 并存期 | 现在-2028 | 双格式是标配,工具链同时支持 |
| 倾斜期 | 2028-2030 | CJS 被视为"遗产",新工具可能不再内置 CJS 优化 |
| 终局期 | 2030+ | ESM 占绝对主导,CJS 像今天的 var ------ 存在、能用、但没人新项目用 |
但请注意:CJS 永远不会被删除 。Node 的核心承诺是"不破坏已有代码",这和 JavaScript 的 var 一样------语法保留,但生态淘汰。
五、给开发者的实用建议
如果你是项目开发者
- 新项目 :直接在
package.json加"type": "module",全程 ESM - 遇到 CJS 老包 :
import pkg from 'pkg'通常直接可用,命名导出问题用解构解决 - 需要
__dirname/require:用import.meta.url+fileURLToPath模拟,或createRequire
如果你是库作者
- 必须双格式发布 :用
exports字段提供import和require两个入口 - 构建工具推荐 :
tsup、unbuild、rollup都支持一键生成双格式 - 类型文件 :优先发
.d.ts,如果双格式类型不同,用typesVersions或条件导出
如果你是学习者
- 直接学 ESM :
import/export是 JavaScript 标准,浏览器和 Node 通用 - 了解 CJS 即可:能读懂老代码,能处理兼容问题,但不需要主动写
六、结语:技术债的偿还,从来都很慢
Node 的模块化分裂,本质是一次"技术债偿还"的漫长过程。CJS 不是错误------它是特定历史条件下的最优解。ESM 也不是完美的------它的静态加载在某些场景下反而笨拙。
真正的教训是:基础设施的演进,远比语法慢得多。JavaScript 语言每年发新版本,但生态的迁移以十年为单位。
对于普通开发者,不必焦虑。Node 的兼容层做得很好,ESM 和 CJS 的互操作在绝大多数场景下"开箱可用"。你只需要记住一个原则:新项目用 ESM,老项目逐步迁移,遇到兼容问题用 createRequire 兜底。
终局已经写好了,只是抵达需要时间。在那之前,我们学会与分裂共处即可。