📦 ES Modules(ESM)
一句话定性
ESM 的故事,是 JavaScript 用二十年时间补上”它从娘胎里就缺失的模块系统”的故事。它平息了 CommonJS vs AMD 的混战、一统江湖,但真正的革命在于它的一个属性——静态可分析。正是这个属性,让 tree-shaking 成为可能,也让 Vite 的 “no-bundle” 开发服务器从幻想变成现实。
一、它是什么 & 出现的时代
ES3 时代的 JavaScript 没有任何模块系统——所有代码共享一个全局作用域。这在写几行表单校验时无所谓,但当 SPA 时代应用动辄上万行、依赖几十个库时,全局变量污染成了灾难。
整个 2009–2015 年,社区在没有官方标准的真空里,自己造了一堆互不兼容的模块方案,打成一片混战。直到 ES6-ES2015(2015)把 import/export 定为语言原生标准,即 ES Modules(ESM),才给出了官方答案。但”标准存在”和”到处能用”之间,又隔了好几年的浏览器与 Node 落地之路。
二、为什么会出现(解决上一代的模块混战)
在 ESM 之前,模块化是一场三国混战:
| 方案 | 出处 | 加载方式 | 致命局限 |
|---|---|---|---|
CommonJS (require) | Node.js(2009) | 同步、运行时 | 同步读文件,只适合服务端;浏览器没有 require |
AMD (define) | RequireJS | 异步、运行时 | 为浏览器设计,但语法啰嗦、回调嵌套难看 |
| UMD | 社区 | 兼容上述两者 | 一坨”if/else 判断环境”的样板代码,丑陋 |
核心矛盾:服务端要同步,浏览器要异步
- CommonJS 是 Node 的方案:文件都在本地磁盘,
require可以同步读取,简单直接。但浏览器里同步加载会卡死页面。- AMD 是浏览器的妥协:模块要从网络异步下载,所以用回调。但写起来繁琐。
- 一个库想同时跑在 Node 和浏览器,就得用 UMD 把两套都包进去。
这是一场没有赢家的战争:没有官方标准,每个环境自成一派。 而且这两种方案有一个共同的根本缺陷——见下文。
关键人物:Browserify 与 Webpack——让 CommonJS 进了浏览器
既然 Node 的 CommonJS 写法那么爽,能不能在浏览器里也用
require?Browserify 给出了答案:它在构建时把所有require的模块打包成一个浏览器能跑的大文件。Webpack 把这个思路发扬光大,成了 SPA 时代的打包之王。正是它们,让”用 CommonJS 写前端”成为现实,也让打包器(bundler)成了前端标配。
三、关键特性 & 为什么重要
① 语言原生、语法简洁
import { foo } from './a.js' // 静态导入
export const bar = 1
export default Component不再需要 UMD 那套环境判断样板,一套语法服务端浏览器通吃。
② 静态可分析(ESM 最重要的特性)
这是整篇文章的核心洞察
ESM 的
import/export是静态的——它们必须出现在模块顶层,在代码”运行之前”(编译期)就能确定整个依赖图。 而 CommonJS 的require()是一个普通函数调用,可以写在if里、循环里、动态拼字符串路径,你必须真正运行代码才知道它依赖了什么。CommonJS: 动态、运行时 ESM: 静态、编译时可知 ───────────────────── ───────────────────── if (x) { import { a } from './a' require('./a') ← 运行才知 import { b } from './b' } ↑ 不运行就能画出依赖图 const m = require( ↑ 不能放进 if/循环 './' + name) ← 路径动态这个”编译期就能知道谁依赖谁”的属性,是后面一切的前提。
③ 由”静态可分析”解锁的两大能力
a) Tree-shaking(摇树优化)
既然编译期就知道每个 export 有没有被 import,打包器就能删掉没用到的代码(死代码)。这就是 tree-shaking。它对 CommonJS 几乎不可能(因为 require 是动态的,无法静态判断哪些导出没被用)。Rollup 是第一个把 tree-shaking 做到极致的打包器,正是靠押注 ESM。
b) 浏览器原生支持 ESM(<script type="module">)
约 2017–2018 年起,主流浏览器原生支持 import。这意味着:浏览器可以自己按需去加载模块,不再非得先打包成一个大文件。 这一点,直接催生了 Vite(见下文)。
四、带来的新问题 / 副作用
Node 的"双模块系统"之痛 —— ESM 落地最大的烂摊子
Node 早已用 CommonJS(
require)建立了庞大的 npm 生态(数百万包)。现在语言官方又有了 ESM(import),于是 Node 被迫同时支持两套模块系统,而它们水火不容:
痛点 说明 .mjsvs.cjsvs"type": "module"一个文件到底是 ESM 还是 CJS,要靠后缀名或 package.json 字段判断,极其混乱 CJS 不能 require()一个 ESM 包(早期)ESM 是异步加载的,同步的 require接不住,导致大量包升级 ESM 后老项目崩溃__dirname/require在 ESM 里没了习惯的 Node 全局变量在 ESM 模式下需要 workaround 双重包危机(dual package hazard) 同一个包的 CJS 和 ESM 版本可能被同时加载,出现两份”单例”,状态不一致 这场迁移痛苦持续了好几年,是”向后兼容铁律的反面代价”——正因为不能抛弃 CommonJS 生态,Node 只能背着两套系统艰难前行。
其他副作用:
- 生态割裂期:一段时间里,有的包只发 CJS、有的只发 ESM、有的发 UMD,作者要维护多种产物,使用者要处理兼容。
- 工具链复杂度转移:虽然 ESM 是标准,但实际项目仍依赖打包/转译,只是问题从”怎么模块化”变成了”怎么处理两套模块系统的互操作”。
五、对后续技术的影响(因果链)
JS 出生就没有模块系统([[ES3]])
│
↓
社区真空期混战:CommonJS([[Node.js]],同步) vs AMD(浏览器,异步) vs UMD
│ │
│ └──► [[Browserify]]/[[Webpack]] 构建时打包 ──► 让 CommonJS 能在浏览器跑
│
↓
[[ES6-ES2015]] 把 ESM 定为语言标准(import/export)
│
├──► 静态可分析(编译期可知依赖图)──► 这是关键分叉点
│ │
│ ├──► tree-shaking 成为可能 ──► [[Rollup]] 把它做到极致
│ │
│ └──► 浏览器原生支持 <script type="module">
│ │
│ ↓
│ 浏览器能自己按需加载模块,无需先打包
│ │
│ ↓
│ ★ 让 [[Vite]] 的 "no-bundle" dev server 成为可能 ★
│ (开发时不打包,浏览器请求哪个模块就编译哪个,
│ 冷启动从几十秒降到毫秒级)
│
└──► Node 被迫双模块系统(CJS + ESM)
│
└──► .mjs/.cjs 混乱、互操作之痛(向后兼容的代价)
历史地位
🔗 时代背景:2013-2018 SPA时代 | 2018-2023 工程化时代 🔗 版本脉络:ECMAScript演进史 | 模块入标准 ES6-ES2015 🔗 相关:Node.js | Webpack | Browserify | Rollup | Vite | Babel