从项目重构失败到高效协作 一文讲清TypeScript模块化开发最佳实践如何规范代码结构降低耦合度提升团队开发效率
说实话,我曾经以为”项目重构”这个词只是简历上的一个装饰词,直到我的团队真的踩过那个坑,才明白什么叫”牵一发而动全身”的痛。
三年前,我和几个同事接手了一个中型B端系统。初始版本是纯JavaScript写的,随着业务迭代,代码库已经膨胀到近2000个文件,互相引用得像一团解不开的毛线球。某天产品经理提了一个看起来很简单的需求:在一个订单模块里增加一个”批量导出”功能。
结果呢?改了三个地方,崩了五个地方。调试了整整两天,最后发现是某个工具函数被错误地引入到了不该引入的模块,顺带着改了核心数据层的逻辑。那次重构失败的经历,让我彻底认识到:没有规范的模块化开发,再快的开发速度也是在堆债务。
今天这篇文章,我就想跟你们聊聊TypeScript模块化开发的那些”血泪教训”和最佳实践,希望能帮你们少走一些弯路。
模块化不是”分文件夹”那么简单
很多人对模块化的理解还停留在”把代码拆到不同文件里”这一步。但真实的模块化,是要解决三个核心问题:
- 依赖管理:我知道谁依赖我,也知道我依赖谁
- 边界清晰:每个模块有自己的职责,不能越界
- 可组合性:模块之间可以像乐高一样拼装,而不是用胶水粘死
TypeScript的模块系统(ES Module)天然支持这些,但前提是你得会用。
一个反面的真实例子
我们当时有个utils.ts文件,里面放了超过200个工具函数,从日期格式化到复杂的业务逻辑全塞在一起。然后任意一个页面组件都可以import { something } from '../utils'。
问题出在哪里?
// utils.ts —— 这是我们当时犯的典型错误
export function formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
export function calculateOrderTotal(items: OrderItem[]): number {
// 这个函数明显是订单模块的业务逻辑
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
export function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// 还有190多个函数...
这个文件后来变成了”万能胶水”,任何不知道放哪里的代码都往里塞。结果就是:
- 改一个日期格式化工具,可能会意外影响订单计算逻辑
- 别人引用
utils.ts时,IDE自动补全了200多个选项,根本不知道哪个才是自己需要的 - 文件体积越来越大,编译速度越来越慢
- 新成员入职后,没人敢动这个文件
这就是高耦合的典型症状:一个地方改动,牵动全身。
正确的模块化思路:按领域划分,而不是按文件类型划分
我们重构后的策略是:按业务领域来组织模块,每个领域有自己的”自治权”。
src/
├── core/ # 基础库:类型定义、通用工具
├── features/ # 功能模块(按领域划分)
│ ├── order/ # 订单领域
│ │ ├── types.ts
│ │ ├── service.ts
│ │ ├── hooks.ts
│ │ └── index.ts # 导出接口
│ ├── user/ # 用户领域
│ │ ├── types.ts
│ │ ├── service.ts
│ │ └── index.ts
│ └── dashboard/ # 仪表盘领域
│ ├── types.ts
│ ├── service.ts
│ └── index.ts
├── shared/ # 跨领域共享的组件和工具
└── app.tsx
这个结构的核心思想是:每个功能模块(feature)都是一个独立的小宇宙,它内部有类型、有服务、有业务逻辑,对外只暴露必要的接口(通过index.ts)。
以订单模块为例,看看具体怎么组织
// features/order/types.ts
// 订单领域的类型定义,只暴露给模块内部和外部需要的人
export interface Order {
id: string;
userId: string;
items: OrderItem[];
status: OrderStatus;
createdAt: Date;
updatedAt: Date;
}
export interface OrderItem {
productId: string;
name: string;
price: number;
quantity: number;
}
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
// 导出时统一类型
export type { OrderStatus };
// features/order/service.ts
// 订单服务,封装所有订单相关的业务逻辑
import { Order, OrderItem } from './types';
import { calculateOrderTotal, fetchFromApi } from '@/shared/api';
export class OrderService {
async getOrderById(orderId: string): Promise<Order | null> {
const response = await fetchFromApi<Order>(`/api/orders/${orderId}`);
return response;
}
async createOrder(userId: string, items: OrderItem[]): Promise<Order> {
// 在这里做参数校验
if (items.length === 0) {
throw new Error('订单不能为空');
}
// 计算总额,使用共享工具
const total = calculateOrderTotal(items);
const order: Order = {
id: generateOrderId(),
userId,
items,
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
};
return fetchFromApi<Order>('/api/orders', {
method: 'POST',
body: JSON.stringify(order),
});
}
async cancelOrder(orderId: string): Promise<Order> {
const order = await this.getOrderById(orderId);
if (!order) {
throw new Error('订单不存在');
}
if (order.status === 'delivered') {
throw new Error('已送达的订单无法取消');
}
return fetchFromApi<Order>(`/api/orders/${orderId}/cancel`, {
method: 'POST',
});
}
}
// 导出单例,避免重复创建
export const orderService = new OrderService();
// features/order/index.ts
// 模块的对外接口,这是最重要的文件
export { orderService } from './service';
export type { Order, OrderItem, OrderStatus } from './types';
这样组织的好处是什么?
- 依赖方向清晰:
order模块只依赖shared里的通用工具,不依赖其他feature模块 - 边界明确:
order模块内部的类型和逻辑不会泄露到外面,外部只能通过index.ts访问 - 易于测试:每个模块可以独立测试,不用依赖其他模块的实现
- 新人友好:新成员只需要看
index.ts就知道这个模块暴露了什么
依赖方向:永远不要向后依赖
这是模块化最容易被忽视的一条规则:模块只能依赖它下方的模块,不能依赖它上方的模块。
✅ 正确的依赖方向:
src/app.tsx
→ features/dashboard/
→ features/order/
→ features/user/
→ shared/
→ core/
❌ 错误的依赖方向(循环依赖或反向依赖):
features/order/
→ features/user/ ← 订单模块不应该依赖用户模块
→ features/dashboard/ ← 更不应该
为什么会出问题?因为循环依赖会导致:
- 编译时模块初始化顺序不确定,运行时可能出现
undefined - 代码逻辑难以追踪,A依赖B,B又依赖A,改一个地方不知道会影响谁
- 单元测试极难编写,因为需要Mock大量依赖
如何检测循环依赖
我们项目中后来引入了madge工具来检测循环依赖:
# 安装
npm install --save-dev madge
# 检测循环依赖
npx madge --circular src/
# 生成依赖图(可视化)
npx madge --image dependency-graph.png src/
运行后如果看到这样的输出:
Warning: Dependency cycle detected:
src/features/order/service.ts -> src/features/user/service.ts -> src/features/order/types.ts
那就说明有问题,需要重新梳理模块边界。
导出规范:每个模块只有一个入口
这是提升团队协作效率最有效的一条实践:每个模块只有一个index.ts,所有对外导出都从这里出去。
// features/order/index.ts —— 这是订单模块的唯一对外窗口
// 内部导出(模块内部使用)
export { OrderService } from './service';
// 对外导出(模块外部使用)
export { orderService } from './service';
export type { Order, OrderItem, OrderStatus } from './types';
使用方式:
// 在其他模块中使用订单模块
import { orderService, type Order, type OrderStatus } from '@/features/order';
这样做有几个好处:
- 引用路径简洁:不用记住每个文件的精确路径
- 重构安全:内部文件结构可以随意调整,外部引用不受影响
- IDE友好:自动补全提示更少、更精准
类型导出的最佳实践
在TypeScript中,类型和值的导出是有区别的,理解这一点很重要。
类型导出的两种方式
// types.ts
export interface Order {
id: string;
total: number;
}
// 方式一:直接导出类型
import { Order } from './features/order/types';
// 方式二:在index.ts中重新导出(推荐)
// index.ts
export type { Order } from './types';
// 使用时
import { Order } from './features/order';
推荐第二种方式,因为:
- 调用方不需要知道类型定义在哪个具体文件
- 如果将来类型文件路径变了,只需要改
index.ts,调用方不受影响 - 类型导出时加上
type关键字,让意图更明确
泛型类型的导出
// core/result.ts
export type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
// 使用
import { Result } from '@/core/result';
function fetchOrder(id: string): Promise<Result<Order>> {
// ...
}
泛型类型同样通过index.ts统一导出,保持接口的一致性。
模块内部的代码组织
确定了文件结构后,每个模块内部的代码组织也很关键。我们总结了一个经验法则:一个模块内部的文件顺序应该是”从外到内”。
features/order/
├── index.ts # 最外层:对外接口(最先看)
├── types.ts # 类型定义(次外层)
├── service.ts # 服务层(业务逻辑)
├── hooks.ts # 自定义Hooks(使用层)
├── constants.ts # 常量定义
└── __tests__/ # 测试文件(最内层)
这样组织的好处是:从外到内阅读,先看对外接口,再看类型定义,最后看具体实现。这和人们阅读文档的习惯是一致的。
实战:从一个重构案例看模块化的威力
让我们回到最初的故事。重构之前,我们的订单导出功能是怎么做的?
// 重构前:散落在各个地方的代码
// components/OrderList.tsx
import { formatDate } from '../utils';
import { getOrderTotal } from '../utils'; // 注意:直接从utils导入
import { exportData } from '../utils'; // 又是utils
const handleExport = async () => {
const orders = await fetchOrders();
exportData(orders); // 导出逻辑也混在utils里
};
重构之后:
// features/order/index.ts —— 统一的对外接口
export { orderService } from './service';
export { useOrderList } from './hooks';
export type { Order, OrderItem, OrderStatus } from './types';
export { ORDER_STATUS_LABELS } from './constants';
// features/order/hooks.ts —— 订单相关的Hooks
import { orderService } from './service';
import { useQuery } from '@tanstack/react-query';
export function useOrderList() {
return useQuery({
queryKey: ['orders'],
queryFn: () => orderService.getAllOrders(),
});
}
// 使用方
// components/OrderList.tsx
import { useOrderList, type Order } from '@/features/order';
import { OrderCard } from '@/shared/components/OrderCard';
const OrderList = () => {
const { data: orders, isLoading } = useOrderList();
if (isLoading) return <LoadingSpinner />;
return (
<div>
{orders?.map((order: Order) => (
<OrderCard key={order.id} order={order} />
))}
</div>
);
};
你看,改动非常小,但效果完全不同:
- 导入路径清晰:
@/features/order一目了然 - 类型安全:
Order类型从模块统一导出,不用关心内部定义 - 逻辑内聚:订单相关的Hooks、服务、类型都在一个文件夹里
- 易于扩展:如果要加新的订单功能,只需要在
features/order里添加,不会影响其他模块
模块间通信:事件总线还是依赖注入?
当模块之间需要通信时,常见的做法有几种,每种都有各自的适用场景。
方式一:共享服务(推荐用于紧密耦合的模块)
// core/events.ts —— 跨模块通信的事件总线
type EventHandler = (...args: any[]) => void;
class EventBus {
private listeners = new Map<string, Set<EventHandler>>();
on(event: string, handler: EventHandler) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(handler);
return () => this.off(event, handler);
}
off(event: string, handler: EventHandler) {
this.listeners.get(event)?.delete(handler);
}
emit(event: string, ...args: any[]) {
this.listeners.get(event)?.forEach(handler => handler(...args));
}
}
export const eventBus = new EventBus();
使用示例:
// features/notification/service.ts
import { eventBus } from '@/core/events';
export class NotificationService {
init() {
eventBus.on('order:created', (order) => {
this.sendNotification(order.userId, '新订单已创建');
});
}
}
// features/order/service.ts
import { eventBus } from '@/core/events';
export class OrderService {
async createOrder(...) {
const order = await this.saveOrder(...);
eventBus.emit('order:created', order);
return order;
}
}
方式二:依赖注入(推荐用于需要测试的场景)
// 定义依赖接口
interface IOrderRepository {
save(order: Order): Promise<Order>;
findById(id: string): Promise<Order | null>;
}
// 服务层接受依赖注入
class OrderService {
constructor(private repository: IOrderRepository) {}
async createOrder(userId: string, items: OrderItem[]): Promise<Order> {
const order = this.buildOrder(userId, items);
return this.repository.save(order);
}
}
// 在入口处注入真实实现
const orderService = new OrderService(new OrderRepository());
// 在测试中注入Mock实现
const orderService = new OrderService(mockRepository);
依赖注入的优势在于:
- 测试友好:可以轻松Mock依赖
- 低耦合:服务不直接创建依赖,而是由外部注入
- 可替换:同一个服务可以用不同的依赖实现
配置文件:tsconfig.json的正确姿势
模块化开发离不开TypeScript配置的支持。合理的配置可以让模块系统发挥最大效能。
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInImports": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
// 路径别名,让导入更清晰
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@features/*": ["src/features/*"],
"@shared/*": ["src/shared/*"],
"@core/*": ["src/core/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
关键点说明:
moduleResolution: "bundler":兼容Vite、Webpack等现代构建工具的模块解析strict: true:开启所有严格类型检查,这是类型安全的基石forceConsistentCasingInImports:强制导入路径大小写一致,避免在不同操作系统下出问题- 路径别名:
@/features/order比../../features/order清晰得多,而且重构路径时只需要改一处配置
代码分割:按需加载的模块
模块化不只是代码组织的问题,还关系到性能。合理的路由级代码分割可以让页面加载更快。
// router.tsx —— 路由级别的代码分割
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// 懒加载各个功能模块
const OrderPage = lazy(() => import('@/features/order/pages/OrderPage'));
const UserPage = lazy(() => import('@/features/user/pages/UserPage'));
const DashboardPage = lazy(() => import('@/features/dashboard/pages/DashboardPage'));
const AppRouter = () => {
return (
<BrowserRouter>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/orders" element={<OrderPage />} />
<Route path="/users" element={<UserPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
};
配合Vite的构建配置,每个功能模块会被打包成独立的代码块:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
// 按功能模块分割代码
manualChunks: {
order: ['src/features/order'],
user: ['src/features/user'],
dashboard: ['src/features/dashboard'],
shared: ['src/shared'],
},
},
},
},
});
这样做的结果是:
- 用户访问订单页面时,只加载订单相关的代码
- 用户访问用户管理页面时,订单代码不会被加载
- 团队并行开发时,各自修改不同模块,不会产生合并冲突
测试策略:模块化的另一面红利
模块化带来的另一个好处是测试变得容易了。因为每个模块职责单一,测试时只需要关注这个模块本身。
// features/order/__tests__/service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OrderService } from '../service';
import { orderService } from '../index';
// Mock shared/api
vi.mock('@/shared/api', () => ({
fetchFromApi: vi.fn(),
calculateOrderTotal: vi.fn(),
}));
describe('OrderService', () => {
let service: OrderService;
beforeEach(() => {
service = new OrderService();
vi.clearAllMocks();
});
it('应该创建一个新订单', async () => {
const mockOrder = { id: '1', userId: 'user1', items: [], status: 'pending' as const };
vi.mocked(fetchFromApi).mockResolvedValue(mockOrder);
const result = await service.createOrder('user1', [
{ productId: 'p1', name: '商品A', price: 100, quantity: 2 },
]);
expect(result.status).toBe('pending');
expect(fetchFromApi).toHaveBeenCalledWith('/api/orders', expect.any(Object));
});
it('空订单应该抛出错误', async () => {
await expect(
service.createOrder('user1', [])
).rejects.toThrow('订单不能为空');
});
});
因为模块边界清晰,测试时只需要Mock该模块依赖的外部接口,不用关心其他模块的内部实现。这就是关注点分离带来的测试优势。
团队协作:让每个人都知道该往哪放代码
再好的架构,如果团队成员不知道如何使用,也是白搭。我们团队在推行模块化开发时,做了以下几件事:
1. 编写模块使用指南
每个功能模块的README.md里说明这个模块的职责和对外接口:
# Order Feature
## 职责
订单模块负责所有订单相关的业务逻辑,包括订单创建、查询、取消等。
## 对外接口
- `orderService`:订单服务实例
- `useOrderList`:获取订单列表的Hook
- `Order`、`OrderItem`、`OrderStatus`:相关类型
## 使用示例
\`\`\`typescript
import { orderService, useOrderList } from '@/features/order';
const orders = await orderService.getAllOrders();
\`\`\`
## 注意事项
- 不要直接从内部文件导入,统一从index.ts导入
- 如需新增功能,请在本模块内扩展,不要引入其他feature模块
2. 设置导入规则
使用ESLint的import/no-relative-packages和自定义规则,防止错误的导入方式:
// eslint.config.js
export default [
{
rules: {
// 禁止循环导入
'import/no-cycle': ['error', { maxDepth: 2 }],
// 禁止从内部文件直接导入(必须从index.ts导入)
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/features/*/service'],
message: '请从模块的index.ts导入,而非直接导入内部文件',
},
{
group: ['@/features/*/types'],
message: '请从模块的index.ts导入类型',
},
],
},
],
},
},
];
3. 定期扫描依赖图
在CI流程中加入依赖图扫描,发现新的循环依赖时自动告警:
# .github/workflows/lint.yml
name: Lint & Dependency Check
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm ci
- run: npx madge --circular src/
- run: npm run lint
从失败到成功的转变
回过头来看,那次”简单的批量导出功能”之所以变成灾难,根本原因不是技术能力不足,而是代码结构缺乏规范。
重构后,我们做了三件事:
- 建立了模块边界:每个功能领域有自己的文件夹,内部结构自洽
- 统一了导入规范:所有对外引用通过
index.ts,路径清晰 - 加入了自动化检查:ESLint和madge帮助我们及时发现违规代码
三个月后,当产品经理再次提出新的订单功能时,我们的开发效率提高了至少40%。新同事入职时,只需要看懂模块结构,就能快速上手。
模块化开发不是一蹴而就的,它需要团队达成共识,需要工具来保驾护航,需要持续维护。但一旦形成习惯,它会回报你远超投入的效率提升。
如果你正在为项目结构混乱而头疼,不妨从下一个功能模块开始,尝试按领域组织代码。哪怕只改一个小模块,你也会感受到那种”一切都各归其位”的清爽感。
