WinddSnow

JavaScript-Modules-ES-Modules-Import-Export

字数统计: 4.1k阅读时长: 17 min
2026/07/30

第58课:模块化(ES Modules)——export/import、动态导入、Tree Shaking

模块化是将大型程序拆分为独立、可复用的小块(模块)的机制。在 ES6 之前,JavaScript 没有原生的模块系统,社区通过 CommonJS(Node.js)和 AMD(浏览器)等方案弥补。ES6 引入了官方标准的 ES Modules(ESM),使用 importexport 关键字在编译时定义模块依赖。ES Modules 带来了静态结构(使得 Tree Shaking 和打包优化成为可能)、默认严格模式独立的模块作用域以及异步加载能力。本节课将系统讲解导出与导入的各种语法、动态 import()、模块在浏览器与 Node.js 中的使用差异,以及 ESM 与 CommonJS 互操作的常见陷阱。


1. 命名导出与默认导出

1.1 命名导出(Named Export)

一个模块可以有多个命名导出。导出声明可以放在变量、函数或类声明之前,也可以单独使用 export { ... } 批量导出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// math.js —— 命名导出模块
export const PI = 3.14159;

export function add(a, b) {
return a + b;
}

export function subtract(a, b) {
return a - b;
}

// 等价于:先声明,再集中导出
// const PI = 3.14159;
// function add(a, b) { return a + b; }
// export { PI, add, subtract };

重命名导出:使用 as 关键字为导出提供不同的外部名称。

1
2
function veryLongInternalFunctionName() { /* ... */ }
export { veryLongInternalFunctionName as shortName };

1.2 默认导出(Default Export)

每个模块只能有一个默认导出。默认导出适用于模块的“主要功能”——如一个类、一个主要函数或一个配置对象。

1
2
3
4
// validator.js —— 默认导出模块
export default function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

默认导出也可以与命名导出共存:

1
2
3
// userUtils.js
export default class User { /* ... */ }
export function formatUserName(name) { /* ... */ }

注意export default 后面可以直接跟表达式(如字面量、函数调用结果),但命名导出必须跟声明或使用花括号。

1
2
3
4
5
6
7
// ✅ 默认导出表达式
export default 42;
export default function() {}; // 匿名函数作为默认导出
export default { key: 'value' };

// ❌ 命名导出表达式
// export 42; // SyntaxError

2. 导入语法详解

2.1 命名导入

从模块中按名称导入一个或多个导出成员。导入名称必须与导出名称匹配(除非使用 as 重命名)。

1
2
3
4
5
import { add, subtract, PI } from './math.js';

console.log(add(5, 3)); // 8
console.log(subtract(10, 4)); // 6
console.log(PI); // 3.14159

导入重命名:使用 as 避免命名冲突。

1
2
3
4
5
import { add as mathAdd, subtract as mathSubtract } from './math.js';
import { add as stringAdd } from './string.js';

console.log(mathAdd(1, 2)); // math 模块的 add
console.log(stringAdd('a', 'b')); // string 模块的 add

2.2 默认导入

导入默认导出时,可以使用任意名称(不需要与原始名称一致)。

1
2
3
import validateEmail from './validator.js';

console.log(validateEmail('test@example.com')); // true

同时导入默认和命名导出

1
2
3
4
import User, { formatUserName } from './userUtils.js';

const user = new User('Alice');
console.log(formatUserName(user.name));

2.3 命名空间导入(Namespace Import)

将整个模块的所有导出成员作为一个对象导入。该对象是只读的(不能修改其属性)。

1
2
3
4
5
import * as MathUtils from './math.js';

console.log(MathUtils.add(1, 2)); // 3
console.log(MathUtils.PI); // 3.14159
// MathUtils.PI = 3; // TypeError: Cannot assign to read only property

2.4 仅执行模块的副作用(Side-effect Import)

导入模块仅执行其顶层代码,不导入任何成员。用于加载 polyfill、全局样式或注册全局变量。

1
2
import './polyfills.js'; // 仅执行 polyfills 的初始化代码
import './styles.css'; // 加载 CSS(需要构建工具支持)

2.5 空导入

如果确实不需要任何导入成员,但需要触发模块的副作用,使用上述副作用导入。语法上不需要任何绑定名称。


3. 模块特性:静态结构、作用域与提升

3.1 静态结构

ES Modules 的 importexport静态声明——模块的依赖图在代码执行之前就已经确定。这意味着:

  • import 必须出现在模块的顶层,不能嵌套在 iftry-catch 或函数中(动态 import() 例外)。
  • 模块路径不能是动态计算的字符串(如 import(variable) 不允许,动态导入例外)。
