前端整洁架构详解
以电商购物车系统为例,逐层讲解整洁架构在前端中的落地方式。
架构分层总览
┌──────────────────────────────────┐
│ 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 组件
框架是细节,业务逻辑才是核心。