Clean Architecture在前端项目中的实践踩坑记录
先看看实际效果
之前项目里用了Clean Architecture,说实话刚开始觉得挺复杂,各种层啊接口啊绕来绕去的,后来慢慢发现确实有用。特别是当项目越来越复杂的时候,这种架构能帮你理清楚代码关系。
这里先给个完整的目录结构,你们看了就知道长啥样:
src/
├── application/ # 应用层
│ ├── use-cases/ # 用例层
│ └── dtos/ # 数据传输对象
├── domain/ # 领域层
│ ├── entities/ # 实体
│ ├── repositories/ # 仓储接口
│ └── services/ # 领域服务
├── infrastructure/ # 基础设施层
│ ├── repositories/ # 仓储实现
│ └── services/ # 外部服务
└── presentation/ # 表示层(UI层)
核心代码就这几行
先来看个简单的用户登录用例,这是应用层的核心代码:
// src/application/use-cases/LoginUser.js
class LoginUser {
constructor(userRepository, authService) {
this.userRepository = userRepository;
this.authService = authService;
}
async execute(email, password) {
// 验证用户是否存在
const user = await this.userRepository.findByEmail(email);
if (!user) {
throw new Error('User not found');
}
// 验证密码
const isValidPassword = await this.authService.verifyPassword(
password,
user.passwordHash
);
if (!isValidPassword) {
throw new Error('Invalid password');
}
// 生成token
const token = await this.authService.generateToken(user.id);
return {
user: {
id: user.id,
email: user.email,
name: user.name
},
token
};
}
}
export default LoginUser;
然后是领域层的实体,这个比较重要:
// src/domain/entities/User.js
class User {
constructor(id, email, name, passwordHash, createdAt) {
this.id = id;
this.email = email;
this.name = name;
this.passwordHash = passwordHash;
this.createdAt = createdAt;
}
static create(email, name, passwordHash) {
if (!email || !name || !passwordHash) {
throw new Error('Missing required fields');
}
if (!this.isValidEmail(email)) {
throw new Error('Invalid email format');
}
return new User(
crypto.randomUUID(),
email.toLowerCase(),
name.trim(),
passwordHash,
new Date()
);
}
updateName(newName) {
if (!newName || newName.trim().length === 0) {
throw new Error('Name cannot be empty');
}
this.name = newName.trim();
this.updatedAt = new Date();
}
static isValidEmail(email) {
const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
return emailRegex.test(email);
}
}
export default User;
仓储接口设计要点
仓储接口的设计真的很重要,这里我踩过不少坑。一开始我把所有的数据库操作都放在一个接口里,结果发现越来越臃肿。后来改成按实体分,每个实体一个仓储接口:
// src/domain/repositories/UserRepository.js
class UserRepository {
async save(user) {
throw new Error('Method not implemented');
}
async findById(id) {
throw new Error('Method not implemented');
}
async findByEmail(email) {
throw new Error('Method not implemented');
}
async findAll() {
throw new Error('Method not implemented');
}
}
export default UserRepository;
具体的实现放在基础设施层:
// src/infrastructure/repositories/UserRepositoryImpl.js
import UserRepository from '../../domain/repositories/UserRepository';
import { PrismaClient } from '@prisma/client';
class UserRepositoryImpl extends UserRepository {
constructor() {
super();
this.prisma = new PrismaClient();
}
async save(user) {
return await this.prisma.user.create({
data: {
id: user.id,
email: user.email,
name: user.name,
passwordHash: user.passwordHash,
createdAt: user.createdAt
}
});
}
async findById(id) {
const userData = await this.prisma.user.findUnique({
where: { id }
});
if (!userData) return null;
// 转换为领域实体
return new User(
userData.id,
userData.email,
userData.name,
userData.passwordHash,
userData.createdAt
);
}
async findByEmail(email) {
const userData = await this.prisma.user.findFirst({
where: { email }
});
if (!userData) return null;
return new User(
userData.id,
userData.email,
userData.name,
userData.passwordHash,
userData.createdAt
);
}
}
export default UserRepositoryImpl;
依赖注入的坑点
这里要特别注意,我在依赖注入这块折腾了好久。开始没用专门的容器,手动管理依赖,后来项目一复杂就乱套了。强烈建议用个轻量级的DI容器:
// src/infrastructure/container.js
import LoginUser from '../application/use-cases/LoginUser';
import UserRepositoryImpl from './repositories/UserRepositoryImpl';
import AuthService from './services/AuthService';
class Container {
constructor() {
this.services = new Map();
this.registerServices();
}
registerServices() {
// 注册仓储实现
this.services.set('userRepository', new UserRepositoryImpl());
this.services.set('authService', new AuthService());
// 注册用例
this.services.set('loginUser', () =>
new LoginUser(
this.get('userRepository'),
this.get('authService')
)
);
}
get(serviceName) {
const service = this.services.get(serviceName);
if (typeof service === 'function') {
return service();
}
return service;
}
}
export default Container;
测试友好是最大优势
Clean Architecture最大的好处就是测试友好。因为各层之间的依赖都是通过接口抽象的,所以单元测试很好写:
// tests/unit/use-cases/LoginUser.test.js
describe('LoginUser Use Case', () => {
let loginUser;
let mockUserRepository;
let mockAuthService;
beforeEach(() => {
mockUserRepository = {
findByEmail: jest.fn()
};
mockAuthService = {
verifyPassword: jest.fn(),
generateToken: jest.fn()
};
loginUser = new LoginUser(mockUserRepository, mockAuthService);
});
it('should login successfully with valid credentials', async () => {
const mockUser = {
id: 'user-id',
email: 'test@example.com',
name: 'Test User',
passwordHash: 'hashed-password'
};
mockUserRepository.findByEmail.mockResolvedValue(mockUser);
mockAuthService.verifyPassword.mockResolvedValue(true);
mockAuthService.generateToken.mockResolvedValue('jwt-token');
const result = await loginUser.execute('test@example.com', 'password');
expect(result).toEqual({
user: {
id: 'user-id',
email: 'test@example.com',
name: 'Test User'
},
token: 'jwt-token'
});
});
});
部署配置别忘了
实际部署的时候还需要一些配置文件,比如环境变量的管理:
// src/config/app.js
export const config = {
database: {
url: process.env.DATABASE_URL,
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 5432
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '24h'
},
api: {
baseUrl: process.env.API_BASE_URL || 'http://localhost:3000',
timeout: parseInt(process.env.API_TIMEOUT) || 5000
}
};
踩坑提醒:这三点一定注意
- 分层不要过度设计:一开始别把所有可能的用例都考虑进去,先实现核心功能,后面再逐步扩展。我之前为了追求完美,把每个实体的CRUD都提前设计好了,结果大部分都没用到。
- 接口设计要稳定:一旦接口定义好了,尽量不要频繁修改。因为下层的实现要跟着变动,上层的调用也要调整。特别是API返回的数据结构,一旦上线就很难改。
- 错误处理要统一:各层之间的错误传递要有个统一的标准,不然调试起来会疯掉。建议定义一套错误码系统,在表示层统一处理。
以上是我对Clean Architecture的一些实践经验,虽然看起来复杂,但真正理解了之后开发体验还是不错的。这个架构的拓展用法还有很多,比如事件驱动、CQRS模式等,后续我会继续分享相关的博客内容。
本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。

暂无评论