在 ES6 之前,JavaScript 一直没有官方的模块系统,社区各自发展出了 CommonJS、AMD、CMD 等方案。ES6 正式引入了 ES Modules(ESM),将模块化作为语言标准,从根本上改变了代码组织方式。
11.7.1 为什么需要模块化
没有模块化时,所有代码都在全局作用域中,变量和函数会互相污染、覆盖,依赖关系难以追踪。模块化的核心目标很简单:
- 封装性:每个模块拥有自己独立的作用域,不会污染全局。
- 显式依赖:模块之间的依赖关系通过 import 和 export 清晰表达,一眼即可看清结构。
- 可复用性:功能拆分到独立模块后,可以在不同项目中复用。
11.7.2 导出(export)
ESM 提供两种主要导出方式:命名导出和默认导出。
命名导出
一个模块可以导出多个具有名称的变量、函数或类。导出语句并不要求放在末尾,可以在声明时直接导出,也可以单独导出。
// math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// 或者先声明再导出
const PI = 3.14159;
function add(a, b) { return a + b; }
export { PI, add };
导出时也可以使用 as 进行重命名:
export { add as sum };
默认导出
每个模块只能有一个默认导出,通常用于导出模块的“主力”功能,比如一个函数、类或对象。
// logger.js
export default function log(msg) {
console.log(`[LOG] ${msg}`);
}
默认导出可以匿名,但给一个名称有助于可读性。一个文件中可以同时存在命名导出和默认导出:
export const version = '1.0';
export default class Calculator { /* ... */ }
11.7.3 导入(import)
导入命名导出
要导入命名导出,必须使用花括号,且名称必须与导出时的名称完全一致(除非用 as 重命名)。
import { PI, add } from './math.js';
console.log(PI); // 3.14159
console.log(add(2, 3)); // 5
可以通过 as 在导入时重命名,避免命名冲突:
import { add as mathAdd } from './math.js';
import { add as stringAdd } from './string.js';
也可以使用 * 将整个模块作为一个对象导入:
import * as math from './math.js';
console.log(math.PI);
console.log(math.add(1, 2));
导入默认导出
导入默认导出时不需要花括号,可以自定义一个名称:
import log from './logger.js';
log('hello');
混合导入
同时导入默认导出和命名导出:
import Calculator, { version } from './calculator.js';
仅执行模块而不导入内容
有些模块只是执行副作用(如注册全局变量、polyfill),并不需要显式导入任何绑定:
import './polyfills.js';
11.7.4 静态特性与限制
ESM 的导入导出是静态的,这意味着:
- import 和 export 必须位于模块顶层,不能放在条件语句或函数内部。
这保证了所有依赖关系在代码执行前就已经确定,为打包工具的树摇(Tree Shaking)和静态分析提供了可能。
- 导入的绑定是只读的“实时引用”,而不是值的拷贝。
这与 CommonJS 完全不同。在 ESM 中,导入的变量是对导出模块中对应变量的直接引用,如果导出模块内部改变了该变量的值,导入方会立即反映。
// counter.js
export let count = 0;
export function increment() {
count++;
}
// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 —— 实时引用,不是拷贝
这与 CommonJS 的 module.exports 是值拷贝形成鲜明对比。CommonJS 的模块导出的是值的快照,后续原始变量的变化不会影响已导入的值(反之亦然)。
11.7.5 动态导入(Dynamic Import)
尽管 ESM 强调静态结构,ES2020 也引入了 import() 动态导入 函数,它返回一个 Promise,可以在任意位置按需加载模块,实现代码分割和条件加载。
if (condition) {
import('./feature.js')
.then(module => {
module.doSomething();
});
}
动态导入常用于路由懒加载、大体积库的按需加载等场景。需要注意,动态导入破坏了静态分析的优势,打包工具无法完全去除未使用的导出,因此应合理使用。
11.7.6 ESM 与 CommonJS 的核心差异
| 特性 | ES Modules | CommonJS |
|------|------------|----------|
| 引入时机 | 编译时静态解析 | 运行时加载 |
| 导出绑定 | 实时引用(live binding) | 值拷贝 |
| this 指向 | 模块顶层 this 为 undefined | 模块顶层 this 指向当前模块(module.exports) |
| 循环引用处理 | 可正确处理(引用已存在) | 可能因加载顺序导致部分导出为空对象 |
| 动态导入 | import() 支持 | require() 本身就是同步动态加载 |
| 使用环境 | 浏览器 & Node.js (>=12) | Node.js |
11.7.7 实用建议
- 优先使用 ESM。在现代项目(无论是前端还是 Node.js)中,ESM 已经成为标准,打包工具也深度依赖其静态结构进行优化。
- 模块路径清晰。浏览器中需要指定完整的路径(包括扩展名
.js),而打包工具和 Node.js 通常可以省略(Node.js 支持解析无扩展名的文件)。 - 避免在模块顶层执行副作用。纯导出功能的模块更容易被 Tree Shaking 优化,也更容易测试。
- 使用命名导出还是默认导出?
命名导出更有利于编辑器自动导入和树摇,且名字明确;默认导出适合模块只有一个主要输出物的场景。团队内部应统一规范,以减少混淆。
掌握了 ESM 的设计和使用,你就拥有了构建大型、可维护 JavaScript 应用的基石。它与 CommonJS 的兼容性、打包工具的集成等内容,将在后续工程化章节进一步展开。