文章

ES Module 规范与特性

ES Module 规范与特性

一句话概括

ES Module 是 JavaScript 语言层面的官方模块系统——静态结构支持 Tree Shaking、实时绑定解决循环依赖、异步加载原生跑在浏览器,三个特性加起来根本不是 CJS 的语法糖,而是换了赛道。

核心知识点

1. 语法全景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ── 导出 ──
export const PI = 3.14;
export function add(a, b) { return a + b; }
// 统一导出
export { PI, add };
// 重导出
export { add as sum } from './math.js';
// 默认导出(一个模块只能一个)
export default function () { /* ... */ }

// ── 导入 ──
import { add, PI } from './math.js';
import { add as plus } from './math.js';   // 重命名
import * as math from './math.js';         // 全量
import def from './math.js';               // 默认导入
import def, { add } from './math.js';      // 混合

// ── 动态导入 ──
const { heavy } = await import('./heavy.js');

2. Live Binding — 和 CJS 的根本区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// counter.js
export let count = 0;
export const inc = () => { count++; };

// main.js
import { count, inc } from './counter.js';
console.log(count); // 0
inc();
console.log(count); // 1 ← 实时绑定!

// 对比 CommonJS:
// const { count, inc } = require('./counter');
// inc();
// console.log(count); // 0 ← 值拷贝,不会变

原理: ESM 的 export/import 不是”把值拷过去”,而是建立了变量地址的映射import { count } 相当于创建了一个指向原模块 count 变量的引用。inc 改了原值,import 端立刻看到。

3. 静态结构 → Tree Shaking

1
2
3
4
5
6
7
8
9
// utils.js
export const used = () => 'used';
export const unused = () => 'unused';

// app.js
import { used } from './utils.js';

// 打包后(Rollup / Webpack):
// unused 的代码直接被删除,产物里没有它

为什么 CJS 做不到? require 可以在 if 里、循环里、任何地方调用——它是运行时的。打包工具不知道哪些导出会被用到。而 ESM 的 import 只能是顶层静态声明,编译时就能画出完整依赖图。

4. 动态 import — 按需加载

1
2
3
4
5
6
7
8
9
10
11
12
// 静态 import 不能放条件分支
// import { heavy } from './heavy.js'; // ❌ 不能放 if 里

// 动态 import 返回 Promise,随时随地调用
if (condition) {
  const { heavy } = await import('./heavy.js');
  heavy();
}

// Vue Router / React Router 的路由懒加载就是靠这个
const Profile = () => import('./pages/Profile.vue');
const LazyPage = React.lazy(() => import('./Page.tsx'));

5. ESM vs CJS 速查

 CommonJSES Module
加载时机运行时编译时解析,执行时取值
加载方式同步阻塞异步
导出值拷贝实时绑定(引用)
Tree Shaking❌ 条件 require 无法静态分析
循环依赖拿到不完整的对象引用不丢,但值可能是 undefined
顶层 await✅ (ES2022)
浏览器原生❌ 需打包
Node.js默认.mjs"type": "module"

其实你每天都在用

  • import React from 'react' — 你的源码写 ESM,babel / tsc 转成 CJS 或保留 ESM
  • Vite 开发服务器 — 直接用浏览器原生 <script type="module"> 加载,不打包、秒启动
  • 路由懒加载() => import('./Home.vue') 把应用拆成独立 chunk,首屏只加载需要的
  • npm 包的 "exports""module" 字段 — 告诉打包工具 ESM 入口在哪,优先走 ESM
  • Tree Shaking 减小包体积 — Lodash 用 import { debounce } from 'lodash-es' 代替 import _ from 'lodash'

常见误解

  • ❌ 误区:「import 是同步的」 恰恰相反。浏览器中 <script type="module"> 默认是异步 + defer 执行。Node 中 ESM 也是异步加载。CJS 的 require 才是真正同步阻塞的(读文件 + 执行)。

  • ❌ 误区:「export default 和 export const 没啥区别」 有。export default 没有绑定名字,导入方自由命名——IDE 重构时追踪困难、Tree Shaking 也会受影响。社区共识:除非模块只有一个主功能,否则优先用命名导出。

  • ❌ 误区:「ESM 在 Node 可以直接 import CJS,所以互操作没问题」 方向是单向的。ESM 可以 import CJS(Node 做了一层包装),但 CJS 不能 require ESM。项目里 CJS 和 ESM 混用时,注意依赖方向别搞反。

  • ❌ 误区:「Tree Shaking 是 Rollup/Webpack 的特性,和 ESM 没关系」 打包工具的 Tree Shaking 必须建立在 ESM 静态结构之上。CJS 代码根本无法安全 Tree Shake——因为 require 可以出现在任何地方,工具不知道哪些代码是 dead code。

一句话总结

ES Module 的静态分析让打包工具有了上帝视角,Live Binding 让循环依赖不再断裂,异步加载让浏览器原生模块成为现实——这才是语言级模块系统的真正意义。

本文由作者按照 CC BY 4.0 进行授权