文章

ES Module 规范与特性深度解析

ESM 与 CJS 是两套范式:静态结构支撑 Tree Shaking、Live Binding 让跨模块变更实时可见、浏览器原生支持零打包。 面试对比两者差异与循环依赖时的表现。

ES Module 规范与特性深度解析

一句话概括

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 速查

 CommonJSES 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 的一个功能。

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