# 模块系统：ESM、CommonJS 与依赖图

## 本章目标与前置

前置是能够运行 `.mjs` 文件、区分工作目录与模块位置。你熟悉 Vue 单文件组件中的 `import`，但以前很多加载细节由构建工具处理：扩展名可以不写，路径别名由配置解释，热更新会替换模块。现在直接运行 Node，需要先区分语言语法、Node 加载规则和构建工具增强。目标是能读懂一个小型后端项目的依赖图，预测模块状态的共享范围，并找到“路径存在却加载失败”的具体原因。

团队助手会逐步拆成参数处理、任务规则和存储模块。拆文件的目的不是让每个文件尽量短，而是让变化原因不同的逻辑拥有清楚的边界。例如“如何识别合法任务标题”与“文件保存到哪里”不应该因为写在同一个模块顶层而永远绑定。模块系统可以隐藏实现，却不会自动形成良好的边界；这一章同时解释它能保证什么，以及它不负责什么。

## 导入不是把文件文本粘贴进来

把导入理解成复制代码，会在缓存、循环依赖和状态共享上产生误解。更合适的模型是依赖图：入口是一个节点，每条导入语句连接另一个节点。加载器先解释模块身份和依赖关系，再按照相应模块系统的规则建立导出与执行模块代码。图中两条边如果指向同一个模块身份，通常共享该模块的一次求值结果。

因此模块顶层的代码并不是“每次调用导入语句时重新运行”。顶层创建的 Map、连接池或计数器可能活到进程结束。这对连接复用很方便，也意味着一个请求不能把当前用户写到模块变量里，再期望下一个请求看不到。模块作用域比全局作用域更隐蔽，但它仍然可以形成进程内的共享可变状态。

ESM 使用 `import` 与 `export`；CommonJS 使用 `require()` 与 `module.exports`。它们不是不同版本的 JavaScript 函数写法，而是两套加载与导出协议。现代 Node 同时支持它们，是为了兼容大量既有生态。新教材统一使用 ESM，遇到旧包时再明确桥接，比在一个项目里靠猜测混用更容易维护。

## 文件名与包配置如何决定解释方式

`.mjs` 明确按 ESM 解释，`.cjs` 明确按 CommonJS 解释。`.js` 的解释还受到最近的 `package.json` 中 `type` 字段等规则影响：`type: module` 明确选择 ESM，`type: commonjs` 明确选择 CommonJS。较新 Node 对模糊输入还有语法检测等兼容行为，但教材不依赖它；为项目显式设置类型，比根据一次运行成功反推模式更可靠。

`package.json` 的作用范围沿目录结构确定。一个目录中的配置会影响其下没有更近包边界的文件，而不是作用于整个硬盘。迁移老项目时直接在根目录添加 `type: module`，可能同时改变配置文件和测试工具脚本的解释方式。需要保持 CommonJS 的文件可以使用 `.cjs` 明示，而不能假定“只是业务源码切了模式”。

ESM 的相对导入需要完整文件扩展名，例如 `./counter.mjs`。Node 不会因为你在 Vue 工程里省略过 `.vue` 或 `.ts`，就自动搜索这里的候选文件。`@/services/task` 也不是语言内置别名；直接运行时若没有对应机制，加载器不知道它指向哪里。Node 原生的包名解析、包内 imports 映射与 Vite 别名是不同层次的问题。

## 解析、导入与导出的 API 契约

静态 `import { count } from './counter.mjs'` 在模块结构建立时声明依赖，导入的名称是只读绑定，调用方不能给它重新赋值。只读的是绑定，不代表绑定所指对象被冻结。导出一个 Map 后，调用方依然可能修改它。隐藏内部 Map、导出操作函数，才是控制修改入口的方式。

动态 `import(specifier)` 返回 Promise，兑现值是模块命名空间对象；加载、解析或求值失败时 Promise 拒绝。它适合按需加载可选功能，不是每次调用都创建新模块实例的工厂函数。相同解析身份的多次导入通常复用同一模块。把用户输入直接变成任意模块路径也不是插件隔离方案，应先限制到明确的允许列表。

