第58课:模块化(ES Modules)——export/import、动态导入、Tree Shaking
模块化是将大型程序拆分为独立、可复用的小块(模块)的机制。在 ES6 之前,JavaScript 没有原生的模块系统,社区通过 CommonJS(Node.js)和 AMD(浏览器)等方案弥补。ES6 引入了官方标准的 ES Modules(ESM),使用 import 和 export 关键字在编译时定义模块依赖。ES Modules 带来了静态结构(使得 Tree Shaking 和打包优化成为可能)、默认严格模式、独立的模块作用域以及异步加载能力。本节课将系统讲解导出与导入的各种语法、动态 import()、模块在浏览器与 Node.js 中的使用差异,以及 ESM 与 CommonJS 互操作的常见陷阱。
1. 命名导出与默认导出
1.1 命名导出(Named Export)
一个模块可以有多个命名导出。导出声明可以放在变量、函数或类声明之前,也可以单独使用 export { ... } 批量导出。
1 | // math.js —— 命名导出模块 |
重命名导出:使用 as 关键字为导出提供不同的外部名称。
1 | function veryLongInternalFunctionName() { /* ... */ } |
1.2 默认导出(Default Export)
每个模块只能有一个默认导出。默认导出适用于模块的“主要功能”——如一个类、一个主要函数或一个配置对象。
1 | // validator.js —— 默认导出模块 |
默认导出也可以与命名导出共存:
1 | // userUtils.js |
注意:export default 后面可以直接跟表达式(如字面量、函数调用结果),但命名导出必须跟声明或使用花括号。
1 | // ✅ 默认导出表达式 |
2. 导入语法详解
2.1 命名导入
从模块中按名称导入一个或多个导出成员。导入名称必须与导出名称匹配(除非使用 as 重命名)。
1 | import { add, subtract, PI } from './math.js'; |
导入重命名:使用 as 避免命名冲突。
1 | import { add as mathAdd, subtract as mathSubtract } from './math.js'; |
2.2 默认导入
导入默认导出时,可以使用任意名称(不需要与原始名称一致)。
1 | import validateEmail from './validator.js'; |
同时导入默认和命名导出:
1 | import User, { formatUserName } from './userUtils.js'; |
2.3 命名空间导入(Namespace Import)
将整个模块的所有导出成员作为一个对象导入。该对象是只读的(不能修改其属性)。
1 | import * as MathUtils from './math.js'; |
2.4 仅执行模块的副作用(Side-effect Import)
导入模块仅执行其顶层代码,不导入任何成员。用于加载 polyfill、全局样式或注册全局变量。
1 | import './polyfills.js'; // 仅执行 polyfills 的初始化代码 |
2.5 空导入
如果确实不需要任何导入成员,但需要触发模块的副作用,使用上述副作用导入。语法上不需要任何绑定名称。
3. 模块特性:静态结构、作用域与提升
3.1 静态结构
ES Modules 的 import 和 export 是静态声明——模块的依赖图在代码执行之前就已经确定。这意味着:
import必须出现在模块的顶层,不能嵌套在if、try-catch或函数中(动态import()例外)。- 模块路径不能是动态计算的字符串(如
import(variable)不允许,动态导入例外)。
1 | // ❌ 语法错误:import 在 if 内部 |
静态结构的最大优势是 Tree Shaking——打包工具(Webpack、Rollup、Vite)可以分析哪些导出成员实际上被使用了,未被引用的代码可以在生产构建中被安全移除。
3.2 模块作用域
每个 ES Module 都有自己的顶级作用域。在模块顶层使用 var、let、const 声明的变量,不会成为全局对象的属性,也不会泄漏到其他模块。
1 | // moduleA.js |
1 | // moduleB.js |
在 <script type="module"> 中,顶层 this 是 undefined(普通脚本中 this 是 window)。这与严格模式行为一致——模块自动处于严格模式。
3.3 导入的绑定是实时只读引用
import 导入的绑定不是值的拷贝,而是对导出模块中变量的只读实时引用。如果导出模块中的值发生变化,导入方也会看到最新值(但通常不推荐在模块中修改导出的变量)。
1 | // counter.js |
1 | // app.js |
这与 CommonJS 的 require 完全不同——CommonJS 导出的是值的副本(对于基本类型)或对象引用的快照。
3.4 提升与循环依赖
import 声明会被提升到模块顶部,所有导入在模块代码执行之前就已经解析和链接。这在一定程度上允许循环依赖,但需要谨慎处理。
1 | // a.js |
当 a.js 执行时,它先去加载和解析 b.js。b.js 在执行时,a.js 的 a 尚未初始化(处于 TDZ),访问 a 会抛出 ReferenceError。避免循环依赖是模块设计的首要原则。如果必须存在,将共享的类型定义提取到独立的 types.js 模块中。
4. 动态 import():运行时按需加载
静态 import 要求模块路径在编译时已知。动态 import() 是 ES2020 引入的函数式操作符,返回一个 Promise,在运行时异步加载模块。它可用于代码分割、条件加载和按需加载。
1 | // 条件加载 |
动态导入与静态导入的区别:
| 特性 | 静态 import |
动态 import() |
|---|---|---|
| 声明位置 | 仅模块顶层 | 任何位置 |
| 模块路径 | 必须是字符串字面量 | 可以是变量或表达式 |
| 加载时机 | 编译时确定依赖,提前加载 | 运行时按需加载 |
| 返回值 | 绑定引用 | Promise |
| Tree Shaking | 支持 | 通常不支持(运行时才确定路径) |
| 适用场景 | 常规模块导入 | 代码分割、条件加载、性能优化 |
5. 浏览器与 Node.js 中的模块
5.1 浏览器中的 ES Modules
使用 <script type="module"> 标签引入模块。浏览器模块默认是延迟执行的(类似 <script defer>)——不会阻塞 HTML 解析,在文档解析完毕后按顺序执行。
1 | <script type="module" src="app.js"></script> |
浏览器模块需要服务器提供正确的 MIME 类型(application/javascript 或 text/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 模式下,import 和 export 可用,require 不可用(需通过 createRequire 或动态 import() 加载 CommonJS 模块)。全局变量 __dirname 和 __filename 不可用,需通过 import.meta.url 推导。
1 | // Node.js ESM 中获取 __dirname 的等效写法 |
6. ESM 与 CommonJS 互操作的常见陷阱
6.1 CommonJS require 导入 ESM
CommonJS 模块不能直接使用 require 加载 ES Module(在 Node.js 中会抛出 ERR_REQUIRE_ESM)。必须使用动态 import()。
1 | // CommonJS 模块中异步加载 ESM |
6.2 ESM import 导入 CommonJS
ES Module 可以通过 import 加载 CommonJS 模块。Node.js 将 CommonJS 的 module.exports 作为默认导出提供给 ESM。
1 | // cjs-module.js (CommonJS) |
1 | // esm-consumer.mjs (ESM) |
注意:命名导入 CJS 模块是 Node.js 的增强特性,并非标准 ESM 行为。在其他环境中(如浏览器)不可用。为了最大化兼容性,导入 CJS 模块时优先使用默认导入。
课后练习
一、概念自测(选择题 / 填空题)
(单选) 以下关于 ES Modules 的描述,哪项是错误的?
A. 模块自动处于严格模式。
B.import声明必须出现在模块顶层。
C. 一个模块只能有一个默认导出。
D. 导入的绑定是导出值的副本,不会随原模块变化。(单选)
import * as Utils from './utils.js'之后,Utils对象可以修改其属性吗?
A. 可以,它是可变对象。
B. 不可以,导入的命名空间是只读的。
C. 取决于是否开启严格模式。
D. 仅在 Node.js 中可以。(填空) 要在运行时按需加载模块,应使用
______语法,它返回一个 Promise。(多选) 以下哪些是 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导入add和PI,从string.js导入默认的capitalize和命名的reverse,并演示它们的使用,打印结果到控制台。
任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的三个 JavaScript 模块文件。提示词中需明确指定每个文件的导出方式(命名导出、默认导出)、导入语法以及使用示例。
三、Agent 模式下的提示词示例
你是一个资深前端开发 Agent。请创建三个 ES Module 文件,构成一个简单的工具库。需要创建:
src/math.js:导出命名导出add(两数相加并返回)、multiply(两数相乘并返回)和PI(常量 3.14159)。src/string.js:默认导出函数capitalize(str),将传入字符串的首字母大写并返回(若传入空字符串则返回空字符串);同时命名导出reverse(str),返回字符串的反转。src/app.js:使用静态import从math.js导入add和PI;使用默认导入导入capitalize,同时命名导入reverse从string.js。编写逻辑调用这些函数并console.log输出结果。在文件顶部添加一行注释说明这是一个演示模块。- 所有文件使用
const声明变量,添加简要 JSDoc 注释。确保代码语法正确,可直接在 Node.js (ESM) 或浏览器<script type="module">中运行。不要使用 CommonJS 语法。完成后列出三个文件的内容。
四、面试真题与参考答案
题目(美团前端面试题):
请详细解释 ES Modules 和 CommonJS 在模块加载机制上的核心区别,包括:加载时机、导出值的特性、静态分析能力和循环依赖处理。为什么 Tree Shaking 通常在 ES Modules 上实现?
参考答案:
1. 加载时机:
- CommonJS:运行时同步加载。
require()在代码执行时才加载模块,从磁盘/网络读取文件并执行,期间阻塞后续代码。适合服务端,不适合浏览器(同步加载远程资源会冻结页面)。 - ES Modules:编译时静态分析 + 异步加载。模块的依赖关系在执行前就已确定,浏览器可以并行下载所有模块,不阻塞 HTML 解析。
2. 导出值的特性:
- CommonJS:
module.exports导出的是一个对象的引用快照。对于基本类型,导出的是值的副本(后续修改不影响导入方);对于对象,是引用的快照。 - ES Modules:导出的是实时只读绑定。导入方看到的是导出模块中变量的实时值,但不能修改它们。
3. 静态分析能力:
- CommonJS:
require()可以在任何地方、接收动态表达式,导致依赖图在运行时才能确定,打包工具难以做静态分析。 - ES Modules:
import/export必须在顶层,路径必须是字面量,使得打包工具可以在构建时确定依赖图。
4. 循环依赖处理:
- CommonJS:遇到循环依赖时,
require返回当前已执行部分的module.exports(可能不完整),后续属性在完全初始化后才可用。 - ES Modules:通过提升和绑定机制,循环依赖时导入的绑定是可用的,但如果访问尚未初始化的变量会触发 TDZ 错误。
5. Tree Shaking 与 ESM:Tree Shaking 依赖导入/导出关系的静态可分析性。ES Modules 的静态结构允许打包工具精确地知道哪些导出被使用、哪些未被使用,从而安全地移除死代码。CommonJS 的动态性使得这种分析几乎不可能。
课后练习答案
一、概念自测答案
D
- 解析:ES Modules 导入的是实时只读引用,而非值的副本。D 描述的是 CommonJS 行为。
B
- 解析:命名空间导入的对象是只读的,尝试修改会抛出 TypeError。这是 ES Modules 规范的一部分。
**
import()**(动态导入)- 解析:
import()是动态导入操作符,返回 Promise。
- 解析:
A、B
- 解析:A 正确,静态结构使 Tree Shaking 成为可能;B 正确,实时只读引用是 ES Modules 的核心特性;C 错误,
require是 CommonJS 语法;D 不准确,ES Modules 的异步加载主要体现在浏览器环境,但并非所有场景都异步。
- 解析:A 正确,静态结构使 Tree Shaking 成为可能;B 正确,实时只读引用是 ES Modules 的核心特性;C 错误,
二、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。代码简洁,注释清晰。直接输出三个文件的完整内容。”