ES Module 规范与特性深度解析
ESM 与 CJS 是两套范式:静态结构支撑 Tree Shaking、Live Binding 让跨模块变更实时可见、浏览器原生支持零打包。 面试对比两者差异与循环依赖时的表现。
一句话概括
ES Module 不是 CJS 的语法糖——它换了赛道:静态结构让打包工具有上帝视角做 Tree Shaking,Live Binding 让跨模块变量变更实时可见,浏览器原生 <script type="module"> 让零打包开发成为现实。
核心知识点
1. 语法全景——8 种模式搞定所有场景
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ── 命名导出 ──
export const PI = 3.14;
export function add(a, b) { return a + b; }
export { PI, add };
// ── 默认导出(一个模块仅一个)──
export default function main() {}
// ── 聚合重导出(barrel export)──
export { add as sum } from './math.js';
export { default as React } from 'react';
// ── 导入 ──
import { add } from './math.js'; // 命名导入
import * as Math from './math.js'; // 命名空间导入
import def from './module.js'; // 默认导入(名字随意)
import def, { add } from './module.js'; // 混合导入
// ── 动态导入 ──
const { heavy } = await import('./heavy.js');
2. Live Binding — ESM 和 CJS 的根本差异
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// counter.mjs
export let count = 0;
export const inc = () => { count++; };
// main.mjs
import { count, inc } from './counter.mjs';
console.log(count); // 0
inc();
console.log(count); // 1 ← 实时更新!CJS 永远做不到
// CJS 对比:
// const { count, inc } = require('./counter');
// inc();
// console.log(count); // 0 ← count 是导出那一刻的原始值快照
原理: import { count } 不是把值拷过来,而是建立指向原模块 count 变量的只读引用(binding)。原模块改了 → 所有 import 端立刻读到新值。但 import 端不能赋值——count = 5 直接报错。
3. 静态结构 → Tree Shaking
1
2
3
4
5
6
7
8
9
10
11
12
// utils.js
export const used = () => 'used';
export const unused = () => 'unused'; // 没被任何 import 引用
// app.js
import { used } from './utils.js';
// 打包后:unused 的代码直接被删除(或标记为 dead code)
// CJS 为什么不行?
// require 可以出现在任何地方——if / 循环 / 表达式:
if (flag) { const x = require('./x'); }
// 打包工具完全不敢删——万一 flag 是 true 呢?
Tree Shaking 的前提是编译时画出完整依赖图,确定每个 export 是否被引用。CJS 的动态 require 让静态分析无从下手。
4. 动态 import — 按需加载的基石
1
2
3
4
5
6
7
8
// 条件加载:根据用户选择加载不同模块
const { render } = document.querySelector('#chart').checked
? await import('./chart.js')
: await import('./fallback.js');
// 路由懒加载——现代框架标配
const Profile = () => import('./pages/Profile.vue'); // Vue Router
const LazyPage = React.lazy(() => import('./Page.tsx')); // React
静态 import 管依赖拓扑,动态 import 管按需加载——两者互补而非互斥。
5. ESM vs CJS 速查
| CommonJS | ES Module | |
|---|---|---|
| 加载时机 | 运行时同步 | 编译时解析,执行时取值 |
| 导出本质 | 值拷贝 | Live Binding(引用) |
| Tree Shaking | ❌ | ✅ |
| 循环依赖 | 拿到不完整对象 | 引用在,值可能 TDZ |
| 顶层 await | ❌ | ✅ (ES2022) |
| 浏览器原生 | ❌ | ✅ <script type="module"> |
| Node.js | 默认 | .mjs 或 "type": "module" |
其实你每天都在用
- Vite 开发服务器:浏览器原生 ESM,上百模块按需 fetch,秒启动不打包
- 路由懒加载:
() => import('./Home.vue')拆 chunk,首屏体积砍半 - npm 包双入口:
"module"字段指向 ESM 入口,打包工具优先读取以获取 Tree Shaking 能力 import type { User } from './types':TS 编译后直接抹掉,零运行时开销(TS 3.8+)import.meta.url:ESM 里替代__dirname的标准方式——new URL('./data.json', import.meta.url)
常见误解(FAQ)
❌ 误区:「import 和 require 都同步」 浏览器
<script type="module">默认异步 + defer(等 DOM 解析完才执行,不阻塞渲染)。Node ESM 也是异步加载。真正同步阻塞的是 CJS 的require(readFileSync+ 立即执行)。这个差异决定了 ESM 能直接跑在浏览器上,CJS 必须打包。❌ 误区:「export default 比命名导出好」 对使用者方便(命名随意),对维护者是灾难——IDE 重构跟踪不到引用、Tree Shaking 效果更差(整个 default 对象无法部分摇掉)。社区共识:除非模块只有一个主功能(如组件),优先命名导出。
❌ 误区:「ESM 能 import CJS,互操作没问题」 这条桥是单向的:ESM → CJS ✅(Node 把
module.exports映射为default导出),CJS → ESM ❌——require处理不了异步加载和 Live Binding。混用项目注意方向。❌ 误区:「Node 里改个后缀就能切 CJS/ESM」
.mjs= ESM,.cjs= CJS,.js看"type"。更要命的是:CJS 文件require不了 ESM 文件——新旧代码过渡期踩坑重灾区。实在要桥接用await import()动态加载。
一句话总结
静态分析让死代码无处藏身,Live Binding 让循环依赖不再断裂,浏览器原生让模块化回归语言本身——ESM 的意义在于它是 JavaScript 的一部分,不只是 Node.js 的一个功能。