`createRequire(import.meta.url)` 来自 `node:module`，接收文件 URL、URL 对象或绝对文件路径，返回一个以该位置为解析基准的 `require` 函数。它适合在 ESM 文件中加载明确的 CommonJS 资源。普通 `require(specifier)` 是同步操作，返回 `module.exports`，解析失败会同步抛错。不要把同步 API 放进一个 `await` 就误认为它不再阻塞。

现代 Node 支持在符合条件时通过 `require` 加载同步 ESM，但存在版本与模块图限制，含顶层 `await` 的模块图不能被当作普通同步模块加载。本教材在 Node 22.22 基线上仍优先使用静态或动态 `import` 加载 ESM，以避免把互操作限制带进基础业务。具体兼容问题应对照所用 Node 版本的模块文档，而不是根据旧博客的一句话断定永远不支持或永远支持。

## 实验一：导出绑定会更新，模块不会重复初始化

环境 Node 22.22.0，无第三方依赖。把下面两个文件保存到同一个新目录。先读 `counter.mjs`：它把修改计数的入口限制在函数里，把初始化日志留在顶层以观察求值次数。

```js counter.mjs
console.log('counter 初始化');
export let count = 0;
export function addTask() {
  count += 1;
  return count;
}
```

```js module-main.mjs
import assert from 'node:assert/strict';
import { count, addTask } from './counter.mjs';

console.log(`before=${count}`);
addTask();
console.log(`after=${count}`);
// 动态导入仍然指向同一个模块实例。
const again = await import('./counter.mjs');
assert.equal(again.count, 1);
assert.equal(again.addTask, addTask);
console.log(`same-function=${again.addTask === addTask}`);
```

```powershell
node module-main.mjs
```

预期输出依次为 `counter 初始化`、`before=0`、`after=1`、`same-function=true`。初始化日志只有一次。`count` 能显示更新，是因为 ESM 的导入关联着导出绑定，不是导入时把零复制到一个独立常量里。动态导入得到的命名空间也能观察同一状态，函数身份断言进一步证明这里没有创建第二份模块。

再运行一次命令，计数重新从零开始，因为这是新的进程。模块缓存的共享范围不是跨所有 Node 程序的机器级全局缓存。团队助手若有两个服务进程，两个模块内存计数器不会自动一致。真正需要跨实例共享的任务状态，应放到后面学习的数据库等外部系统中。

这里没有把导出的 `count` 设计成生产计数接口。它只用来观察绑定机制。业务模块往往更适合导出 `getCount()` 和明确的修改函数，让调用者不依赖内部变量布局；测试时也可以通过工厂创建独立状态。理解共享行为后再选择接口，比机械地把所有变量都 export 更稳妥。

## 实验二：CommonJS 导出对象与缓存

以下两个文件也放在同一目录，文件名与第一组不同。CommonJS 中 `module.exports` 是真正对外返回的值，`exports` 初始时只是指向它的方便引用。给 `exports` 重新赋值并不会自动替换 `module.exports`；本例统一使用后者，避免把别名误当成特殊导出语法。

```js legacy-counter.cjs
console.log('legacy 初始化');
let count = 0;
module.exports = {
  increment() { count += 1; return count; },
  read() { return count; }
};
```

```js legacy-main.mjs
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const first = require('./legacy-counter.cjs');
const second = require('./legacy-counter.cjs');
assert.equal(first, second);
first.increment();
assert.equal(second.read(), 1);
console.log(`shared=${second.read()}`);
```

执行 `node legacy-main.mjs`，预期只出现一次 `legacy 初始化`，随后 `shared=1`。第一行建立一个基于当前模块位置的加载函数；两次 require 解析到同一个文件；缓存使两次返回相同导出对象；对象方法闭包访问同一个内部变量。这里同步加载一个小模块没有问题，但模块顶层若执行昂贵计算，也会直接延迟入口启动。

