嘿,我是 Agnes。今天咱们不聊虚的,直接钻进代码里去。
很多刚上手 Node.js 做大型项目的朋友,经常会遇到一个让人头秃的问题:代码写着写着,文件之间互相引用,最后报出 Cannot access 'xxx' before initialization 或者更隐晦的运行时死锁。甚至有时候为了省事,把几十个文件塞进一个大模块里,导致维护时牵一发而动全身。
模块化开发的核心,不是为了“显得专业”,而是为了把复杂性关进笼子里。在这篇文章里,我会带你从底层逻辑到实战规范,把 TypeScript 在 Node.js 中的模块化开发讲透,特别是那个让无数开发者头疼的“循环依赖”问题。
一、 为什么你的 Node.js 项目会“乱成一锅粥”?
在深入技巧之前,我们先看看“乱”是从哪来的。Node.js 基于 CommonJS(CJS)或 ES Modules(ESM),它们对依赖的处理方式决定了模块间的耦合度。
1. CommonJS 的滞后性陷阱
CommonJS 是动态执行的。当你写 require('./user') 时,它在运行时才去加载。这意味着,如果 A 依赖 B,B 依赖 A,Node.js 在加载 A 时会发现 A 还没加载完,从而拿到一个空对象或者部分初始化的对象。
// 错误示例:循环依赖的典型死局
// user.ts
const Order = require('./order'); // Node.js 在这里会阻塞或拿到空对象
class User {
orders: Order[] = [];
}
module.exports = User;
// order.ts
const User = require('./user'); // 尝试访问 user,但 user 还在加载中
class Order {
owner: User;
}
module.exports = Order;
2. TypeScript 的编译幻觉
TypeScript 在编译时会检查类型,但它不会在运行时重构你的依赖关系。你觉得你用了接口解耦,但如果你不小心在 index.ts 里直接 import * as all,或者在顶层执行了副作用代码,构建出来的 JS 依然可能是循环依赖的。
二、 核心法则:如何优雅地划分模块
好的模块划分不是看谁的文件名起得高大上,而是看单一职责和依赖方向。我们采用一种类似“洋葱架构”的分层策略,但为了保持 Node.js 项目的灵活性,我将其简化为三个核心层级:
- Domain(领域层):纯粹的逻辑,不依赖任何框架或数据库。
- Application(应用层):编排领域逻辑,处理用例。
- Infrastructure/Interface(基础设施/接口层):数据库、HTTP 服务器、第三方服务。
2.1 目录结构实战
假设我们要做一个电商后台的“订单管理”模块,目录结构应该是这样的:
src/
├── domain/
│ ├── order/
│ │ ├── entities/
│ │ │ └── Order.ts # 纯类定义,无外部依赖
│ │ ├── value-objects/
│ │ │ └── OrderId.ts # 值对象,确保订单ID格式正确
│ │ └── interfaces/
│ │ └── IOrderRepository.ts # 接口定义,注意:这里是接口,不是实现!
│ │
│ └── user/
│ └── ...
│
├── application/
│ ├── services/
│ │ └── OrderService.ts # 编排业务逻辑,依赖 domain 的接口
│ └── dto/
│ └── CreateOrderDto.ts
│
├── infrastructure/
│ ├── database/
│ │ ├── postgres/
│ │ │ └── PostgresOrderRepository.ts # 实现 domain 里的 IOrderRepository
│ │ └── connection.ts
│ └── express/
│ └── routes/
│ └── orderRoutes.ts # 依赖 application 层
│
└── main.ts # 唯一知道所有层的地方,负责组装(依赖注入)
为什么这样分?
domain层永远不import任何其他层。它只定义是什么(数据结构和规则)。application层依赖domain层,但不知道数据存在哪里(是 Postgres 还是 MongoDB?它不在乎)。infrastructure层依赖application和domain,负责具体的实现细节。- 依赖方向是单向的:Infrastructure → Application → Domain。绝对不允许反向依赖。
三、 破解循环依赖:三大实战技巧
循环依赖的本质是耦合过高。当我们发现两个模块互相依赖时,说明它们之间有一条“脐带”连得太紧了。以下是三种经过实战检验的解耦技巧。
技巧 1:接口隔离(依赖倒置原则)
这是最有效的一招。既然 A 依赖 B,B 依赖 A 是因为 A 需要 B 的具体实现,B 需要 A 的具体实现,那我们能不能让它们都依赖接口?
场景:UserService 需要调用 PaymentService 来扣费,而 PaymentService 需要调用 UserService 来验证用户是否存在。
错误做法(循环依赖):
// payment.service.ts
import { UserService } from './user.service'; // 循环!
export class PaymentService {
async charge(userId: string) {
const user = UserService.findById(userId); // 运行时可能出错
// ...
}
}
// user.service.ts
import { PaymentService } from './payment.service';
export class UserService {
async purchase(product: string) {
PaymentService.charge(this.id);
}
}
正确做法(引入接口 + 依赖注入):
首先,定义接口在 domain 层:
// domain/interfaces/IPaymentGateway.ts
export interface IPaymentGateway {
charge(userId: string, amount: number): Promise<void>;
}
// domain/interfaces/IUserRepository.ts
export interface IUserRepository {
findById(id: string): Promise<User>;
}
然后,应用层通过构造函数注入,而不是直接 import 类:
// application/services/UserService.ts
import { IUserRepository } from '../../domain/interfaces/IUserRepository';
import { IPaymentGateway } from '../../domain/interfaces/IPaymentGateway';
export class UserService {
// 不依赖具体类,只依赖接口
constructor(
private userRepository: IUserRepository,
private paymentGateway: IPaymentGateway
) {}
async purchase(userId: string) {
const user = await this.userRepository.findById(userId);
if (!user) throw new Error('User not found');
// 调用接口方法,具体实现由注入的对象决定
await this.paymentGateway.charge(userId, 100);
}
}
// infrastructure/payment/StripePaymentService.ts
import { IPaymentGateway } from '../../domain/interfaces/IPaymentGateway';
import { IUserRepository } from '../../domain/interfaces/IUserRepository';
export class StripePaymentService implements IPaymentGateway {
constructor(private userRepository: IUserRepository) {}
async charge(userId: string, amount: number) {
// 检查用户状态
const user = await this.userRepository.findById(userId);
if (!user.isActive) throw new Error('Inactive user');
// 调用 Stripe API...
console.log(`Charged ${amount} to ${user.email}`);
}
}
关键点:现在 UserService 和 StripePaymentService 之间没有直接的文件级循环依赖了。UserService 知道 IPaymentGateway,StripePaymentService 也知道 IUserRepository。它们通过接口握手,而不是通过实现文件握手。
技巧 2:提取共享基础模块(Base Module)
有时候,循环依赖是因为两个模块共享了很多底层工具。比如,OrderModule 和 InvoiceModule 都需要操作数据库和发送日志。
解决方案:创建一个 shared 或 common 模块,将通用的类型、接口、工具函数提取出来。
// shared/types/DbEntity.ts
export interface BaseDbEntity {
id: string;
createdAt: Date;
updatedAt: Date;
}
// shared/utils/logger.ts
export const logger = {
info: (msg: string) => console.log(`[INFO] ${msg}`),
error: (msg: string) => console.error(`[ERROR] ${msg}`)
};
然后,OrderModule 和 InvoiceModule 都只 import 自 shared,它们之间没有直接联系。
技巧 3:使用路由表或事件总线解耦
如果业务逻辑确实复杂,A 需要通知 B,B 需要通知 A,不要直接互相调用方法。使用事件驱动的方式。
// event-bus.ts
export class EventBus {
private listeners: Map<string, Function[]> = new Map();
on(event: string, callback: Function) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(callback);
}
emit(event: string, data: any) {
const callbacks = this.listeners.get(event) || [];
callbacks.forEach(cb => cb(data));
}
}
// 全局单例或注入实例
export const eventBus = new EventBus();
// order.service.ts
import { eventBus } from '../shared/event-bus';
export class OrderService {
async createOrder(orderData) {
// 保存订单...
eventBus.emit('ORDER_CREATED', { orderId: orderData.id });
}
}
// notification.service.ts
import { eventBus } from '../shared/event-bus';
export class NotificationService {
constructor() {
// 订阅事件,而不是直接调用 OrderService
eventBus.on('ORDER_CREATED', (data) => {
this.sendEmail(data.orderId);
});
}
}
这样,OrderService 和 NotificationService 之间没有任何直接的 import 关系,彻底消除了循环依赖的风险。
四、 提升代码复用率:可组合的服务设计
复用率高不代表代码多,而是意味着低内聚。一个模块只做好一件事,并且这件事可以被轻易地组合进其他模块。
4.1 避免“上帝类”
很多项目里有一个 AppService.ts,里面集成了数据库连接、邮件发送、缓存、日志等等。这种类是复用的毒药。
重构前:
// bad-example.ts
export class AppService {
db: Database;
cache: Redis;
mailer: SMTP;
constructor() {
this.db = new Database();
this.cache = new Redis();
this.mailer = new SMTP();
}
async createUser() {
// 这里塞了所有逻辑
}
}
重构后: 将每个关注点拆分为独立的 Service 或 Helper,并通过接口组合。
// Good Example: 组合而非继承
export class CreateUserHandler {
constructor(
private userRepo: IUserRepository,
private passwordHasher: IPasswordHasher,
private emailSender: IEmailSender
) {}
async execute(input: CreateUserInput) {
const hashedPassword = await this.passwordHasher.hash(input.password);
const user = await this.userRepo.create({ ...input, password: hashedPassword });
await this.emailSender.sendWelcome(user.email);
return user;
}
}
4.2 使用泛型和接口定义可复用逻辑
在 TypeScript 中,利用泛型可以创建极其通用的数据访问层。
// domain/repositories/IBaseRepository.ts
export interface IBaseRepository<T extends { id: string }> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
create(entity: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T>;
update(id: string, data: Partial<T>): Promise<T | null>;
delete(id: string): Promise<boolean>;
}
然后,UserRepository 和 OrderRepository 都实现这个接口。上层服务可以编写通用的逻辑,比如“批量导入”功能,它可以接受任何 IBaseRepository 的实现。
// application/services/BatchImportService.ts
export class BatchImportService {
constructor(private repo: IBaseRepository<any>) {} // 实际项目中应使用更具体的泛型约束
async import(data: any[]) {
const results = await Promise.all(
data.map(item => this.repo.create(item))
);
return results;
}
}
五、 工程化保障:如何用工具防止循环依赖
手写代码难免出错,我们需要工具来帮我们“抓现行”。在 Node.js + TypeScript 项目中,有两个利器:
5.1 ESLint + eslint-plugin-import
这是最基础的防线。配置 eslint-plugin-import 的 no-cycle 规则。
// .eslintrc.json
{
"plugins": ["import"],
"rules": {
"import/no-cycle": ["error", { "maxDepth": 5, "ignoreExternal": true }]
}
}
这样,当你的代码中出现循环依赖时,ESLint 会直接报错,阻止你提交代码。
5.2 depcruise(依赖图可视化)
这是一个专门用来分析循环依赖的库。它可以生成依赖关系图,让你直观地看到哪里出现了环。
npx depcruise src --include-only "^src" --output-format dot --no-strict
输出会生成一个 .dot 文件,可以用 Graphviz 渲染成图片。如果你看到箭头又指回了起点,那就是循环依赖了。
六、 一个完整的实战示例:订单系统
让我们把上面的理论串联起来,看一个小型的、完整的 TypeScript Node.js 模块划分示例。
1. Domain 层(纯逻辑)
// src/domain/order/Order.ts
export class Order {
constructor(
public readonly id: string,
public readonly userId: string,
public readonly total: number,
public readonly status: 'pending' | 'paid' | 'shipped'
) {}
markAsPaid() {
if (this.status !== 'pending') {
throw new Error('Only pending orders can be paid');
}
this.status = 'paid';
}
}
// src/domain/order/IOrderRepository.ts
import { Order } from './Order';
export interface IOrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
2. Application 层(编排逻辑)
// src/application/order/CreateOrderCommand.ts
import { Order } from '../../domain/order/Order';
import { IOrderRepository } from '../../domain/order/IOrderRepository';
export class CreateOrderCommand {
constructor(private orderRepo: IOrderRepository) {}
async execute(userId: string, items: Array<{ price: number }>): Promise<Order> {
const total = items.reduce((sum, item) => sum + item.price, 0);
const order = new Order(
this.generateId(),
userId,
total,
'pending'
);
await this.orderRepo.save(order);
return order;
}
private generateId(): string {
return Math.random().toString(36).substring(2);
}
}
// src/application/order/PayOrderCommand.ts
import { IOrderRepository } from '../../domain/order/IOrderRepository';
export class PayOrderCommand {
constructor(private orderRepo: IOrderRepository) {}
async execute(orderId: string) {
const order = await this.orderRepo.findById(orderId);
if (!order) throw new Error('Order not found');
order.markAsPaid();
await this.orderRepo.save(order);
}
}
3. Infrastructure 层(具体实现)
// src/infrastructure/order/MemoryOrderRepository.ts
import { Order } from '../../domain/order/Order';
import { IOrderRepository } from '../../domain/order/IOrderRepository';
// 使用内存存储模拟数据库,便于测试
export class MemoryOrderRepository implements IOrderRepository {
private orders: Map<string, Order> = new Map();
async save(order: Order): Promise<void> {
this.orders.set(order.id, order);
}
async findById(id: string): Promise<Order | null> {
return this.orders.get(id) || null;
}
}
4. Main 层(组装)
// src/main.ts
import { MemoryOrderRepository } from './infrastructure/order/MemoryOrderRepository';
import { CreateOrderCommand } from './application/order/CreateOrderCommand';
import { PayOrderCommand } from './application/order/PayOrderCommand';
const repository = new MemoryOrderRepository();
const createOrderCommand = new CreateOrderCommand(repository);
const payOrderCommand = new PayOrderCommand(repository);
async function main() {
const order = await createOrderCommand.execute('user-1', [{ price: 100 }]);
console.log('Created:', order);
await payOrderCommand.execute(order.id);
console.log('Paid!');
}
main();
分析这个例子:
CreateOrderCommand依赖IOrderRepository(接口),而不是MemoryOrderRepository(实现)。MemoryOrderRepository实现IOrderRepository,但它不依赖任何 Application 层的类。main.ts是唯一知道MemoryOrderRepository存在的地方,负责将它们“焊接”在一起。- 零循环依赖:依赖方向清晰,从上到下。
