JavaScript 模块系统的三十年战争:从 script 标签到 ES Module 的完整演进

从早期 script 标签到 CommonJS、AMD、UMD,再到 ES Module 与 Node.js 双模块体系,本文结合真实工程场景,梳理 JavaScript 模块系统的演进脉络、核心设计差异与落地经验。

如果你在一个稍微有点年头的前端项目里待过,大概会见过这样的代码:入口 html 里按顺序挂七八个 script 标签,靠全局变量互相通信;或者 webpack 配置里同时出现 main、module、browser 三个字段,打包产物还要区分 esm、cjs、umd。这些看起来像历史遗留的破事,背后其实是 JavaScript 模块系统三十年来的反复拉扯。

AI technology illustration

模块化真正麻烦的地方,从来不是”怎么拆分文件”,而是”怎么在语言天生没有模块概念的前提下,把不同文件里的代码安全地拼起来”。这个问题的答案,在不同时代受制于浏览器、服务器、构建工具和语言标准的发展,最终演变成了今天这套看似分裂实则自洽的格局。

没有模块的时代:全局变量和加载顺序

最早的 JavaScript 压根不考虑模块。浏览器里的脚本共享一个全局作用域,页面加载多少个 js 文件,它们就共同污染一个 window。你要用上一个文件里的函数,只能保证它在前面被加载。于是真实项目里最常见的场景是:某个页面突然报”xxx is not defined”,排查半天,发现是某个人把脚本顺序调错了,或者新加一个依赖库忘了放在它依赖的库前面。

这种方案的问题有几个层次。第一,名字冲突几乎是必然的,团队一大,谁都可能不小心覆盖掉别人的全局变量。第二,依赖关系完全靠加载顺序暗示,代码本身不表达任何依赖信息。第三,没法做静态分析,工具不知道哪些文件之间真正相关,也就没办法做可靠的删除死代码之类优化。

为了解决命名冲突,出现了命名空间模式,也就是把相关功能挂到一个全局对象下面,比如 window.APP.utils.format。但这只解决了冲突,没有解决依赖和加载顺序。你依然需要手工保证每个文件加载的先后顺序,文件多了以后,这基本就是靠猜。

这时候出现了一个经典模式: IIFE(立即执行函数表达式)。它利用函数作用域制造了一层隔离,让文件内部变量不会泄漏到全局,同时可以显式接收外部依赖。比如很多老项目的代码长这样:

(function(global) {
  var moduleObj = {};
  moduleObj.say = function(name) {
    return 'hello ' + name;
  };
  global.Greeter = moduleObj;
})(window);

这个模式有模块的雏形了:隔离、导出、通过参数声明依赖。但它和全局变量本质上还在同一套体系里:模块之间靠约定通信,依赖关系还是隐性的,加载顺序依然致命。真正让人意识到这套方案瓶颈的,是应用规模变大之后。当项目里上百个文件互相依赖时,靠人脑维护加载顺序已经完全不现实,这时整个社区开始寻找真正的依赖管理和加载机制。

服务端先行:CommonJS 的诞生与问题

在浏览器端挣扎的时候,服务端 JavaScript 反而先走出了关键一步。Node.js 在设计之初就需要一套文件级模块系统,于是采纳了 CommonJS 的规范。CommonJS 的核心是 requiremodule.exports,一个文件就是一个模块,每个模块有自己的作用域,加载过的模块会被缓存。

这套设计有几个明显优势。第一,依赖关系是显式的,代码里直接写 require('./utils'),工具可以顺着做静态分析。第二,同步加载在服务端几乎无成本,因为模块已经存在于本地磁盘,读文件是很快的操作。第三,缓存机制让多次 require 不会重复执行模块代码,这在复杂业务里非常重要。

CommonJS 真正的问题不在设计本身,而在它和浏览器的运行环境不兼容。浏览器加载 JavaScript 走的是网络请求,天然是异步的;如果直接使用同步 require,一旦文件体积变大或者网络变慢,页面会直接卡住。社区不是没想过改造,但浏览器的约束就在那里,于是顺着”异步加载”这条思路,长出了另一套方案。