CommonJS 的缓存通常按解析后的文件名组织，而 ESM 采用基于 URL 的模块身份。ESM 中不同查询参数可能导致不同实例，因此不要用随意追加时间戳的导入字符串模拟正常热更新。大小写、符号链接、不同包副本与不同解析位置，也可能使你以为相同的依赖实际变成不同身份。排查共享异常时，应记录解析结果，而不只是比较源码里看起来相同的包名。

## 循环依赖为什么不是简单的执行顺序问题

假设任务模块导入存储模块，存储模块又导入任务模块的默认配置，就形成环。CommonJS 为避免无限加载，会在模块尚未执行完时向环的另一侧提供当时的导出对象。因此对方可能读到未填完的属性。若后面用新对象替换 `module.exports`，之前拿到旧对象的一方还可能继续持有旧引用。增加一行日志往往只暴露时机，不能修复依赖关系。

ESM 会先建立模块间的绑定关系，但某些绑定在其声明初始化之前仍处于不能读取的状态。若循环中的一个模块在顶层立即读取另一个尚未初始化的导出，就可能抛出 `ReferenceError`。这不意味着 ESM 完全不允许循环；把读取延后到整个图完成求值之后，有时可以运行。然而“这次没有报错”不等于依赖结构清晰，也不等于未来添加一个顶层读取不会破坏它。

实际修复通常来自职责调整。把双方都需要的纯常量放到第三个无反向依赖的模块，或者让高层入口创建对象后通过参数注入依赖。任务规则可以接收一个 `save` 函数，而不在顶层导入包含服务启动的入口文件。这样图的方向清楚，测试也不必真的启动服务器。不要用延时器去延后导入，掩盖由模块设计造成的问题。

## 顶层副作用、初始化与关闭

导入模块就自动监听端口，会让所有依赖该模块的地方意外启动服务；导入就连接数据库，会让简单的格式化函数测试也需要数据库。可以把“定义能力”和“启动能力”分开：模块导出工厂或启动函数，入口在读取配置后调用它，并保存对应的关闭函数。这样失败发生在哪一步、资源由谁释放，都能明确描述。

这与 Vue 的组合式函数有相似之处：调用函数时创建状态，比导入模块时无条件创建全局状态更容易控制实例数量。但组件卸载不会自动销毁 Node 模块里的定时器和连接，因为服务端没有那个组件生命周期。模块缓存带来的长期引用还会影响内存回收，后面的性能章节会具体观察。

加载失败时先分类。`ERR_MODULE_NOT_FOUND` 可能表示路径、扩展名或依赖安装问题；语法错误说明文件找到了但无法按当前模式解析；模块顶层抛错说明加载过程中执行的业务失败。不要把这三类问题都归结为“缓存脏了”。清空依赖目录会增加变量，常常无法解释真正原因。

## 从前端构建迁移时最容易误判的三件事

第一件事是把构建后的能力当作源码天然拥有的能力。Vue 项目里导入图片可能得到资源 URL，导入样式可能产生页面副作用，导入 TypeScript 可能先经过转换。这些操作依赖构建链定义的约定。直接运行 Node 时，应该先问加载器支持什么文件类型，再问是否需要一个构建步骤。服务器并不因为没有界面就不需要构建，但也不应为了运行普通 JavaScript 无条件加入一层执行器。

第二件事是以为开发热更新等同于普通模块缓存行为。热更新系统需要知道旧模块如何释放资源、状态是否迁移，以及依赖方是否重新求值。直接删除某个 CommonJS 缓存条目，不会撤销该模块已经注册的监听器，也不会把所有持有旧对象的模块自动指向新对象。服务重启时旧端口未释放、定时器越积越多，往往是资源生命周期问题，不只是缓存删除得不够彻底。

第三件事是把动态加载当作错误隔离。动态 import 的拒绝可以被捕获，但被加载模块仍然和主程序共享进程权限与内存空间。它能够修改共享对象、安排定时器或消耗 CPU。若团队助手允许用户提交扩展，必须另外设计权限、进程隔离和资源限制，不能因为扩展是动态导入就认为它处在沙箱里。

