2. 模块化架构设计
在 Node.js 服务端工程化中,模块化架构设计决定了代码的可维护性、可测试性与演进速度。很多团队在项目初期只关注“能不能跑”,等到业务膨胀、依赖关系变复杂时,才发现模块边界模糊、循环依赖频发、公共出口混乱。本章围绕三个核心问题展开:CommonJS 与 ESM 的差异与互操作策略、基于领域边界拆分模块、设计清晰的依赖方向与公共出口,并最终避免循环依赖。
一、CommonJS 与 ESM 的本质差异
Node.js 最早采用 CommonJS(CJS),通过 require() 同步加载模块,module.exports 导出内容。ESM(ECMAScript Modules)则是语言标准,使用 import / export,加载过程是静态可分析的。两者最关键的差异不在语法,而在“加载时机”和“绑定方式”。
CJS 的 require 是运行时执行,模块被加载时立即执行整个文件,导出对象是值的拷贝(对基本类型)或引用(对对象)。ESM 的 import 是编译期静态解析,导出是“活绑定”,即导入方看到的是导出方变量的实时值。这意味着 ESM 更适合做 tree-shaking,也更容易在构建阶段发现依赖问题。
在 Node.js 中启用 ESM 有两种方式:文件扩展名为 .mjs,或在 package.json 中设置 "type": "module"。若设置为 module,则 .js 文件按 ESM 解析,CJS 文件需改为 .cjs。这一点在混合项目中必须明确,否则会出现“同一目录下两种模块系统互相不识别”的问题。
二、互操作策略:谁调用谁,怎么调用
互操作的核心原则是:ESM 可以导入 CJS,但 CJS 不能同步导入 ESM。具体来说:
- ESM 中导入 CJS:
import pkg from 'cjs-pkg',默认导入拿到的是module.exports整体;命名导入是否可用取决于 CJS 是否被静态分析识别。 - CJS 中导入 ESM:只能使用动态
import(),返回 Promise,不能使用require()。 - 若 CJS 模块使用
exports.foo = ...,ESM 的命名导入可能失败,建议统一用默认导入再解构。
实际工程中,推荐策略是:新模块优先使用 ESM;存量 CJS 模块保持不动,通过默认导入方式被 ESM 引用;需要被 CJS 调用的 ESM 模块,封装一层 CJS 适配文件,内部用动态 import() 暴露异步接口。这样既避免大规模重写,也保证依赖方向清晰。
三、基于领域边界拆分模块
模块拆分不是按文件类型拆,而是按业务能力拆。推荐以“领域边界”为单位,每个领域模块内部包含实体、值对象、领域服务、仓储接口,对外只暴露必要的用例。例如订单领域可以拆为 order/domain、order/application、order/infra,其中 domain 不依赖任何外部框架,application 依赖 domain,infra 实现 domain 定义的接口。
判断边界是否合理,可以用一个简单标准:如果两个模块之间需要频繁互相调用,说明它们可能属于同一个领域;如果调用是单向且稳定的,说明边界清晰。领域模块之间禁止直接引用对方的内部文件,只能通过公共出口交互。
四、依赖方向与公共出口设计
依赖方向应遵循“外层依赖内层,内层不知道外层”。具体来说:基础设施层依赖应用层,应用层依赖领域层,领域层不依赖任何其他层。这样领域逻辑可以独立测试,替换数据库或 HTTP 框架时不影响业务规则。
公共出口的设计原则是:每个模块只暴露一个 index.js(或 index.mjs),内部文件不对外可见。外部引用时统一写 import { createOrder } from '@/order',而不是 import { createOrder } from '@/order/application/create-order'。这样做的好处是:重构内部结构时不影响调用方,也便于统一控制导出内容。
// order/index.js —— 公共出口
export { createOrder } from './application/create-order.js';
export { OrderRepository } from './domain/order-repository.js';
// 不导出内部实现细节
五、避免循环依赖
循环依赖的典型表现是:A 模块导入 B,B 模块又导入 A,导致运行时某一方拿到 undefined 或部分初始化的对象。在 CJS 中,循环依赖往往表现为“部分导出”,在 ESM 中则可能直接抛出引用错误。
排查循环依赖可以用 madge 工具:npx madge --circular src/,它会列出所有循环链路。解决思路有三种:
- 提取公共依赖:把双方共同依赖的部分抽到第三个模块,让 A 和 B 都依赖 C,而不是互相依赖。
- 依赖倒置:在领域层定义接口,基础设施层实现接口,应用层通过接口调用,避免直接引用具体实现。
- 事件解耦:对于跨领域的通知类逻辑,使用事件总线或消息队列,把同步调用改为异步事件,切断直接依赖。
madge --circular 检查,一旦发现循环链路立即阻断合并。
六、落地检查清单
| 检查项 | 判断标准 | 工具/方式 |
|---|---|---|
| 模块系统一致性 | 同一包内不混用 CJS/ESM,或明确标注扩展名 | package.json type 字段 |
| 依赖方向 | 领域层不依赖应用层和基础设施层 | 目录结构 + import 检查 |
| 公共出口 | 外部只通过 index 文件引用模块 | ESLint no-restricted-imports |
| 循环依赖 | 无循环链路 | madge --circular |
| 互操作 | ESM 默认导入 CJS,CJS 动态导入 ESM | 代码评审 + 运行时验证 |
模块化架构不是一次性设计,而是随着业务演进持续调整的过程。建议每新增一个领域模块,都先明确它的依赖方向和公共出口,再写第一行业务代码。把循环依赖检查纳入 CI,把公共出口作为团队约定,才能真正让模块化架构在工程中落地。