本页目录

模块系统:ESM、CommonJS 与依赖图

理解 Node 如何定位、加载和缓存模块,分清导出绑定与对象引用,并用依赖图解释循环引用和模块副作用。

L2 · 能交付约 15 分钟阅读含示例、练习与验收

建议先读:从终端认识 Node:第一个程序与进程生命周期

本页内容

本章目标与前置#

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

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

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

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

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

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

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

.mjs 明确按 ESM 解释,.cjs 明确按 CommonJS 解释。.js 的解释还受到最近的 package.jsontype 字段等规则影响: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:它把修改计数的入口限制在函数里,把初始化日志留在顶层以观察求值次数。

counter.mjs
console.log('counter 初始化');
export let count = 0;
export function addTask() {
  count += 1;
  return count;
}
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=0after=1same-function=true。初始化日志只有一次。count 能显示更新,是因为 ESM 的导入关联着导出绑定,不是导入时把零复制到一个独立常量里。动态导入得到的命名空间也能观察同一状态,函数身份断言进一步证明这里没有创建第二份模块。

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

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

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

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

legacy-counter.cjs
console.log('legacy 初始化');
let count = 0;
module.exports = {
  increment() { count += 1; return count; },
  read() { return count; }
};
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() 返回副本,调用方修改数组不会改到内部状态。提示:把可变数组放在工厂函数内部,不放在模块顶层。以下答案含模块与完整验证入口。

参考答案:两个文件独立运行验证
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]; }
  };
}
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,预期打印通过信息,退出码零。这里副本只包含字符串,因此浅复制足够;若元素改成嵌套对象,就要重新决定返回只读模型、逐层复制还是冻结数据,不能把本例结论直接扩大到任意结构。

验收与自测#

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

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

本章实际验证范围#

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

官方参考#

加载规则与缓存身份见 ESM 文档CommonJS 文档;文件模式与包边界见 Packages 文档;互操作桥接见 createRequire 文档。以上实验使用稳定 ESM 与 CommonJS 能力,未依赖实验性文本模块或构建工具别名。

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