这里有一个经常被误解的点:CommonJS 并不是因为设计落后才被浏览器淘汰,而是它的同步模型与浏览器网络环境冲突。理解这一点,才能理解后面为什么要搞异步模块定义,也才能理解为什么 Node.js 后来引入 ESM 时要花那么大功夫处理缓存和加载时机的差异。

浏览器端的自救:AMD、UMD 与构建工具

为了在浏览器里实现异步模块加载,社区出现了 AMD(Asynchronous Module Definition)。AMD 的典型代表是 RequireJS,它的写法是显式声明依赖,依赖模块通过异步加载完成后才执行回调:

define(['jquery', './moduleA'], function($, moduleA) {
  return function doSomething() { /* ... */ };
});

AMD 解决了同步问题,但带来了另一个麻烦:代码写起来繁琐,而且需要额外的运行时加载器。在 RequireJS 使用的年代,你需要手动管理每个文件的 define 声明,构建时还得做依赖打包。这是能跑的方案,但绝不是舒服的方案。

AMD 流行的同时,社区还有 CMD(比如 Sea.js)、以及各种变体,这些方案的核心分歧在于执行时机和写法习惯,这里不展开。真正值得说的是 UMD。AMD 和 CommonJS 各有支持者,一个服务端、一个浏览器端,那有没有一种东西能同时兼容两者?UMD 做了这件事:通过一段统一的包装代码,在 CommonJS 环境里走 module.exports,在 AMD 环境里走 define,在其他环境里挂到全局变量。

UMD 看起来万能,但它的本质是”同时兼容多个平台”,代码里到处是环境判断和包装模板,阅读和调试体验都一般。它更多是库作者为了让自己的包能同时被浏览器的 script 标签、RequireJS 和 Node.js 使用而做的妥协。

到这一步,模块系统已经分出了多条线:CommonJS 走同步、AMD 走异步、UMD 走兼容。真正终结这种混乱的,不是又一个新的运行时方案,而是两样东西:构建工具和官方标准。

构建工具的出现让浏览器端模块化绕开了”浏览器体积太大”的限制。Grunt、Gulp 时代还在做文件拼接,到 Browserify 和 Webpack 时代,前端可以像 Node.js 一样在代码里写 require,构建工具负责把所有依赖打包成浏览器能理解的普通脚本。浏览器运行时不再需要模块加载器,模块系统的负担转移到了构建阶段。

这个阶段的典型状态,是一个项目里前端代码用 CommonJS 语法写模块,经过 webpack 打包成一组普通脚本;同时为了兼容各种老环境,打包产物可能还带着 UMD 包装。这种做法延续了非常多年,直到现在一些兼容性要求高的项目依然如此。

官方标准登场:ES Module 的设计

ECMAScript 的官方模块标准,也就是 ES Module(ESM),真正进入了语言层面。它用 importexport 表达依赖和导出,语法上是静态声明式的。这意味着所有 import 语句必须在模块顶层,不能写在条件判断里;也意味着解析器读完文件就知道它依赖哪些模块。

静态分析是 ESM 与 CommonJS 最关键的分水岭。CommonJS 的 require 是一个运行时函数,可以写在任意位置,甚至可以动态拼接路径,这就导致工具没法静态判断模块依赖关系。而 ESM 的 import 语句固定在最外层,解析阶段就能构建出完整的依赖图,因此可以做 tree shaking、更激进的代码优化和更精确的错误定位。

ESM 还有一个设计特征是实时绑定。CommonJS 导出的是值的拷贝,模块内部后续修改不会反映到导入方;而 ESM 导出的是同一个名字的实时绑定,导入方和导出方共享同一份引用。简单说:

// counter.js
export let count = 0;
export function increment() {
  count++;
}

// app.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1