当模块需要配置时，优先把配置作为工厂参数传入，而不是在多个文件顶层各读一遍环境变量。这样一次测试可以创建两个不同配置的实例，不需要修改进程全局环境，也不会因为模块先被别处导入而缓存了旧值。配置读取属于启动阶段，业务模块负责使用已经验证的值，这条边界会贯穿后面的完整服务。

还可以用“能否只导入而不启动任何外部资源”检查模块职责。纯规则模块应当可以；数据库适配器可能定义连接工厂，但实际连接应由明确的启动流程触发。这个检查不是绝对禁止顶层初始化，而是要求每个副作用都有可解释的必要性与拥有者。真正有意共享的连接池，应同时具有明确的关闭路径，而不是只在模块里创建后就被遗忘。

## 练习：把共享单例改成可创建的任务仓库

实现一个 `createTasks()` 工厂。每次调用得到独立仓库；`add(title)` 只接受去空白后非空的字符串，保存规范化标题并返回数量；`list()` 返回副本，调用方修改数组不会改到内部状态。提示：把可变数组放在工厂函数内部，不放在模块顶层。以下答案含模块与完整验证入口。

<details><summary>参考答案：两个文件独立运行验证</summary>

```js task-store.mjs
export function createTasks() {
  const titles = [];
  return {
    add(title) {
      if (typeof title !== 'string' || title.trim() === '') {
        throw new TypeError('标题必须是非空字符串');
      }
      titles.push(title.trim());
      return titles.length;
    },
    list() { return [...titles]; }
  };
}
```

```js task-store-main.mjs
import assert from 'node:assert/strict';
import { createTasks } from './task-store.mjs';

const alpha = createTasks();
const beta = createTasks();
assert.equal(alpha.add('  阅读文档  '), 1);
assert.deepEqual(beta.list(), []);
const snapshot = alpha.list();
snapshot.push('外部修改');
assert.deepEqual(alpha.list(), ['阅读文档']);
assert.throws(() => alpha.add(' '), TypeError);
console.log('独立实例、数据副本与输入校验通过');
```

执行 `node task-store-main.mjs`，预期打印通过信息，退出码零。这里副本只包含字符串，因此浅复制足够；若元素改成嵌套对象，就要重新决定返回只读模型、逐层复制还是冻结数据，不能把本例结论直接扩大到任意结构。

</details>

## 验收与自测

验收要求：两个缓存实验的初始化日志都只出现一次；第二次启动命令不会继承上次内存；工厂实验能证明实例隔离与列表副本；删除相对导入中的扩展名后，你能根据错误解释失败层次，再恢复文件。只在自己的练习副本进行这个修改。

自测一：ESM 导入绑定只读，是否意味着导出的对象不可修改？答案：不意味着，对象内部是否可变是另一层约束。自测二：为什么两个相同包名的 require 可能拿到不同实例？答案：它们可能从不同位置解析到不同文件或不同包副本。自测三：循环依赖为什么可能在新增一行顶层读取后失败？答案：新增读取可能访问尚未完成初始化的绑定或不完整导出，暴露依赖图中原本隐藏的时序条件。

## 本章实际验证范围

Node 22.22.0 下实际执行三个验证入口，连同它们导入的三个模块全部通过语法检查。ESM 与 CommonJS 初始化均只出现一次，独立仓库和副本断言通过。

## 官方参考

加载规则与缓存身份见 [ESM 文档](https://nodejs.org/docs/latest-v24.x/api/esm.html) 和 [CommonJS 文档](https://nodejs.org/docs/latest-v24.x/api/modules.html)；文件模式与包边界见 [Packages 文档](https://nodejs.org/docs/latest-v24.x/api/packages.html)；互操作桥接见 [createRequire 文档](https://nodejs.org/docs/latest-v24.x/api/module.html#modulecreaterequirefilename)。以上实验使用稳定 ESM 与 CommonJS 能力，未依赖实验性文本模块或构建工具别名。
