Clean Architecture在前端项目中的实践踩坑记录

诸葛雨路 框架 阅读 1,205
赞 21 收藏
二维码
手机扫码查看
反馈

先看看实际效果

之前项目里用了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立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论