B05 模块化与 ESM
B05 模块化与 ESM
摘要:ES 模块(ECMAScript Module,ESM)是语言级模块系统:每个文件独立作用域,
export声明出口,import声明依赖,加载与执行顺序由依赖图静态确定。模块是单例,同一路径只执行一次;循环依赖下未初始化的绑定会得到暂时性死区错误。本文给出导入导出写法、单例与循环依赖的实测,以及目录拆分的四条依据。
关键词:ESM,模块,导入导出,单例,循环依赖
所属:frontend/js / B-进阶 | 上一篇:B04-事件循环与任务队列 | 下一篇:B06-错误处理与调试
1 引言
把全部代码写进一个文件后,函数名冲突、加载顺序不明、复用只能复制粘贴。模块化解决三件事:作用域隔离、依赖显式化、按文件复用。
本文回答三个问题:导入导出怎么写、模块为什么只执行一次、循环依赖时为什么会报错。范围见表 1-1。
| 项 | 本文覆盖 | 本文不覆盖 |
|---|---|---|
| 语法 | import/export、重命名、聚合导出 |
动态 import() 的加载策略 |
| 语义 | 单例、静态分析、绑定导出 | 打包与代码分割(C05) |
| 组织 | 目录拆分、循环依赖排查 | 包管理与版本控制 |
2 预备知识
| 项 | 要求 | 核对方式 |
|---|---|---|
| 环境 | Chrome 130+ 与 Node.js 20+ | 两者都能运行 .mjs |
| 前置 | B01 | 理解作用域与闭包 |
| 文件 | js-practice/modules/ 目录 |
编码 UTF-8 |
3 原理与实现
3.1 依赖图与执行顺序
模块系统先构建依赖图,再按深度优先顺序执行,如图 3-1 所示。
flowchart TD
A["main.mjs"] --> B["format.mjs"]
A --> C["store.mjs"]
C --> B
B --> D["constants.mjs"]
D --> E["执行顺序最先"]
B --> F["其次"]
C --> G["再次"]
A --> H["最后执行主体"]
图 3-1 模块按依赖图的深度优先顺序执行,被依赖者先执行
图上可见:
constants.mjs最先执行,main.mjs主体最后执行;同一模块在图中出现多次也只执行一次。
3.2 完整实现
// js-practice/modules/constants.mjs;运行环境:Node.js 20+
export const CURRENCY = 'CNY';
export const TAX_RATE = 0.06;
// js-practice/modules/format.mjs;运行环境:Node.js 20+
import { CURRENCY } from './constants.mjs';
export function money(amount) {
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: CURRENCY }).format(amount);
}
// js-practice/modules/store.mjs;运行环境:Node.js 20+
// 模块是单例:这里的 items 在整个应用内只有一份
const items = [];
export function add(item) {
items.push(item);
return items.length;
}
export function all() {
return [...items]; // 返回副本,避免外部直接改数组
}
export default { add, all }; // 默认导出:整个模块作为一个对象
// js-practice/modules/main.mjs;运行环境:Node.js 20+
import { money } from './format.mjs';
import store, { add } from './store.mjs';
import { TAX_RATE as TAX } from './constants.mjs';
console.log(money(1999 * (1 + TAX))); // ¥2,118.94
add('键盘');
add('鼠标');
console.log(store.all().length); // 2,另一处导入看到同一份数据
导入导出形式见表 3-1。
| 形式 | 写法 | 说明 |
|---|---|---|
| 具名导出 | export function f() {} |
推荐,静态可分析 |
| 具名导入并重命名 | import { f as g } from './m.mjs' |
避免命名冲突 |
| 默认导出 | export default {} |
一个模块只一个,命名由导入方定 |
| 聚合导出 | export * from './m.mjs' |
做统一出口 index.mjs |
| 仅执行副作用 | import './polyfill.mjs' |
用于注册与初始化 |
| 动态导入 | const m = await import('./heavy.mjs') |
按需加载,返回 Promise |
模块边界划分的四条依据见表 3-2。
| 依据 | 做法 | 好处 |
|---|---|---|
| 按变化频率 | 常量、工具、业务分层 | 改常量不牵动业务 |
| 按依赖方向 | 单向依赖,底层不引用上层 | 消除循环依赖 |
| 按职责单一 | 一个文件只导出一组相关能力 | 便于测试与替换 |
| 按体积 | 首屏不需要的模块用动态导入 | 缩短首屏加载 |
4 验证与讨论
- 在
js-practice目录执行node modules/main.mjs,确认输出¥2,118.94与2。 - 在
main.mjs中再导入一次store.mjs,确认模块只执行一次、长度仍为2。 - 构造循环依赖:
a.mjs导入b.mjs,b.mjs顶层读取a.mjs的导出变量,确认报Cannot access before initialization。 - 用
import('./format.mjs')动态导入,确认返回 Promise 且可在点击后按需加载。
| 检查项 | 预期结果 | 说明 |
|---|---|---|
| 单例 | 多次导入共享同一份数据 | 模块缓存按路径复用 |
| 执行顺序 | 被依赖者先执行 | 深度优先 |
| 循环依赖 | 顶层读取未初始化绑定会报错 | 暂时性死区 |
| 默认导出 | 导入名可自定义 | 与具名导出可共存 |
讨论与局限。 浏览器模块路径必须带扩展名且区分大小写,Node.js 虽然宽松但为兼容应统一写 .mjs 或 .js。另,export * 与默认导出不兼容,聚合出口时要显式再导出默认成员。
5 常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
Cannot use import statement outside a module |
文件被当作经典脚本 | 改后缀为 .mjs 或声明 "type":"module" |
Failed to resolve module specifier |
浏览器中漏写相对路径前缀 | 写成 ./x.mjs 或 ../lib/x.mjs |
循环依赖导致某个导出为 undefined |
顶层读取了未初始化的绑定 | 把读取移入函数体内延迟执行 |
| 导入后修改导出变量无效 | 导入绑定是只读的 | 通过导出函数修改内部状态 |
| 改了模块代码页面无变化 | 浏览器缓存了模块 | 强刷,或开发时加版本查询参数 |
6 结论
| 序号 | 结论 | 依据 |
|---|---|---|
| 1 | 模块按路径缓存,全程只执行一次 | 图 3-1 |
| 2 | 导出绑定只读,修改状态要走导出的函数 | 表 3-1 |
| 3 | 依赖保持单向,可消除循环依赖 | 表 3-2 |
| 4 | 浏览器路径必须带扩展名与相对前缀 | 5 节 |
- 上一篇:B04-事件循环与任务队列
- 下一篇:B06-错误处理与调试
- 返回:B00-总结 | 科目 README
参考文献
[1] MDN Web Docs · JavaScript 模块,https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules(访问于 2026-10-31)
[2] ECMA-262 · 16 ECMAScript Language: Scripts and Modules,https://tc39.es/ecma262/#sec-ecmascript-language-scripts-and-modules(访问于 2026-10-31)

评论 0
还没有评论,快来抢沙发吧~