前端整洁架构详解

15 阅读5分钟

前端整洁架构详解

以电商购物车系统为例,逐层讲解整洁架构在前端中的落地方式。


架构分层总览

┌──────────────────────────────────┐
│  UI 框架层(Vue/React 组件)      │  ← 最外层:页面、组件
│  ┌────────────────────────────┐  │
│  │  接口适配层                 │  │  ← Store/Composable、API 适配、Repository 实现
│  │  ┌────────────────────┐    │  │
│  │  │  应用层             │    │  │  ← 用例:编排领域逻辑
│  │  │  ┌────────────┐    │    │  │
│  │  │  │  领域层     │    │    │  │  ← 纯 JS/TS:实体、值对象、规则
│  │  │  └────────────┘    │    │  │
│  │  └────────────────────┘    │  │
│  └────────────────────────────┘  │
└──────────────────────────────────┘

依赖规则:依赖关系只能从外层指向内层,内层完全不知道外层的存在。


1. 领域层(纯 JS/TS,零依赖)

最内层,不引入任何框架,不 import Vue/React,不 import axios,纯业务逻辑。

1.1 值对象 — 封装校验和不可变概念

// domain/value-objects/Money.ts
export class Money {
  constructor(private amount: number, private currency: string = 'CNY') {
    if (amount < 0) throw new Error('金额不能为负数');
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) throw new Error('币种不同');
    return new Money(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return new Money(this.amount * factor, this.currency);
  }

  get value(): number { return this.amount; }
  toString(): string { return `${this.currency} ${this.amount.toFixed(2)}`; }
}
// domain/value-objects/Email.ts
export class Email {
  constructor(private value: string) {
    if (!/^[^\s@]+@[^\s@]+.[^\s@]+$/.test(value)) {
      throw new Error('邮箱格式不合法');
    }
  }

  get domain(): string { return this.value.split('@')[1]; }
  toString(): string { return this.value; }
}

为什么用值对象? 校验逻辑内聚,不会散落在各个组件里。任何地方拿到一个 Money,就保证它是合法的。

1.2 实体 — 有标识,有生命周期

// domain/entities/CartItem.ts
import { Money } from '../value-objects/Money';

export class CartItem {
  constructor(
    public readonly productId: string,  // 唯一标识
    public readonly name: string,
    private unitPrice: Money,
    private quantity: number,
  ) {
    if (quantity <= 0) throw new Error('数量必须大于0');
  }

  get totalPrice(): Money {
    return this.unitPrice.multiply(this.quantity);
  }

  changeQuantity(newQty: number): CartItem {
    return new CartItem(this.productId, this.name, this.unitPrice, newQty);
  }
}

1.3 聚合根 — 保证一致性边界

// domain/aggregates/Cart.ts
import { Money } from '../value-objects/Money';
import { CartItem } from '../entities/CartItem';

export class Cart {
  private items: CartItem[] = [];

  addItem(item: CartItem): void {
    const existing = this.items.find(i => i.productId === item.productId);
    if (existing) {
      // 已存在则合并数量
      this.items = this.items.map(i =>
        i.productId === item.productId
          ? i.changeQuantity(i.quantity + item.quantity)
          : i
      );
    } else {
      this.items.push(item);
    }
  }

  removeItem(productId: string): void {
    this.items = this.items.filter(i => i.productId !== productId);
  }

  get total(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.totalPrice),
      new Money(0),
    );
  }

  get itemCount(): number {
    return this.items.length;
  }

  // 业务规则:购物车最多 20 件商品
  canAddMore(): boolean {
    return this.items.length < 20;
  }
}

关键点:所有业务规则都在这里——最多 20 件、合并同类商品、金额计算。组件不需要知道这些规则。

1.4 Repository 接口(领域层定义)

// domain/repositories/CartRepository.ts
import { Cart } from '../aggregates/Cart';

export interface CartRepository {
  getCart(): Promise<Cart>;
  save(cart: Cart): Promise<void>;
  clear(): Promise<void>;
}

接口在领域层定义,实现在适配层——这是依赖反转的核心,让领域层不依赖具体的数据获取方式。


