前端整洁架构详解

前端整洁架构详解

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


架构分层总览

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

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


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

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

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

typescript 复制代码
// 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)}`; }
}
typescript 复制代码
// 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 实体 --- 有标识,有生命周期

typescript 复制代码
// 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 聚合根 --- 保证一致性边界

typescript 复制代码
// 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 接口(领域层定义)

typescript 复制代码
// 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 添加商品到购物车

typescript 复制代码
// 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 结算下单

typescript 复制代码
// 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 ↔ 领域对象转换)

typescript 复制代码
// 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 层(最外层细节)

typescript 复制代码
// 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)

typescript 复制代码
// 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 框架层(组件 --- 只负责渲染)

xml 复制代码
<!-- 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>

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


依赖关系总览

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

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


项目目录结构

bash 复制代码
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 组件

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

相关推荐
里欧跑得慢2 小时前
Flutter 测试全攻略:从单元测试到集成测试的完整实践
前端·css·flutter·web
徐小夕2 小时前
我花一天时间Vibe Coding的开源AI工具,一键检测你的电脑能跑哪些AI大模型
前端·javascript·github
英俊潇洒美少年2 小时前
Vue3 企业级封装:useEventListener + 终极版 BaseEcharts 组件
前端·javascript·vue.js
嵌入式×边缘AI:打怪升级日志2 小时前
使用JsonRPC实现前后台
前端·后端
小码哥_常3 小时前
深度剖析:为什么Android选择了Binder
前端
方安乐4 小时前
单元测试之helper函数
前端·javascript·单元测试
音仔小瓜皮4 小时前
【Web八股】深入理解浏览器DOM事件流,灵活控制它!
前端·web
灼灼桃花夭5 小时前
js之阳历 → 农历(含时辰)转换函数
开发语言·前端·javascript
gyx_这个杀手不太冷静5 小时前
大人工智能时代下前端界面全新开发模式的思考(三)
前端·架构·ai编程