这段代码在真正支持 ESM 的环境下会依次输出 0 和 1,因为 count 是实时绑定。如果换成 CommonJS,count 拿到的是拷贝值,第二次输出仍然会是 0。很多人会在面试里被问到这里,但工程师真正需要理解的是:这种设计让模块之间的状态同步更自然,但也减少了”快照”带来的安全性,调试时更容易出现”我导出的值怎么自己变了”这种困惑。

ESM 在浏览器里的加载模型和 CommonJS 完全不同。浏览器加载 ESM 时会先解析整个依赖图,再按照依赖顺序执行脚本。所以你要在 html 里使用模块,需要这样写:

<script type="module" src="./main.js"></script>

注意 type=”module” 会自带一些默认行为,比如严格模式、延迟执行、以及只执行一次。如果你在一个普通项目里把 script 标签从普通方式改成 type=”module”,页面上很多全局变量的交互方式都会变化,这也是迁移 ESM 时最容易出坑的地方。

Node.js 的尴尬:双模块体系

语言标准出来之后,Node.js 面临一个尴尬的局面:它的整个生态都建立在 CommonJS 上,底层的 module 机制、缓存机制、npm 包默认行为全都围绕 CJS 设计。直接切换成 ESM 显然不可能,于是 Node.js 采用了长期并行的方案:同一个环境里同时支持 CommonJS 和 ES Module。

这带来了一系列实际工程问题。首先,判断一个文件是 CJS 还是 ESM,Node.js 靠文件后缀和 package.json 的 type 字段来决定。默认情况下,以 .js 结尾的文件在 type 不是 module 时按 CommonJS 解析;后缀 .mjs 强制按 ESM,后缀 .cjs 强制按 CommonJS。

其次,在 ESM 里不能直接使用 require。如果代码要兼容两种体系,很多人会选择把暴露接口写成 CJS,因为 CommonJS 可以从 ES Module 里通过 import 默认导出来调用。反过来,想在 CommonJS 里 require 一个 ESM 模块,从很早开始就一直麻烦,直到最近版本才逐步缓解。

还有一个坑在于依赖提升和循环依赖。CommonJS 有模块缓存,循环依赖时有一部分模块会拿到未初始化完成的导出对象;ESM 因为有实时绑定,处理循环依赖的机制也不同。真实项目里,循环依赖本身就该尽量避免,但大型项目中模块关系复杂,偶尔会出现因为循环依赖导致”拿不到变量”的诡异问题。

这里给一个比较实用的对照表,帮大家在技术选型时有一个基本判断:

对比维度 CommonJS ES Module
语法 require / module.exports import / export
加载时机 运行时同步加载 解析时静态依赖
浏览器原生支持 不支持 现代浏览器支持
Tree shaking 基本不可能 天然支持
导出与导入关系 值的拷贝 实时绑定
典型运行环境 Node.js 服务端 浏览器与构建工具
依赖分析 动态调用,难以静态分析 静态声明,可构建依赖图

这张表能覆盖大部分判断场景,但真实项目里不能只看语法,还要看你所处生态的成熟度。对库作者来说,现在比较稳妥的做法是同时发布 CJS 和 ESM 两种格式的产物,并通过 package.json 的 exports 字段做条件导出,让不同运行环境加载各自合适的版本。这也是很多包为什么同时出现 cjs 和 esm 目录的原因。

让人困惑的模块互操作

很多团队会遇到一个实际场景:Node.js 项目从 CommonJS 慢慢迁移到 ESM,结果发现大量包依赖关系混乱,import 进来的东西在某些地方是 { default: ... },某些地方直接就是模块本身。这种互操作问题背后有几个原因。

第一是默认导出和命名导出的转换。Node.js 在支持 ESM 时做了不少兼容处理,CJS 模块被 ESM import 时,CJS 里的 module.exports 会被包装成 ESM 的默认导出,而 module.exports 上的属性可能会被静态分析识别为命名导出,也可能识别不出来,取决于代码写法。

