ES6 模块化(ES Modules,简称 ESM)是现代 JavaScript 的核心基石之一。它的出现,彻底改变了 JavaScript 的代码组织方式,告别了过去依赖全局变量、IIFE(立即调用函数表达式)、CommonJS(Node.js)、AMD(RequireJS)等社区方案的混乱时代,为语言本身带来了官方的、统一的模块化解决方案。
下面,我将为您系统、深入地详解 ES6 模块化的所有相关语法,并阐述其背后的设计思想与工作原理。
现代JavaScript的基石:深入解析ES6模块化(ESM)语法全景
一、为什么需要模块化?
在了解具体语法之前,我们必须明白 ES 模块化解决了什么核心问题:
- 命名空间与作用域隔离:避免了在网页中通过多个
<script>标签引入 JS 文件时,不同文件中的变量相互冲突(全局作用域污染)的问题。每个模块都有自己独立的作用域。 - 依赖管理:清晰地声明一个模块依赖于哪些其他模块,使得代码的依赖关系一目了然,便于维护和理解。
- 代码复用与组织:允许我们将功能内聚的代码封装成独立的单元(模块),在需要的地方按需导入,极大地提高了代码的可复用性和组织性。
- 性能优化:由于其静态的导入/导出结构,构建工具(如 Webpack, Vite)可以在编译时进行分析,实现摇树优化(Tree Shaking),即只打包代码中实际使用到的部分,有效减小最终产物体积。
二、ES6 模块化的核心思想
- 一个文件一个模块:在 ESM 中,每一个
.js文件都被视为一个独立的模块。 - 自动进入严格模式:模块内的代码默认就是严格模式(
'use strict';),无需手动声明。 - 私有作用域:模块内部声明的所有变量、函数、类,默认都是私有的,外部无法直接访问。
- 通过
export暴露,通过import引入:模块必须显式地使用export关键字来“暴露”或“导出”希望被外部使用的接口。其他模块则通过import关键字来“导入”这些接口。 - 静态结构:
import和export命令都只能出现在模块的顶层作用域中,不能在if语句、循环、函数等代码块中使用。这种静态性使得依赖关系在编译时就能确定下来,这是实现 Tree Shaking 的基础。
三、export:如何从模块中导出接口
export 命令用于规定模块的对外接口。一个模块可以有多个 export。主要有两种导出方式:命名导出(Named Exports)和默认导出(Default Export)。
1. 命名导出 (Named Exports)
命名导出是最常用、最灵活的方式。你可以导出多个变量、函数或类。
方式一:在声明时直接导出
// file: utils.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export class Calculator {
// ... class implementation
}
方式二:在模块末尾集中导出
这种方式更清晰,可以一目了然地看到模块导出了哪些接口。
// file: utils.js
const PI = 3.14159;
function add(a, b) {
return a + b;
}
class Calculator {
// ...
}
// 使用 {} 将需要导出的接口包裹起来
export { PI, add, Calculator };
方式三:导出时重命名
如果你希望导出的接口在外部被调用时使用不同的名字,可以使用 as 关键字。
// file: utils.js
function internalAddFunction(a, b) {
return a + b;
}
// 外部导入时需要使用 `sum` 这个名字
export { internalAddFunction as sum };
2. 默认导出 (Default Export)
export default 用于指定模块的默认输出。每个模块只能有一个 export default。
默认导出非常适合于一个模块只导出一个主要功能(比如一个类、一个函数)的场景。
// file: MyComponent.js
// 导出一个类
export default class MyComponent {
// ...
}
// file: mainFunction.js
// 导出一个函数
export default function() {
console.log('This is the default export.');
}
// file: config.js
// 导出一个对象
export default {
apiKey: '12345'
};
注意:export default 后面可以直接跟值,如 export default 123;。它本质上是导出了一个名为 default 的变量,所以 export default const myVar = 10; 是错误的语法。正确的写法是:
const myVar = 10; export default myVar;
四、import:如何从其他模块导入接口
import 命令用于加载其他模块提供的接口。
1. 导入命名导出的接口
使用花括号 {} 来指定要导入的变量名,且这些名字必须与 export 的名字完全一致。
// file: main.js
import { PI, add, Calculator } from './utils.js';
console.log(PI); // 3.14159
const result = add(2, 3); // 5
const calc = new Calculator();
导入时重命名
如果导入的变量名与当前作用域的变量名冲突,或者你想要一个更简洁的名字,可以使用 as 关键字。
import { PI as CircleConstant, add as sum } from './utils.js';
console.log(CircleConstant); // 3.14159
sum(1, 1);
整体导入(命名空间导入)
将一个模块导出的所有接口都导入到一个对象中,这在导入的接口较多时非常有用。
import * as Utils from './utils.js';
console.log(Utils.PI);
const result = Utils.add(4, 5);
const calc = new Utils.Calculator();
2. 导入默认导出的接口
导入默认导出的接口时,你可以为它指定任意的名称,且不需要使用花括号。
// 假设 MyComponent.js 使用了 export default
import MyAwesomeComponent from './MyComponent.js';
const component = new MyAwesomeComponent();
3. 混合导入
在同一个 import 语句中,可以同时导入默认导出和命名导出。默认导出必须在命名导出之前。
// 假设 module.js 中既有 export default 又有命名 export
// export default function mainFunc() {}
// export const helper = () => {};
import mainFunc, { helper } from './module.js';
mainFunc();
helper();
4. 仅为副作用而导入
有时,你可能只想执行一个模块中的代码(比如它会向全局对象添加 polyfill,或者执行一些初始化配置),而不需要导入任何接口。
// 这会执行 a-polyfill.js 里的代码,但不会导入任何变量
import './a-polyfill.js';
五、export from:模块的聚合与转发
这个语法允许你将从其他模块导入的接口,直接再次导出,而无需在当前模块中进行中间存储。这在创建一个“包”的入口文件时非常有用。
假设你有 add.js 和 subtract.js,你想创建一个 math.js 来统一导出它们。
// file: add.js
export function add(a, b) { return a + b; }
// file: subtract.js
export function subtract(a, b) { return a - b; }
// file: math.js (聚合导出)
// 从 add.js 导入并立即导出 add 函数
export { add } from './add.js';
// 从 subtract.js 导入并立即导出 subtract 函数
export { subtract } from './subtract.js';
// 也可以使用 * 转发所有命名导出
// export * from './add.js';
// export * from './subtract.js';
// 也可以转发时重命名
// export { add as sum } from './add.js';
现在,其他模块可以直接从 math.js 导入所有数学函数:
import { add, subtract } from './math.js';
六、高级主题:动态 import()
前面提到,import 命令是静态的,必须在模块顶层。但有时我们希望按需、动态地加载模块,比如在用户点击某个按钮后才加载对应的功能模块(代码分割/懒加载)。
ES2020 引入了动态 import() 函数。它看起来像函数调用,返回一个 Promise。
button.addEventListener('click', async () => {
try {
const module = await import('./heavy-module.js');
// 如果 heavy-module.js 使用了 export default
const myModule = module.default;
myModule.doSomething();
// 如果是命名导出
// const { someFunction } = await import('./heavy-module.js');
// someFunction();
} catch (error) {
console.error('Failed to load module:', error);
}
});
import() 可以在代码的任何地方使用,是实现代码分割和懒加载的标准方式。
七、核心差异:ESM 与 CommonJS
- 输出值的拷贝 vs 实时绑定(Live Bindings):CommonJS (
require) 导出的是值的拷贝(对于原始类型)或引用(对于对象)。一旦导出,模块内部的变化不会影响已经导入的值。而 ESM 导出的是绑定(一个符号链接),模块内部变量的值发生变化,导入方获取到的值也会实时更新。 - 同步加载 vs 异步/静态加载:CommonJS 是同步加载,模块在执行时才被加载和编译,适用于服务器端。ESM 的设计目标之一是浏览器,其静态结构允许在编译时分析依赖,支持异步加载,不会阻塞页面渲染。
总结
| 功能/语法 | 示例代码 | 说明 |
|---|---|---|
| 命名导出 (Named) | export const a = 1; <br> export { b }; |
导出带有明确名称的接口,可导出多个。 |
| 默认导出 (Default) | export default function() {}; |
每个模块最多一个,是模块的主要输出。 |
| 导入命名接口 | import { a, b as otherB } from './mod.js'; |
使用 {} 导入,名称需匹配,可使用 as 重命名。 |
| 导入默认接口 | import MyModule from './mod.js'; |
直接指定一个名称接收默认导出。 |
| 命名空间导入 | import * as mod from './mod.js'; |
将所有命名导出收集到一个对象中。 |
| 混合导入 | import Default, { named } from './mod.js'; |
同时导入默认和命名接口。 |
| 模块转发 | export { a } from './other.js'; <br> export * from './other.js'; |
在不引入当前模块作用域的情况下,聚合和再导出其他模块的接口。 |
| 动态导入 | const mod = await import('./mod.js'); |
按需、异步加载模块,返回一个 Promise。 |
ES6 模块化是现代 Web 开发的基石,无论是使用 React、Vue、Angular 等框架,还是使用 Vite、Webpack 等构建工具,其底层都深度依赖于 ESM。熟练掌握它,是每一位现代 JavaScript 开发者必备的技能。