1
2
3
4
// ❌ 语法错误:import 在 if 内部
// if (condition) {
// import { something } from './module.js';
// }

静态结构的最大优势是 Tree Shaking——打包工具(Webpack、Rollup、Vite)可以分析哪些导出成员实际上被使用了,未被引用的代码可以在生产构建中被安全移除。

3.2 模块作用域

每个 ES Module 都有自己的顶级作用域。在模块顶层使用 varletconst 声明的变量,不会成为全局对象的属性,也不会泄漏到其他模块。

1
2
3
// moduleA.js
const localVar = 'I am private';
export const publicVar = 'I am public';
1
2
3
4
// moduleB.js
import { publicVar } from './moduleA.js';
console.log(publicVar); // 'I am public'
// console.log(localVar); // ReferenceError: localVar is not defined

<script type="module"> 中,顶层 thisundefined(普通脚本中 thiswindow)。这与严格模式行为一致——模块自动处于严格模式。

3.3 导入的绑定是实时只读引用

import 导入的绑定不是值的拷贝,而是对导出模块中变量的只读实时引用。如果导出模块中的值发生变化,导入方也会看到最新值(但通常不推荐在模块中修改导出的变量)。

1
2
3
4
5
// counter.js
export let count = 0;
export function increment() {
count++;
}
1
2
3
4
5
6
// app.js
import { count, increment } from './counter.js';

console.log(count); // 0
increment();
console.log(count); // 1 —— 实时反映 counter.js 中 count 的变化

这与 CommonJS 的 require 完全不同——CommonJS 导出的是值的副本(对于基本类型)或对象引用的快照。

3.4 提升与循环依赖

import 声明会被提升到模块顶部,所有导入在模块代码执行之前就已经解析和链接。这在一定程度上允许循环依赖,但需要谨慎处理。

1
2
3
4
5
6
7
8
9
// a.js
import { b } from './b.js';
export const a = 1;
console.log('a.js:', b);

// b.js
import { a } from './a.js';
export const b = 2;
console.log('b.js:', a);

a.js 执行时,它先去加载和解析 b.jsb.js 在执行时,a.jsa 尚未初始化(处于 TDZ),访问 a 会抛出 ReferenceError避免循环依赖是模块设计的首要原则。如果必须存在,将共享的类型定义提取到独立的 types.js 模块中。


4. 动态 import():运行时按需加载

静态 import 要求模块路径在编译时已知。动态 import() 是 ES2020 引入的函数式操作符,返回一个 Promise,在运行时异步加载模块。它可用于代码分割、条件加载和按需加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 条件加载
if (userRole === 'admin') {
import('./admin-dashboard.js')
.then(module => module.initDashboard());
}

// 异步函数中使用
async function loadChartLibrary() {
const { Chart } = await import('./chart.js');
return new Chart();
}

// 事件驱动加载
button.addEventListener('click', async () => {
const module = await import('./heavy-feature.js');
module.launch();
});

动态导入与静态导入的区别

特性 静态 import 动态 import()
声明位置 仅模块顶层 任何位置
模块路径 必须是字符串字面量 可以是变量或表达式
加载时机 编译时确定依赖,提前加载 运行时按需加载
返回值 绑定引用 Promise
Tree Shaking 支持 通常不支持(运行时才确定路径)
适用场景 常规模块导入 代码分割、条件加载、性能优化

5. 浏览器与 Node.js 中的模块

5.1 浏览器中的 ES Modules

使用 <script type="module"> 标签引入模块。浏览器模块默认是延迟执行的(类似 <script defer>)——不会阻塞 HTML 解析,在文档解析完毕后按顺序执行。

1
2
3
4
5
<script type="module" src="app.js"></script>
<script type="module">
import { init } from './app.js';
init();
</script>

浏览器模块需要服务器提供正确的 MIME 类型(application/javascripttext/javascript),否则会被拒绝加载。

**import.meta**:在浏览器模块中,import.meta.url 返回当前模块的完整 URL。

1
console.log(import.meta.url); // 'https://example.com/js/app.js'

5.2 Node.js 中的 ES Modules

从 Node.js 12 开始,ES Modules 正式稳定。有两种方式启用 ESM:

  • 文件扩展名设置为 .mjs
  • package.json 中设置 "type": "module",此时 .js 文件被视为 ESM。

在 ESM 模式下,importexport 可用,require 不可用(需通过 createRequire 或动态 import() 加载 CommonJS 模块)。全局变量 __dirname__filename 不可用,需通过 import.meta.url 推导。

1
2
3
4
5
6
// Node.js ESM 中获取 __dirname 的等效写法
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

6. ESM 与 CommonJS 互操作的常见陷阱

