React 领域驱动设计(DDD):在 React 项目中实现业务实体、仓储模式与 UI 视图的物理层级划分

大家好,欢迎来到今天的讲座。我是你们的技术向导。

今天我们不聊 mapfilter,不聊怎么把 CSS 写进 style 属性里,也不聊怎么用 useMemo 去优化那根本不存在的性能瓶颈。我们聊点更硬核的,聊点能让你在代码审查时看着隔壁组的“面条代码”露出慈父般微笑的东西——领域驱动设计(DDD)在 React 中的应用

特别是,我们要解决一个让无数 React 开发者夜不能寐的问题:物理层级划分

想象一下,你的项目目录里有一千个文件。Button.js 在这里,UserList.js 在那里,apiService.js 又在另一个角落。当你想改个业务逻辑时,你得像个在迷宫里找出口的蚂蚁,翻过一座“UI 组件山”,跨过一条“工具函数河”,最后才摸到“业务逻辑”的脚趾头。这就像你点了一份宫保鸡丁,结果厨师把鸡肉和花生米拌在了一起,你还得自己把它们分开。

今天,我们就来用 DDD 的手术刀,把这团乱麻解剖开来,建立一个清晰的、物理上隔离的、逻辑上紧密相连的架构。

一、 拒绝“上帝文件夹”,拥抱“洋葱架构”

首先,我们要确立一个原则:物理位置决定职责

在传统的 React 项目中,大家最喜欢干的一件事就是:创建一个名为 src 的文件夹,然后像撒胡椒面一样把所有东西都扔进去。componentspagesservicesutils,统统平级。这叫什么?这叫“面条式架构”。

在 DDD 的世界里,我们要建立一个“洋葱架构”或者更通俗的“Feature-based Architecture”。

我们把项目按照业务功能来切分,而不是按照技术层次(比如“所有 View 放一起,所有 Model 放一起”)。

举个例子,假设我们在做一个电商系统。不要搞什么 src/components/Productsrc/components/Cart。我们要搞什么?我们要搞 src/features/cartsrc/features/product

每一个 feature 目录下,都应该包含三个核心物理区域:

  1. Domain (领域层):这里存放业务实体、业务逻辑。注意:这里绝对不能有任何 React 的影子,不能有任何 import React,甚至不能有任何 useState 这里是纯粹的 JavaScript/TypeScript 面向对象编程(OOP)。
  2. Application (应用层):这里存放用例,也就是“如何使用领域层”。它负责协调,负责事务管理,负责把 UI 的请求翻译成领域层的命令。
  3. 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 项目中物理地构建这个架构:

  1. 目录结构是第一生产力

    • 不要有 src/componentssrc/utils
    • 要有 src/features/{featureName}
    • features 下面,按 domain, application, infrastructure/ui 分层。
  2. 单向依赖原则

    • UI 层 -> 应用层 -> 领域层 -> 基础设施层。
    • 绝对禁止 UI 层直接依赖基础设施层(比如直接写 API 调用)。
    • 绝对禁止 领域层依赖 UI 层。
  3. 类型安全

    • 尽量使用 TypeScript。DDD 对类型非常敏感。Money 类比 number 类型更能防止低级错误。
  4. 测试分离

    • 领域层是纯函数/类,最容易写单元测试。你可以不写 UI 测试,但你必须写领域层的测试。
    • 测试你的 Cart 类,确保它不会把数量变成负数。
    • 测试你的 CartService,确保它正确调用了 Cart 类的方法。

好了,今天的讲座就到这里。

我希望你们能明白,架构不是摆设,它是一种沟通的契约。当你把业务逻辑从 React 组件里抽离出来,放在 domain 目录下时,你实际上是在告诉你的团队:“这是业务规则,这是真理,谁都不能改,除非你懂行。”

这就是 React 领域驱动设计的魅力所在。它让你的代码不再是一团乱麻,而是一座座坚固的堡垒。去吧,重构你的项目,让代码变得性感起来!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注