2. 应用层(用例 — 编排领域对象)

这一层只做编排,不包含业务规则本身。它协调领域对象完成一个完整操作。

2.1 添加商品到购物车

// application/usecases/AddToCartUseCase.ts
import { Cart } from '../../domain/aggregates/Cart';
import { CartItem } from '../../domain/entities/CartItem';
import { Money } from '../../domain/value-objects/Money';
import { CartRepository } from '../../domain/repositories/CartRepository';

export class AddToCartUseCase {
  constructor(private cartRepo: CartRepository) {}

  async execute(productId: string, name: string, price: number, qty: number): Promise<Cart> {
    // 1. 从仓库获取当前购物车
    const cart = await this.cartRepo.getCart();

    // 2. 业务规则检查(规则在领域对象里,用例只调用)
    if (!cart.canAddMore()) {
      throw new Error('购物车已满,最多 20 件商品');
    }

    // 3. 创建领域对象
    const item = new CartItem(productId, name, new Money(price), qty);

    // 4. 执行领域操作
    cart.addItem(item);

    // 5. 持久化
    await this.cartRepo.save(cart);

    return cart;
  }
}

2.2 结算下单

// application/usecases/CheckoutUseCase.ts
import { CartRepository } from '../../domain/repositories/CartRepository';
import { OrderRepository } from '../../domain/repositories/OrderRepository';
import { Order } from '../../domain/aggregates/Order';

export class CheckoutUseCase {
  constructor(
    private cartRepo: CartRepository,
    private orderRepo: OrderRepository,
  ) {}

  async execute(userId: string): Promise<Order> {
    const cart = await this.cartRepo.getCart();

    if (cart.itemCount === 0) {
      throw new Error('购物车为空,无法下单');
    }

    // 领域逻辑:创建订单
    const order = Order.createFromCart(cart, userId);

    await this.orderRepo.save(order);
    await this.cartRepo.clear();

    return order;
  }
}

用例层的特点:读数据 → 调领域方法 → 存数据。像导演一样编排,但不自己写业务规则。


3. 接口适配层(Store/Composable + API 适配)

3.1 Repository 实现(JSON ↔ 领域对象转换)

// infrastructure/repositories/ApiCartRepository.ts
import { CartRepository } from '../../domain/repositories/CartRepository';
import { Cart } from '../../domain/aggregates/Cart';
import { CartItem } from '../../domain/entities/CartItem';
import { Money } from '../../domain/value-objects/Money';
import { cartApi } from '../api/CartApi';

export class ApiCartRepository implements CartRepository {
  async getCart(): Promise<Cart> {
    // API 返回的是原始 JSON,需要转换成领域对象
    const raw = await cartApi.fetchCart();
    const cart = new Cart();
    for (const item of raw.items) {
      cart.addItem(new CartItem(item.productId, item.name, new Money(item.price), item.qty));
    }
    return cart;
  }

  async save(cart: Cart): Promise<void> {
    // 领域对象 → 原始 JSON,给 API
    const payload = {
      items: cart.items.map(i => ({
        productId: i.productId,
        name: i.name,
        price: i.unitPrice.value,
        qty: i.quantity,
      })),
    };
    await cartApi.updateCart(payload);
  }

  async clear(): Promise<void> {
    await cartApi.clearCart();
  }
}

关键:API 返回的 JSON → 领域对象的转换在这里完成。领域层永远不碰原始 JSON。

3.2 API 层(最外层细节)

// infrastructure/api/CartApi.ts
import axios from 'axios';

export const cartApi = {
  fetchCart: () => axios.get('/api/cart').then(r => r.data),
  updateCart: (data: any) => axios.put('/api/cart', data).then(r => r.data),
  clearCart: () => axios.delete('/api/cart').then(r => r.data),
};

3.3 Composable(连接用例和 UI)

// application/composables/useCart.ts
import { ref } from 'vue';
import { AddToCartUseCase } from '../usecases/AddToCartUseCase';
import { ApiCartRepository } from '../../infrastructure/repositories/ApiCartRepository';

