关于node 模块化的现状与未来

requireimport,Node.js 花了 14 年。这场漫长的迁移尚未结束,但终局已隐约可见。

一、问题的根源:一次"先上车后补票"的技术债

2009 年,Node.js 诞生时,ECMAScript 还没有官方的模块系统。Ryan Dahl 不得不自创 CommonJS(CJS) ------requiremodule.exports 这对组合,成了 Node 开发者最熟悉的语法。

2015 年,ES6 正式发布 import/export 语法,浏览器和 Node 都意识到:这才是 JavaScript 的未来。但问题在于:Node 已经跑了 6 年,npm 上积累了数十万个 CJS 包。不可能一刀切。

于是 Node 团队选择了最务实的方案:兼容并存。2019 年 Node 13 正式支持 ESM,但留下了两个致命的设计:

  1. 文件扩展名决定模块类型.js 默认 CJS,.mjs 强制 ESM,.cjs 强制 CJS
  2. package.jsontype 字段"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.jsonexports 字段标准化

exports 让库作者可以精确控制不同模块系统的入口,比过去的 main/module 双字段更清晰。但它也引入了新的复杂度:条件导出(nodeimportrequiredefault)的优先级规则,足以让新手崩溃。

四、未来:终局是 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 字段提供 importrequire 两个入口
  • 构建工具推荐tsupunbuildrollup 都支持一键生成双格式
  • 类型文件 :优先发 .d.ts,如果双格式类型不同,用 typesVersions 或条件导出

如果你是学习者

  • 直接学 ESMimport/export 是 JavaScript 标准,浏览器和 Node 通用
  • 了解 CJS 即可:能读懂老代码,能处理兼容问题,但不需要主动写

六、结语:技术债的偿还,从来都很慢

Node 的模块化分裂,本质是一次"技术债偿还"的漫长过程。CJS 不是错误------它是特定历史条件下的最优解。ESM 也不是完美的------它的静态加载在某些场景下反而笨拙。

真正的教训是:基础设施的演进,远比语法慢得多。JavaScript 语言每年发新版本,但生态的迁移以十年为单位。

对于普通开发者,不必焦虑。Node 的兼容层做得很好,ESM 和 CJS 的互操作在绝大多数场景下"开箱可用"。你只需要记住一个原则:新项目用 ESM,老项目逐步迁移,遇到兼容问题用 createRequire 兜底

终局已经写好了,只是抵达需要时间。在那之前,我们学会与分裂共处即可。


相关推荐
老王以为1 小时前
从源码到架构:React useActionState 深度剖析
前端·javascript·react.js
前端超有趣1 小时前
详解JavaScript中encodeURIComponent和decodeURIComponent的使用(附实战场景)
前端·javascript
萧曵 丶2 小时前
Vue3组件通信全方案
前端·javascript·vue.js·typescript·vue3
前端那点事2 小时前
双Token无感刷新:Vue3 + Axios 企业级完整实现
前端·vue.js
前端那点事2 小时前
Vue Token鉴权避坑指南|5步完整实现(从生成到失效全解析)
前端·vue.js
Momo__2 小时前
package.json 配置详解:依赖管理深度指南
前端
漫游的渔夫2 小时前
前端开发者做 Agent:模型说执行就执行?先加 3 道闸门再碰真实业务
前端·人工智能·typescript
前端那点事2 小时前
企业级Vue前端鉴权方案全解析|从Token到OAuth2.0,覆盖多端适配+权限管控
前端·vue.js
亲亲小宝宝鸭2 小时前
从Vben-Admin里面学习hooks
前端·vue.js