📦 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 被迫同时支持两套模块系统,而它们水火不容:

痛点说明
.mjs vs .cjs vs "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 只能背着两套系统艰难前行。

其他副作用:

  1. 生态割裂期:一段时间里,有的包只发 CJS、有的只发 ESM、有的发 UMD,作者要维护多种产物,使用者要处理兼容。
  2. 工具链复杂度转移:虽然 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 混乱、互操作之痛(向后兼容的代价)

历史地位

ESM 是 JavaScript “补完计划”里最关键、也最难产的一块拼图。它一统了模块化的混战,但它真正改变历史的,不是”统一”,而是那个看似不起眼的属性——静态可分析。这个属性把”删无用代码(tree-shaking)“和”不打包也能跑(浏览器原生 ESM)“两件事变成了现实,而后者直接孕育了 Vite——它撕掉了 Webpack 时代”开发前必须先打包整个应用”的前提,把前端开发体验带进了毫秒级冷启动的新纪元。一个语言特性,重新定义了一个时代的工具链。


🔗 时代背景:2013-2018 SPA时代 | 2018-2023 工程化时代 🔗 版本脉络:ECMAScript演进史 | 模块入标准 ES6-ES2015 🔗 相关:Node.js | Webpack | Browserify | Rollup | Vite | Babel