6.1 CommonJS require 导入 ESM

CommonJS 模块不能直接使用 require 加载 ES Module(在 Node.js 中会抛出 ERR_REQUIRE_ESM)。必须使用动态 import()

1
2
3
4
// CommonJS 模块中异步加载 ESM
(async () => {
const { default: myModule } = await import('./esm-module.mjs');
})();

6.2 ESM import 导入 CommonJS

ES Module 可以通过 import 加载 CommonJS 模块。Node.js 将 CommonJS 的 module.exports 作为默认导出提供给 ESM。

1
2
3
// cjs-module.js (CommonJS)
module.exports = { name: 'Alice', age: 25 };
module.exports.status = 'active';
1
2
3
4
5
6
7
// esm-consumer.mjs (ESM)
import cjsModule from './cjs-module.js';
console.log(cjsModule); // { name: 'Alice', age: 25, status: 'active' }

// 也可以使用命名导入(Node.js 会静态分析 CJS 导出)
import { name, age } from './cjs-module.js';
console.log(name, age); // 'Alice' 25

注意:命名导入 CJS 模块是 Node.js 的增强特性,并非标准 ESM 行为。在其他环境中(如浏览器)不可用。为了最大化兼容性,导入 CJS 模块时优先使用默认导入。


课后练习

一、概念自测(选择题 / 填空题)

  1. (单选) 以下关于 ES Modules 的描述,哪项是错误的?
    A. 模块自动处于严格模式。
    B. import 声明必须出现在模块顶层。
    C. 一个模块只能有一个默认导出。
    D. 导入的绑定是导出值的副本,不会随原模块变化。

  2. (单选) import * as Utils from './utils.js' 之后,Utils 对象可以修改其属性吗?
    A. 可以,它是可变对象。
    B. 不可以,导入的命名空间是只读的。
    C. 取决于是否开启严格模式。
    D. 仅在 Node.js 中可以。

  3. (填空) 要在运行时按需加载模块,应使用 ______ 语法,它返回一个 Promise。

  4. (多选) 以下哪些是 ES Modules 相比 CommonJS 的优势?
    A. 静态结构,支持 Tree Shaking。
    B. 导入的绑定是实时只读引用。
    C. require 可以在任何位置使用。
    D. 异步加载,不阻塞主线程。

二、AI 编程任务:编写面向 AI 的提示词

场景:你需要编写一个 JavaScript 模块集合,包含以下三个文件:

  • math.js:导出命名函数 add(两数相加)、multiply(两数相乘)和常量 PI
  • string.js:默认导出一个函数 capitalize(str),将字符串首字母大写;同时命名导出一个函数 reverse(str),返回反转后的字符串。
  • app.js:从 math.js 导入 addPI,从 string.js 导入默认的 capitalize 和命名的 reverse,并演示它们的使用,打印结果到控制台。

任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的三个 JavaScript 模块文件。提示词中需明确指定每个文件的导出方式(命名导出、默认导出)、导入语法以及使用示例。

三、Agent 模式下的提示词示例

你是一个资深前端开发 Agent。请创建三个 ES Module 文件,构成一个简单的工具库。需要创建:

  1. src/math.js:导出命名导出 add(两数相加并返回)、multiply(两数相乘并返回)和 PI(常量 3.14159)。
  2. src/string.js:默认导出函数 capitalize(str),将传入字符串的首字母大写并返回(若传入空字符串则返回空字符串);同时命名导出 reverse(str),返回字符串的反转。
  3. src/app.js:使用静态 importmath.js 导入 addPI;使用默认导入导入 capitalize,同时命名导入 reversestring.js。编写逻辑调用这些函数并 console.log 输出结果。在文件顶部添加一行注释说明这是一个演示模块。
  4. 所有文件使用 const 声明变量,添加简要 JSDoc 注释。确保代码语法正确,可直接在 Node.js (ESM) 或浏览器 <script type="module"> 中运行。不要使用 CommonJS 语法。完成后列出三个文件的内容。

四、面试真题与参考答案

题目(美团前端面试题):

请详细解释 ES Modules 和 CommonJS 在模块加载机制上的核心区别,包括:加载时机、导出值的特性、静态分析能力和循环依赖处理。为什么 Tree Shaking 通常在 ES Modules 上实现?

参考答案

1. 加载时机

  • CommonJS运行时同步加载require() 在代码执行时才加载模块,从磁盘/网络读取文件并执行,期间阻塞后续代码。适合服务端,不适合浏览器(同步加载远程资源会冻结页面)。
  • ES Modules编译时静态分析 + 异步加载。模块的依赖关系在执行前就已确定,浏览器可以并行下载所有模块,不阻塞 HTML 解析。