第二是动态 import 的异步边界。ES Module 天然支持顶层 await,但 CommonJS 完全没有这个概念。如果你在 ESM 里 await import('some-cjs-module'),Node.js 会先把 CJS 模块包装加载,这个过程本身没有太大问题,但错误堆栈、调试信息、以及模块执行时机都会和纯 ESM 项目不一样。

第三是很多早期库为了兼容浏览器和 Node.js,写的是 UMD 包装代码。当这类包被 ESM import 时,构建工具和 Node.js 的处理方式并不完全相同,导致同一段代码在 vite 项目里正常、在 Node.js 环境下却报错。这类问题往往不是语法错误,而是”模块边界的行为不一致”造成的。

这部分的建议很简单:新写的代码尽量只输出一种模块格式;如果必须双格式,优先让库的核心是 ESM,再通过打包工具生成 CJS 兼容版本。不要到处混用,否则你会在”为什么 import 和 require 拿到的对象不一样”这种事情上反复浪费时间。

从打包器到原生:现在和未来的走向

ES Module 已经在现代浏览器里原生支持了很多年,但实际项目中仍然很少直接使用原生 ESM。原因是性能:原生 ESM 在浏览器里每个模块都是一个独立的 HTTP 请求,模块数量一多,加载效率远不如打包后的单个文件。这也是 webpack、vite 这些工具存在的根本原因。它们的重点已经不是”转换语法”,而是”在开发时保持原生的 ESM 开发体验,在生产时打包成更高效的产物”。

不少人以为 vite 快是因为用了 ESM,这个理解不准确。vite 开发时确实是基于原生 ESM,它把依赖预构建后提供给浏览器直接解析,所以冷启动快。但生产构建时,vite 仍然会走 rollup 打包,把无数微小模块合并成有限的几个文件。也就是说,对浏览器来说,模块化语法只是”编译时的组织方式”,最终交付物仍然需要进行合包优化。

真正值得关注的方向是 import maps 和 HTTP 缓存粒度的配合。import maps 可以让浏览器直接识别裸模块标识符,而不用写完整的路径;配合 HTTP/2 服务端推送或者合理的缓存策略,能让多个模块并行下载,又不会带来大量重复请求。但 import maps 对 CDN、缓存策略、更新时机的控制要求都很高,更多用在可控制运行环境的内部系统中。

对前端工程化而言,模块系统的未来不会是一场”谁取代谁”的革命,而更像是兼容与共存的演进。新项目可以放心使用 ESM 语法,并通过构建工具输出多种格式;老项目可以逐步把内部模块从 UMD 收敛到 ESM;服务端代码则需要尊重 Node.js 当前的双模块现实,明确你的项目是 pure ESM 还是 CJS 兼容模式。

站在全局去看这段历史,会比较清楚一件事:模块系统的每次演进,都是在”在浏览器里安全加载”和”让代码组织更可靠”这两个目标之间做平衡。CommonJS 让服务端编程变得干净,AMD 让浏览器里出现了异步加载,UMD 是它们之间的临时桥梁,而 ESM 则把模块化正式带入了语言层。理解这条线,不是为了追忆旧时代,而是为了在这些系统并存的项目环境里,读代码时知道它为什么长这样,定位问题时知道去怀疑谁。

一种比较实用的心态是:不要执着于”选择一个正确的模块标准”,而要理解你所在项目的运行环境、交付目标、依赖生态,再决定以哪种模块体系为主干。模块系统不是一道有多选一的题目,而是一套需要按场景组合使用的工程基础设施。

最终,当你再看到 package.json 里像迷宫的 conditions 字段,或者构建配置里同时出现 cjs、esm、umd 时,会明白这其实是 JavaScript 模块化走到今天的正常形态。新旧体系会在很长一段时间内继续并存,而真正重要的,是你知道它们各自存在的理由。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/602/

(0)
上一篇 8小时前
下一篇 2小时前

相关推荐