一、为什么会出现 ES Module?
在 ES6 之前,JS 语言本身 没有模块系统,不同环境出现了不同的实现:
-
浏览器:没有模块机制,只能
<script>夹杂加载 -
Node:使用 CommonJS(require)
-
打包工具(webpack/rollup)模拟模块系统
为了统一,ES6(2015)引入了官方标准模块系统:
👉 ES Module(ESM)
它是浏览器、Node、构建工具共同支持的官方模块标准。
二、ESM 的三大特点
1. 静态解析(Static Analysis)
在编译阶段就能确定依赖关系:
javascript
import { a } from './x.js'
import y from './y.js'
与 CommonJS 不同,ESM 的 import 必须写在顶层,不能写成动态的:
❌ 不允许:
javascript
if (flag) {
import x from './xxx.js' // 错误
}
好处:
-
可做静态分析
-
可做 tree-shaking
-
可做按需加载
-
能让浏览器实现"按模块加载"
2. Live Binding(实时绑定)
可以简单理解为:
import 导入的变量不是值的拷贝,是一个"引用",始终与源模块保持同步。
示例:
javascript
// a.js
export let count = 0
export function inc() { count++ }
javascript
// b.js
import { count, inc } from './a.js'
console.log(count) // 0
inc()
console.log(count) // 1(会实时更新)
这就是 ES Module 的"活绑定"。
3. 模块默认是严格模式
等同于内部声明:
javascript
'use strict'
严格模式:
-
禁止未声明变量
-
禁止 this 指向 window
-
提高性能(部分优化)
三、ES Module 加载原理
浏览器原生 ESM 加载流程:
javascript
<script type="module" src="main.js"></script>
浏览器做的事情:
1. 解析 main.js,构建依赖图(不执行)
浏览器扫描所有的 import:
javascript
main.js
├── import A from './a.js'
├── import B from './b.js'
└── import C from './c.js'
所有依赖会被递归扫描(静态分析)。
2. 加载所有依赖资源(并行)
浏览器会并发请求:
-
a.js
-
b.js
-
c.js
不像 CommonJS 是"边执行边加载"。
3. 将所有模块放入 Module Map(模块缓存)
浏览器维护一个模块缓存(Module Map):
javascript
URL → 该模块的导出对象
每个模块只执行一次。
4. 执行模块(自上而下)
模块执行顺序符合依赖图拓扑排序。
四、Node.js 中的 ES Module 原理
Node 原生支持 ESM,有两种方式:
方式 1:使用 .mjs 后缀
javascript
import fs from 'fs'
方式 2:在 package.json 加:
javascript
{ "type": "module" }
Node 加载 ESM 的主要特点:
-
不允许 import 动态使用(与浏览器一致)
-
使用 URL 解析(必须加文件后缀)
-
CommonJS 中的 require 机制不同(不是同一个系统)
五、ESM 与 CommonJS 对比(核心差异)
| 特性 | ES Module | CommonJS |
|---|---|---|
| 加载方式 | 编译时确定依赖,静态导入 | 运行时执行 require,动态导入 |
| 导出内容 | Live Binding | 值拷贝(执行时) |
| 支持 tree-shaking | ✔ 支持 | ❌ 不支持 |
| 是否异步 | ✔(浏览器) | ❌ 同步 |
| 使用环境 | 浏览器、Node | Node |
| 加载顺序 | 依赖图拓扑排序 | require 执行顺序 |
静态 vs 动态 本质区别
ESM 是静态的,CommonJS 是动态的。
六、为什么 ESM 对构建工具非常重要?
(1)可以静态分析依赖树
Vite、Rollup、Webpack 都可以根据 import 解析依赖,不用真正执行 JS。
(2)可以做 tree-shaking
编译时就能知道哪些变量没用,直接删掉。
(3)Vite 可以实现"按需编译"
因为浏览器能的直接请求 ESM:
javascript
import { x } from '/src/utils/helper.js'
Vite 会对这个模块即时编译,而不是打包后再运行。
👉 这就是 Vite 秒启动的核心原理!
七、浏览器 ESM 的高阶特性
1. 模块的跨域加载
script module 支持跨域加载,只要正确 CORS:
javascript
<script type="module" src="https://example.com/main.js"></script>
2. 动态 import(支持异步、懒加载)
ESM 的动态加载:
javascript
import('./feature.js').then(m => {
m.run()
})
这个语法在浏览器、Node 都支持。
3. 模块只能执行一次
无论 import 多少次,结果来自同一份 Module Map 缓存。
八、ES Module 执行顺序
当浏览器遇到:
javascript
<script type="module" src="./main.js"></script>
整体加载流程如下:
bash
┌─────────────────────────────────┐
│ 1. 浏览器下载 main.js │
└───────────────┬───────────────┘
▼
┌───────────────────┐
│ 2. 解析依赖 (静态) │◄───────────────────────────────────────────┐
└──────────┬────────┘ │
│ │
┌────────────┴────────────┐ │
▼ ▼ │
┌─────────────┐ ┌─────────────────┐ │
│ import A.js │ │ import B.js │ │
└───────┬─────┘ └─────────┬──────┘ │
▼ ▼ │
┌─────────────┐ ┌─────────────┐ │
│ 下载 A.js │ │ 下载 B.js │ │
└───────┬─────┘ └───────┬─────┘ │
▼ ▼ │
┌────────────────────┐ ┌────────────────────┐ │
│ 解析 A.js 的 import │ │ 解析 B.js 的 import │ │
└──────────┬─────────┘ └──────────┬─────────┘ │
│ │ │
(如果有子依赖) (如果有子依赖) │
▼ ▼ │
┌────────────────┐ ┌────────────────┐ │
│ 递归加载依赖树 │──────►│ C.js / D.js ...│ │
└────────────────┘ └────────────────┘ │
│ │
└───────────────► 完成完整依赖图构建 ◄────────────────┘
(Module Graph 完成)
(不执行 JS)
─────────────────────────────────────────────────────────────────────────────
接下来进入执行阶段(按依赖拓扑顺序执行)
─────────────────────────────────────────────────────────────────────────────
示例:
javascript
console.log('main start')
import './a.js'
console.log('main end')
执行顺序:
-
浏览器静态扫描 main → 找到 a.js
-
浏览器并行加载 a.js
-
执行 a.js
-
最后执行 main.js 的代码
说明:import 会提前加载依赖模块,而不是等执行到那一行才加载。
九、ES Module 总结一句话
ES Module 是一种"静态依赖分析 + 原生 ESM 加载 + 模块之间实时绑定"的现代模块系统,是浏览器、Node、Vite 等工具的底层基础。
静态 → 能做 Tree-shaking
实时绑定 → 执行效率高
可缓存 → 浏览器运行快
原生支持 → Vite 可以不打包直接跑