返回专题首页

JavaScript 专题

模块系统:ESM、CommonJS、打包前后的依赖关系

当 JavaScript 还主要停留在简单脚本阶段时,大家可以把代码全写在一个文件里,靠全局变量共享状态。但一旦项目变大,这种方式很快就会失控:命名冲突、依赖关系混乱、文件职责不清、修改一个地方影响全局。模块系统就是为了解决这些问题,它让代码可以被拆分、封装、导入和复用。

JavaScript 专题第 07 篇 / 25 篇5 分钟

当 JavaScript 还主要停留在简单脚本阶段时,大家可以把代码全写在一个文件里,靠全局变量共享状态。但一旦项目变大,这种方式很快就会失控:命名冲突、依赖关系混乱、文件职责不清、修改一个地方影响全局。模块系统就是为了解决这些问题,它让代码可以被拆分、封装、导入和复用。

很多人知道 import/exportrequire/module.exports 这些写法,却并没有真正理解模块在工程里到底意味着什么。结果就是:写代码时会用,切到不同运行时、打包工具、测试环境或构建阶段时就开始混乱。这一篇的重点,就是把 JavaScript 模块系统背后的运行时边界和工程边界讲清楚。

模块首先解决的是边界问题

模块最核心的价值,不是“方便拆文件”,而是建立边界。一个模块要尽量只暴露明确的接口,把内部实现细节藏起来。这样做的好处是:

  • 依赖关系更清晰;
  • 代码职责更明确;
  • 改动影响范围更可控;
  • 测试和复用都更容易。

所以真正学模块,不只是会写 importexport,还包括你能否判断什么应该暴露、什么应该留在模块内部、模块之间该如何组织依赖。

CommonJS 和 ESM 为什么会并存?

这是 JavaScript 历史演进留下来的现实。Node 早期广泛使用 CommonJS,因此 requiremodule.exports 成为很多后端和工具项目的基础写法。随着浏览器生态和标准化推进,ES Modules,也就是 ESM,逐渐成为官方标准,现代前端构建与浏览器原生模块能力大多围绕它发展。

两者最大的差别不只是语法,而是模块加载和分析方式也不同。ESM 更适合静态分析,这对 Tree Shaking、打包优化、类型推断和工具链集成都很重要;CommonJS 更偏运行时加载,历史包袱更重,但在一些 Node 生态里仍然大量存在。

这就是为什么你在真实项目里经常会看到二者并存。学它们不是为了站队,而是为了遇到兼容和构建问题时能解释清楚。

为什么现代前端普遍偏向 ESM?

因为 ESM 更符合现代工程化的需要。它允许工具在真正运行前就分析依赖图,知道哪些导出被使用、哪些没被使用、哪些模块存在循环依赖风险。对于前端构建来说,这种静态可分析性非常关键。

从项目体验上看,ESM 还带来了更统一的语法风格。无论浏览器、构建工具还是很多现代框架,都更愿意围绕它组织代码。你会发现,只要项目是现代前端应用,import/export 几乎已经成为事实标准。

打包前后的模块世界为什么不一样?

这是很多人第一次做工程化时最困惑的地方。你在源码里写的是一套模块关系,但浏览器实际执行的产物,往往已经被打包器重组、合并、拆分或转换过。也就是说,源码世界和产物世界不是一回事。

例如:

  • 源码里是 ESM,构建后可能变成浏览器可直接执行的 bundle;
  • 一部分模块会被拆成懒加载 chunk;
  • 某些仅在开发环境存在的代码可能被生产构建剔除;
  • 一些包会被转译成兼容旧环境的形式。

理解这一点很重要,因为很多“源码明明这样写,为什么线上表现不一样”的问题,都和打包后的依赖重组有关。

模块边界设计,远比导入语法更重要

项目里最难的从来不是记 default export 和命名导出,而是设计合理的模块边界。几个常见判断标准是:

  • 一个模块是否承担单一职责;
  • 它对外暴露的是稳定接口还是随意暴露内部细节;
  • 是否出现双向依赖或深层循环依赖;
  • 模块命名、目录结构和导出方式是否清晰表达职责。

很多代码库后期难维护,不是因为“模块系统不行”,而是因为模块边界一开始就没有设计好。文件越拆越多,却没有真正形成清晰依赖图,只会让复杂度更分散。

循环依赖为什么危险?

循环依赖是模块系统里非常典型又经常被低估的问题。两个模块互相依赖,短小代码里可能暂时还不明显,但一旦初始化逻辑、导出时机、运行时副作用复杂起来,就可能出现值未准备好、拿到 undefined、初始化顺序异常等问题。

在 ESM 和 CommonJS 中,循环依赖的表现还可能不同,所以它特别容易把人带进“为什么本地偶发、构建后更明显”的排查陷阱。工程上最稳的做法通常不是“研究怎么在循环依赖里求生”,而是尽早拆解职责,让依赖方向更单向。

项目里常见的模块误区

常见问题通常包括:

  • 文件拆得很多,但没有职责边界,只是形式上的模块化;
  • 导出过多内部实现,导致调用方深度耦合;
  • 默认导出、命名导出混用混乱,阅读体验差;
  • 忽略循环依赖,等到运行时异常才被动排查;
  • 只理解源码层面的模块,不理解构建产物中的模块重组。

写在最后

模块系统是 JavaScript 进入工程化协作的第一道门。它解决的不是语法问题,而是边界问题、依赖问题和可维护性问题。接下来我们会进入异步编程,Promise 和 async/await 会和模块一样,成为你后续理解现代 JavaScript 项目的关键支柱。