const cartRepo = new ApiCartRepository();
const addToCartUseCase = new AddToCartUseCase(cartRepo);

export function useCart() {
  const cart = ref<Cart | null>(null);
  const loading = ref(false);
  const error = ref<string | null>(null);

  async function addItem(productId: string, name: string, price: number, qty: number) {
    loading.value = true;
    error.value = null;
    try {
      cart.value = await addToCartUseCase.execute(productId, name, price, qty);
    } catch (e) {
      error.value = e.message;
    } finally {
      loading.value = false;
    }
  }

  return { cart, loading, error, addItem };
}

4. UI 框架层(组件 — 只负责渲染)

<!-- ui/components/ProductCard.vue -->
<script setup lang="ts">
import { useCart } from '@/application/composables/useCart';

const { addItem, loading, error } = useCart();

function handleAdd() {
  addItem(product.id, product.name, product.price, 1);
}
</script>

<template>
  <div class="product-card">
    <h3>{{ product.name }}</h3>
    <p>¥{{ product.price }}</p>
    <button :disabled="loading" @click="handleAdd">
      加入购物车
    </button>
    <p v-if="error" class="error">{{ error }}</p>
  </div>
</template>

组件只做三件事:展示数据、捕获用户操作、调用用例。零业务逻辑。


依赖关系总览

ProductCard.vue          ← 只渲染,零业务逻辑
  ↓ 调用
useCart()                ← 状态管理 + 调用用例
  ↓ 调用
AddToCartUseCase         ← 编排领域对象
  ↓ 使用
Cart / CartItem / Money  ← 纯业务规则
  ↓ 通过接口
CartRepository (接口)    ← 领域层定义接口
  ↓ 实现
ApiCartRepository        ← 适配层实现,转换 JSON ↔ 领域对象
  ↓ 调用
cartApi (axios)          ← 最外层:HTTP 请求细节

箭头方向 = 依赖方向,全部从外指向内。内层完全不知道外层存在。


项目目录结构

src/
├── domain/                          # 领域层(纯 TS,零框架依赖)
│   ├── value-objects/
│   │   ├── Money.ts
│   │   └── Email.ts
│   ├── entities/
│   │   └── CartItem.ts
│   ├── aggregates/
│   │   ├── Cart.ts
│   │   └── Order.ts
│   └── repositories/
│       ├── CartRepository.ts        # 接口定义
│       └── OrderRepository.ts
│
├── application/                     # 应用层(用例编排)
│   ├── usecases/
│   │   ├── AddToCartUseCase.ts
│   │   └── CheckoutUseCase.ts
│   └── composables/
│       └── useCart.ts
│
├── infrastructure/                  # 接口适配层
│   ├── repositories/
│   │   └── ApiCartRepository.ts     # Repository 实现
│   └── api/
│       └── CartApi.ts               # axios 调用
│
├── ui/                              # UI 框架层
│   ├── components/
│   │   └── ProductCard.vue
│   └── pages/
│       └── CartPage.vue

这样做的好处

场景传统做法整洁架构
换框架 Vue→React重写所有业务逻辑只重写 UI 层,领域层直接复用
API 字段名变了改几十个组件只改 ApiCartRepository 的转换逻辑
加新业务规则在组件里到处加 if在领域对象里加,组件无感知
单元测试mock axios、mount 组件直接测 Cart.addItem(),纯函数测试
后端复用不可能领域层是纯 TS,Node 端可直接引用

实际项目中的权衡

不是所有前端项目都需要完整分层

项目规模建议做法
简单页面/CRUD分离 API 调用和 UI 即可,不必过度设计
中等复杂度提取领域层(纯 TS),用 Composable 做应用层
复杂业务系统完整分层 + DDD 建模(如交易系统、审批流)

核心原则

领域层必须是纯 JS/TS,不依赖任何框架。 这样你的业务逻辑可以:

  • 在 Vue 和 React 之间迁移
  • 在 Node 后端复用(同构)
  • 独立写单元测试,不需要 mock 组件

框架是细节,业务逻辑才是核心。