Node.js 模块与工程:CommonJS、ESM 与包边界

写 Node 应用时,一半时间在写业务,另一半时间往往在处理「这个包怎么引、怎么发、怎么兼容」。
这一篇围绕模块系统展开,想讲清楚几件事:CommonJS 和 ESM 在 Node 里如何共存、package.json 里哪些字段决定包的边界,以及在实际项目里怎么减少「引错入口」和「混用混乱」。

Node 最早广泛使用 CommonJS(CJS)

  • require('xxx') 同步加载(内部有缓存),返回 module.exports
  • 一个文件一个模块,运行时解析。

特点:

  • 动态性较强require 可以出现在条件分支里(虽然不推荐滥用);
  • 生态历史包袱大:大量 npm 包仍是 CJS 导出。

在维护老项目或工具链时,几乎绕不开 CJS。

ESMimport / export)是语言标准层面的模块系统:

  • 更利于静态分析和 Tree-shaking;
  • Node 通过 .mjs、package.json 的 type、或条件导出 来声明「这个包按 ESM 解析」。

在 Node 里使用 ESM 时要注意:

  • 文件扩展名与 package.json"type": "module" 会改变解析规则;
  • 与 CJS 互操作时,有时需要 createRequire 或默认导出包装,细节容易踩坑。

对库作者和应用项目来说,几个字段特别关键:

  • name / version:标识与发布;
  • main / module / exports:外界 require/import 你时,实际落到哪个文件;
    • exports 字段能精细控制子路径导出、条件导出(import vs require、浏览器 vs Node),是现代库更推荐的方式;
  • typemodule 或省略(默认按 CJS 处理 .js 的规则与版本有关,要以当前 Node 文档为准);
  • engines:声明支持的 Node 版本,避免使用者环境过旧导致静默失败。

工程含义是:

  • 入口设计 = 对外 API 设计:改入口等于破坏性变更,需要版本号与 CHANGELOG 说话。

Node 的模块解析大致会:

  • 从当前文件向上找 node_modules
  • 解析 package.jsonmain/exports
  • 处理 index.js 等默认文件。

常见问题包括:

  • 引用了包的内部路径(未在 exports 中暴露),升级包后突然挂掉;
  • 同一依赖多版本共存,体积膨胀且行为不一致;
  • 幽灵依赖(依赖了并未在自身 package.json 声明的包),在 pnpm 等严格布局下直接报错。

实践上更稳妥的做法:

  • 只依赖包的公开入口
  • 应用层用 lockfile 锁版本;
  • monorepo 里用 workspace 协议明确内部包关系。

现实项目里混用很常见,可以遵循:

  • 新项目优先 ESM(若团队与依赖链允许);
  • 尽量提供清晰 exports,同时文档写明 CJS/ESM 用法;
  • 避免在同一项目深处来回混用两种风格的「动态技巧」,除非有明确理由。
  • CJS 仍是存量生态的默认语言之一;ESM 是长期方向,但迁移要算账;
  • package.json 的入口与 exports 决定包的对外契约,改之前要当 API 变更看待;
  • 依赖解析与 node_modules 布局直接影响可复现构建与升级安全。

把「模块与包」当成工程边界来管理,后面接 TypeScript、打包、发布到 npm,都会轻松一层。