大家好,欢迎来到今天的讲座。我是你们的技术向导。
今天我们不聊 map 和 filter,不聊怎么把 CSS 写进 style 属性里,也不聊怎么用 useMemo 去优化那根本不存在的性能瓶颈。我们聊点更硬核的,聊点能让你在代码审查时看着隔壁组的“面条代码”露出慈父般微笑的东西——领域驱动设计(DDD)在 React 中的应用。
特别是,我们要解决一个让无数 React 开发者夜不能寐的问题:物理层级划分。
想象一下,你的项目目录里有一千个文件。Button.js 在这里,UserList.js 在那里,apiService.js 又在另一个角落。当你想改个业务逻辑时,你得像个在迷宫里找出口的蚂蚁,翻过一座“UI 组件山”,跨过一条“工具函数河”,最后才摸到“业务逻辑”的脚趾头。这就像你点了一份宫保鸡丁,结果厨师把鸡肉和花生米拌在了一起,你还得自己把它们分开。
今天,我们就来用 DDD 的手术刀,把这团乱麻解剖开来,建立一个清晰的、物理上隔离的、逻辑上紧密相连的架构。
一、 拒绝“上帝文件夹”,拥抱“洋葱架构”
首先,我们要确立一个原则:物理位置决定职责。
在传统的 React 项目中,大家最喜欢干的一件事就是:创建一个名为 src 的文件夹,然后像撒胡椒面一样把所有东西都扔进去。components、pages、services、utils,统统平级。这叫什么?这叫“面条式架构”。
在 DDD 的世界里,我们要建立一个“洋葱架构”或者更通俗的“Feature-based Architecture”。
我们把项目按照业务功能来切分,而不是按照技术层次(比如“所有 View 放一起,所有 Model 放一起”)。
举个例子,假设我们在做一个电商系统。不要搞什么 src/components/Product 和 src/components/Cart。我们要搞什么?我们要搞 src/features/cart 和 src/features/product。
每一个 feature 目录下,都应该包含三个核心物理区域:
- Domain (领域层):这里存放业务实体、业务逻辑。注意:这里绝对不能有任何 React 的影子,不能有任何
import React,甚至不能有任何useState。 这里是纯粹的 JavaScript/TypeScript 面向对象编程(OOP)。 - Application (应用层):这里存放用例,也就是“如何使用领域层”。它负责协调,负责事务管理,负责把 UI 的请求翻译成领域层的命令。
- Infrastructure (基础设施层):这里存放数据访问、UI 视图。注意,在 DDD 的洋葱架构里,基础设施层是向内依赖的,它依赖应用层,而不是反过来。
让我们用代码说话,看看这应该长什么样:
src/
├── features/
│ ├── cart/
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ │ ├── CartItem.ts <-- 纯业务实体
│ │ │ │ └── Cart.ts <-- 聚合根
│ │ │ └── valueObjects/
│ │ │ └── Money.ts <-- 金额这种东西是值对象
│ │ ├── application/
│ │ │ ├── services/
│ │ │ │ └── CartService.ts <-- 业务编排
│ │ │ └── repositories/
│ │ │ └── ICartRepository.ts <-- 仓储接口
│ │ └── infrastructure/
│ │ ├── repositories/
│ │ │ └── LocalCartRepository.ts <-- 仓储实现
│ │ └── ui/
│ │ ├── CartPage.tsx <-- 懒惰组件
│ │ └── components/
│ │ ├── CartItemView.tsx
看到没?这就是我们要建立的物理防线。
二、 领域层:业务的心脏(不要把它变成 React 组件)
很多人搞错了,他们把业务逻辑写在 React 组件的 useEffect 里。这就像你把心脏放在冰箱里保鲜,结果心脏冻硬了,怎么跳?
在领域层,我们要定义实体和值对象。
1. 值对象:不可变的业务单位
比如“价格”、“数量”、“地址”。这些东西没有唯一 ID,它们只是状态的描述。在 React 里,我们习惯了 const [price, setPrice] = useState(0),但在 DDD 里,值对象应该是不可变的。
我们定义一个 Money 类,它封装了金额和货币单位。它不应该有“修改”的方法,它应该有“计算”的方法。
// src/features/cart/domain/valueObjects/Money.ts
export class Money {
constructor(
public readonly amount: number,
public readonly currency: string
) {}
// 值对象的方法,返回一个新的 Money 实例,而不是修改自己
public add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("Cannot add different currencies");
}
return new Money(this.amount + other.amount, this.currency);
}
// 仅仅为了展示,我们不需要 toString,让它保持纯净
}
2. 实体:有身份的业务对象
比如 CartItem。它有 ID,有状态。在 React 里,我们用对象来表示它。但在 DDD 里,我们用类。为什么?因为我们需要封装行为。一个 CartItem 不应该允许你直接把 quantity 改成 -1,它应该有一个 increaseQuantity() 的方法。
// src/features/cart/domain/entities/CartItem.ts
import { Money } from '../valueObjects/Money';
export class CartItem {
constructor(
public readonly id: string,
public readonly productId: string,
public readonly name: string,
public readonly price: Money, // 这里引用值对象
private _quantity: number
) {
if (_quantity < 0) throw new Error("Quantity cannot be negative");
}
// 封装内部状态,保证业务规则
public get quantity(): number {
return this._quantity;
}
public increaseQuantity(amount: number = 1): void {
if (amount <= 0) return;
this._quantity += amount;
}
public decreaseQuantity(amount: number = 1): void {
if (amount <= 0) return;
this._quantity -= amount;
if (this._quantity < 0) {
this._quantity = 0; // 或者抛出异常,视业务而定
}
}
public get total(): Money {
return new Money(this.price.amount * this.quantity, this.price.currency);
}
}
3. 聚合根:防御的堡垒
Cart 是聚合根。它持有 CartItem 的集合。所有的对 CartItem 的修改,都必须通过 Cart 这个聚合根来进行。这是为了保持数据的一致性。
// src/features/cart/domain/entities/Cart.ts
import { CartItem } from './CartItem';
export class Cart {
constructor(private readonly id: string) {
this.items = [];
}
private items: CartItem[] = [];
public addItem(item: CartItem): void {
// 业务规则:如果商品已存在,数量增加,而不是重复添加
const existingItem = this.items.find(i => i.id === item.id);
if (existingItem) {
existingItem.increaseQuantity();
} else {
this.items.push(item);
}
}
public removeItem(itemId: string): void {
this.items = this.items.filter(i => i.id !== itemId);
}
public updateItemQuantity(itemId: string, newQuantity: number): void {
const item = this.items.find(i => i.id === itemId);
if (item) {
// 这里可以调用 item 的方法,或者直接修改(取决于封装程度)
if (newQuantity < 0) throw new Error("Invalid quantity");
item.decreaseQuantity(item.quantity - newQuantity);
}
}
public getItems(): ReadonlyArray<CartItem> {
return this.items;
}
public get totalAmount(): number {
return this.items.reduce((sum, item) => sum + item.total.amount, 0);
}
}
看懂了吗?这个 Cart 类完全不知道 React 是什么,不知道 useState 是什么。它只关心业务逻辑:能不能加?能不能减?总数怎么算?这就是我们的业务核心。
三、 仓储模式:数据访问的抽象接口
在 DDD 中,我们使用仓储模式 来处理数据持久化。为什么?因为 React 组件不应该知道数据是从数据库来的,还是从本地缓存来的,或者是模拟的。
我们需要定义一个接口。
// src/features/cart/application/repositories/ICartRepository.ts
export interface ICartRepository {
// 获取一个购物车(通常是当前用户的)
getById(id: string): Promise<Cart>;
// 保存购物车
save(cart: Cart): Promise<void>;
}
然后,我们需要一个实现。为了演示,我们写一个内存实现,模拟数据库。
// src/features/cart/infrastructure/repositories/LocalCartRepository.ts
import { ICartRepository } from '../../application/repositories/ICartRepository';
import { Cart } from '../../domain/entities/Cart';
// 模拟一个简单的内存数据库
const db: Map<string, Cart> = new Map();
export class LocalCartRepository implements ICartRepository {
public async getById(id: string): Promise<Cart> {
const cart = db.get(id);
if (!cart) {
// 这里我们返回一个新的空购物车,或者抛出异常
return new Cart(id);
}
return cart;
}
public async save(cart: Cart): Promise<void> {
// 在实际项目中,这里会调用 localStorage 或者 API
db.set(cart.id, cart);
console.log(`Saved cart ${cart.id} to local DB`);
}
}
注意到了吗?仓储层是基础设施层的一部分。它依赖领域层(Cart 类)。但是,应用层和 UI 层不知道这个仓储是内存实现的,还是真的在连接 MongoDB。它们只知道有一个 ICartRepository 的接口。
四、 应用层:业务流程的编排者
现在,我们有了实体,有了仓库。接下来,我们需要一个服务层或者用例层。它的作用是把这些东西串联起来。
比如,用户点击“购买”按钮。这个按钮在 UI 层。UI 层不能直接去调 Cart.addItem,也不能直接去调 repository.save。
UI 层应该调用一个服务,这个服务执行一个用例。
// src/features/cart/application/services/CartService.ts
import { ICartRepository } from '../repositories/ICartRepository';
import { Cart } from '../../domain/entities/Cart';
import { CartItem } from '../../domain/entities/CartItem';
import { Money } from '../../domain/valueObjects/Money';
export class CartService {
constructor(private readonly cartRepo: ICartRepository) {}
// 用例:添加商品到购物车
public async addItemToCart(
cartId: string,
productId: string,
name: string,
priceAmount: number
): Promise<Cart> {
// 1. 获取当前购物车
const cart = await this.cartRepo.getById(cartId);
// 2. 创建商品实体(这里假设价格是只读的,或者由后端提供)
const newItem = new CartItem(
productId,
productId,
name,
new Money(priceAmount, 'CNY'),
1
);
// 3. 调用领域层的业务逻辑
cart.addItem(newItem);
// 4. 保存
await this.cartRepo.save(cart);
return cart;
}
}
这个 CartService 是连接 UI 和 Domain 的桥梁。它不包含复杂的 UI 逻辑,也不包含底层数据库操作细节,它只负责“流程控制”。
五、 UI 层:纯粹的展示与交互
好了,最激动人心的时刻到了。现在,我们有了干净的业务逻辑、数据访问接口和业务编排。最后,我们要把它们变成 React 组件。
这里我们要遵循一个铁律:展示层(View)不能包含业务逻辑,业务层不能包含 UI 逻辑。
1. 懒惰组件:纯展示
这些组件只负责渲染 HTML。它们接收 props,渲染出来。它们不应该知道什么是 CartItem,什么是 Money。它们只知道 CartItemProps。
// src/features/cart/infrastructure/ui/components/CartItemView.tsx
interface CartItemViewProps {
name: string;
price: number;
quantity: number;
onQuantityChange: (newQuantity: number) => void;
}
export const CartItemView: React.FC<CartItemViewProps> = ({
name,
price,
quantity,
onQuantityChange
}) => {
return (
<div style={{ border: '1px solid #ccc', padding: '10px', marginBottom: '10px' }}>
<h3>{name}</h3>
<p>Price: {price}</p>
<p>Quantity: {quantity}</p>
<button onClick={() => onQuantityChange(quantity + 1)}>+</button>
<button onClick={() => onQuantityChange(quantity - 1)} disabled={quantity <= 0}>-</button>
</div>
);
};
2. 智能组件:容器组件
这才是 React 发挥作用的地方。智能组件负责连接状态、调用服务、处理用户输入,然后把数据传给懒惰组件。
我们通常会写一个 useCart Hook 来封装所有的逻辑。
// src/features/cart/infrastructure/ui/CartPage.tsx
import { useState, useEffect } from 'react';
import { CartService } from '../../application/services/CartService';
import { ICartRepository } from '../../application/repositories/ICartRepository';
import { CartItemView } from './components/CartItemView';
import { Cart } from '../../domain/entities/Cart';
// 在 React 应用启动时,我们需要初始化依赖
// 实际项目中,可以使用 DI 容器(如 InversifyJS)或者 React Context
const cartService = new CartService(new LocalCartRepository());
export const CartPage: React.FC = () => {
const [cart, setCart] = useState<Cart | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 初始化:加载购物车
useEffect(() => {
loadCart();
}, []);
const loadCart = async () => {
try {
setLoading(true);
// 假设当前用户 ID 是 'user-123'
const currentCart = await cartService.getCartById('user-123');
setCart(currentCart);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// 处理数量变化
const handleQuantityChange = async (itemId: string, newQuantity: number) => {
if (!cart) return;
// 调用服务层更新领域模型
await cartService.updateQuantity('user-123', itemId, newQuantity);
// 重新加载购物车(简化处理,实际应使用 Observer 模式或状态管理库)
loadCart();
};
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h1>Your Cart</h1>
<div>
{cart.getItems().map(item => (
<CartItemView
key={item.id}
name={item.name}
price={item.total.amount}
quantity={item.quantity}
onQuantityChange={(newQty) => handleQuantityChange(item.id, newQty)}
/>
))}
<h2>Total: {cart.totalAmount}</h2>
</div>
</div>
);
};
六、 物理层级的深度解析:为什么这很重要?
你们可能会问:“这代码看起来比普通写法多了一倍,图什么呢?”
图的是可维护性和可测试性。
假设有一天,老板说:“我们要把购物车数据从 LocalStorage 改成 MySQL。”
如果你把逻辑写在组件里:
你需要去 50 个组件里找到那行 localStorage.getItem,修改它,还要确保没有副作用。
如果你用 DDD:
你只需要改 LocalCartRepository 类。其他所有代码都不用动。因为 UI 层只依赖 ICartRepository 接口,它不知道实现变了。
假设有一天,产品经理说:“购物车不能有负数。”
如果你写在组件里:
你在 UI 层加了判断 if (qty < 0) return。结果测试发现,如果你直接调用 API 接口,负数照样能传进来。因为 API 接口里没有这个判断。
如果你用 DDD:
你在 CartItem 类的构造函数或者 decreaseQuantity 方法里加了判断。无论是 UI 调用,还是 API 调用,都走的是这个方法。Bug 从源头就被堵死了。
七、 React 中的依赖注入与层与层之间的鸿沟
在 React 中,我们通常用 Context 或者 Hooks 来传递数据。但在 DDD 架构中,我们需要更严格的依赖管理。
我们可以创建一个全局的 AppContext,在应用入口处注入所有的 Service 和 Repository。
// src/App.tsx
import { CartService } from './features/cart/application/services/CartService';
import { LocalCartRepository } from './features/cart/infrastructure/repositories/LocalCartRepository';
const cartService = new CartService(new LocalCartRepository());
export const App: React.FC = () => {
return (
<CartContext.Provider value={{ cartService }}>
<Routes>
<Route path="/cart" element={<CartPage />} />
</Routes>
</CartContext.Provider>
);
};
然后在 CartPage 里,我们只需要从 Context 取出来用,或者直接实例化(在微型应用中)。
八、 进阶:事件驱动与充血模型
当然,DDD 还有很多高级玩法。
1. 充血模型 vs 贫血模型
刚才的例子中,Cart 类有方法(addItem),这是“充血模型”。很多 React 开发者习惯“贫血模型”,即 Cart 只是一个数据容器(POJO),所有逻辑都在 Service 里。在 React 中,贫血模型比较容易实现,但充血模型更符合 DDD 的初衷,它让业务逻辑集中在它该在的地方。
2. 领域事件
当购物车发生变化时,我们可以发出一个事件。
// 在 Cart 实体中
export class Cart {
// ...
public addItem(item: CartItem): void {
// ... existing logic
// 触发领域事件
this.addDomainEvent(new ItemAddedEvent(item.id));
}
}
UI 层可以订阅这些事件来更新界面,而不需要频繁去轮询数据库。
九、 总结一下“物理层级”的构建指南
最后,我们来总结一下,如何在你的 React 项目中物理地构建这个架构:
-
目录结构是第一生产力:
- 不要有
src/components,src/utils。 - 要有
src/features/{featureName}。 - 在
features下面,按domain,application,infrastructure/ui分层。
- 不要有
-
单向依赖原则:
- UI 层 -> 应用层 -> 领域层 -> 基础设施层。
- 绝对禁止 UI 层直接依赖基础设施层(比如直接写 API 调用)。
- 绝对禁止 领域层依赖 UI 层。
-
类型安全:
- 尽量使用 TypeScript。DDD 对类型非常敏感。
Money类比number类型更能防止低级错误。
- 尽量使用 TypeScript。DDD 对类型非常敏感。
-
测试分离:
- 领域层是纯函数/类,最容易写单元测试。你可以不写 UI 测试,但你必须写领域层的测试。
- 测试你的
Cart类,确保它不会把数量变成负数。 - 测试你的
CartService,确保它正确调用了Cart类的方法。
好了,今天的讲座就到这里。
我希望你们能明白,架构不是摆设,它是一种沟通的契约。当你把业务逻辑从 React 组件里抽离出来,放在 domain 目录下时,你实际上是在告诉你的团队:“这是业务规则,这是真理,谁都不能改,除非你懂行。”
这就是 React 领域驱动设计的魅力所在。它让你的代码不再是一团乱麻,而是一座座坚固的堡垒。去吧,重构你的项目,让代码变得性感起来!