B05 模块化与 ESM

2026-10-31 17:150 阅读约 11 分钟JavaScript

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 验证与讨论

  1. 在 js-practice 目录执行 node modules/main.mjs,确认输出 ¥2,118.94 与 2。
  2. 在 main.mjs 中再导入一次 store.mjs,确认模块只执行一次、长度仍为 2。
  3. 构造循环依赖:a.mjs 导入 b.mjs,b.mjs 顶层读取 a.mjs 的导出变量,确认报 Cannot access before initialization。
  4. 用 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 节

参考文献

[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

0/2000

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