Node.js 模块与工程:CommonJS、ESM 与包边界
写 Node 应用时,一半时间在写业务,另一半时间往往在处理「这个包怎么引、怎么发、怎么兼容」。
这一篇围绕模块系统展开,想讲清楚几件事:CommonJS 和 ESM 在 Node 里如何共存、package.json里哪些字段决定包的边界,以及在实际项目里怎么减少「引错入口」和「混用混乱」。
CommonJS:require / module.exports 的心智模型
Node 最早广泛使用 CommonJS(CJS):
require('xxx')同步加载(内部有缓存),返回module.exports;- 一个文件一个模块,运行时解析。
特点:
- 动态性较强:
require可以出现在条件分支里(虽然不推荐滥用); - 生态历史包袱大:大量 npm 包仍是 CJS 导出。
在维护老项目或工具链时,几乎绕不开 CJS。
ESM:import / export 与静态结构
ESM(import / export)是语言标准层面的模块系统:
- 更利于静态分析和 Tree-shaking;
- Node 通过
.mjs、package.json 的type、或条件导出 来声明「这个包按 ESM 解析」。
在 Node 里使用 ESM 时要注意:
- 文件扩展名与
package.json的"type": "module"会改变解析规则; - 与 CJS 互操作时,有时需要
createRequire或默认导出包装,细节容易踩坑。
package.json:不只是依赖列表,更是「契约」
对库作者和应用项目来说,几个字段特别关键:
name/version:标识与发布;main/module/exports:外界require/import你时,实际落到哪个文件;exports字段能精细控制子路径导出、条件导出(importvsrequire、浏览器 vs Node),是现代库更推荐的方式;
type:module或省略(默认按 CJS 处理.js的规则与版本有关,要以当前 Node 文档为准);engines:声明支持的 Node 版本,避免使用者环境过旧导致静默失败。
工程含义是:
- 入口设计 = 对外 API 设计:改入口等于破坏性变更,需要版本号与 CHANGELOG 说话。
node_modules 与解析规则:为什么「装得上」不等于「引得着」
Node 的模块解析大致会:
- 从当前文件向上找
node_modules; - 解析
package.json的main/exports; - 处理
index.js等默认文件。
常见问题包括:
- 引用了包的内部路径(未在
exports中暴露),升级包后突然挂掉; - 同一依赖多版本共存,体积膨胀且行为不一致;
- 幽灵依赖(依赖了并未在自身
package.json声明的包),在 pnpm 等严格布局下直接报错。
实践上更稳妥的做法:
- 只依赖包的公开入口;
- 应用层用 lockfile 锁版本;
- monorepo 里用 workspace 协议明确内部包关系。
CJS 与 ESM 混用:减少痛苦的几种策略
现实项目里混用很常见,可以遵循:
- 新项目优先 ESM(若团队与依赖链允许);
- 库尽量提供清晰
exports,同时文档写明 CJS/ESM 用法; - 避免在同一项目深处来回混用两种风格的「动态技巧」,除非有明确理由。
小结:模块是工程边界,不是语法糖
- CJS 仍是存量生态的默认语言之一;ESM 是长期方向,但迁移要算账;
package.json的入口与exports决定包的对外契约,改之前要当 API 变更看待;- 依赖解析与 node_modules 布局直接影响可复现构建与升级安全。
把「模块与包」当成工程边界来管理,后面接 TypeScript、打包、发布到 npm,都会轻松一层。