2. 导出值的特性

  • CommonJSmodule.exports 导出的是一个对象的引用快照。对于基本类型,导出的是值的副本(后续修改不影响导入方);对于对象,是引用的快照。
  • ES Modules:导出的是实时只读绑定。导入方看到的是导出模块中变量的实时值,但不能修改它们。

3. 静态分析能力

  • CommonJSrequire() 可以在任何地方、接收动态表达式,导致依赖图在运行时才能确定,打包工具难以做静态分析。
  • ES Modulesimport/export 必须在顶层,路径必须是字面量,使得打包工具可以在构建时确定依赖图。

4. 循环依赖处理

  • CommonJS:遇到循环依赖时,require 返回当前已执行部分的 module.exports(可能不完整),后续属性在完全初始化后才可用。
  • ES Modules:通过提升和绑定机制,循环依赖时导入的绑定是可用的,但如果访问尚未初始化的变量会触发 TDZ 错误。

5. Tree Shaking 与 ESM:Tree Shaking 依赖导入/导出关系的静态可分析性。ES Modules 的静态结构允许打包工具精确地知道哪些导出被使用、哪些未被使用,从而安全地移除死代码。CommonJS 的动态性使得这种分析几乎不可能。


课后练习答案

一、概念自测答案

  1. D

    • 解析:ES Modules 导入的是实时只读引用,而非值的副本。D 描述的是 CommonJS 行为。
  2. B

    • 解析:命名空间导入的对象是只读的,尝试修改会抛出 TypeError。这是 ES Modules 规范的一部分。
  3. **import()**(动态导入)

    • 解析:import() 是动态导入操作符,返回 Promise
  4. A、B

    • 解析:A 正确,静态结构使 Tree Shaking 成为可能;B 正确,实时只读引用是 ES Modules 的核心特性;C 错误,require 是 CommonJS 语法;D 不准确,ES Modules 的异步加载主要体现在浏览器环境,但并非所有场景都异步。

二、AI 编程任务参考答案(提示词示例)

示例提示词
“请生成三个 ES Module 文件。要求:

  • math.js:使用命名导出 export const PI = 3.14159;export function add(a, b) { return a + b; } 以及 export function multiply(a, b) { return a * b; }
  • string.js:默认导出 export default function capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1); };同时命名导出 export function reverse(str) { return str.split('').reverse().join(''); }
  • app.js:使用 import { add, PI } from './math.js';import capitalize, { reverse } from './string.js';。调用 console.log(add(3, 5))console.log(PI)console.log(capitalize('hello'))console.log(reverse('world'))
  • 所有文件使用 ESM 语法,不包含 CommonJS。代码简洁,注释清晰。直接输出三个文件的完整内容。”
CATALOG
  1. 1. 第58课:模块化(ES Modules)——export/import、动态导入、Tree Shaking
    1. 1.1. 1. 命名导出与默认导出
      1. 1.1.1. 1.1 命名导出(Named Export)
      2. 1.1.2. 1.2 默认导出(Default Export)
    2. 1.2. 2. 导入语法详解
      1. 1.2.1. 2.1 命名导入
      2. 1.2.2. 2.2 默认导入
      3. 1.2.3. 2.3 命名空间导入(Namespace Import)
      4. 1.2.4. 2.4 仅执行模块的副作用(Side-effect Import)
      5. 1.2.5. 2.5 空导入
    3. 1.3. 3. 模块特性:静态结构、作用域与提升
      1. 1.3.1. 3.1 静态结构
      2. 1.3.2. 3.2 模块作用域
      3. 1.3.3. 3.3 导入的绑定是实时只读引用
      4. 1.3.4. 3.4 提升与循环依赖
    4. 1.4. 4. 动态 import():运行时按需加载
    5. 1.5. 5. 浏览器与 Node.js 中的模块
      1. 1.5.1. 5.1 浏览器中的 ES Modules
      2. 1.5.2. 5.2 Node.js 中的 ES Modules
    6. 1.6. 6. ESM 与 CommonJS 互操作的常见陷阱
      1. 1.6.1. 6.1 CommonJS require 导入 ESM
      2. 1.6.2. 6.2 ESM import 导入 CommonJS
    7. 1.7. 课后练习
      1. 1.7.1. 一、概念自测(选择题 / 填空题)
      2. 1.7.2. 二、AI 编程任务:编写面向 AI 的提示词
      3. 1.7.3. 三、Agent 模式下的提示词示例
      4. 1.7.4. 四、面试真题与参考答案
    8. 1.8. 课后练习答案
      1. 1.8.1. 一、概念自测答案
      2. 1.8.2. 二、AI 编程任务参考答案(提示词示例)