代码越改越乱?来试试前端领域模型驱动设计(DDD)

作者:王培杰

背景

上面的场景想必大家都有体会:产品或后端让你帮忙确认一段历史逻辑。对于业务复杂的系统,我们需要抽丝剥茧般地梳理代码。当然,现在有了 AI 辅助,我们可以用"魔法"快速定位逻辑。但如果要在这些历史逻辑的基础上继续迭代,即便有 AI 的帮助,也很容易陷入一个不可逆的循环------改着改着就推不动了,或者改完后难以自我审查。

在项目演进过程中,业务复杂度的升级是必然的,但代码走向不可维护,归根结底是由于项目复杂度的失控。

是什么让我们的代码走向不可维护呢

例子1

我们先来看一段伪代码。下面是一个购物车列表页,功能很简单,通过选中部分购物车行进行商品的提交。

ini 复制代码
function CartList({ data }: { data: CartLineData[] }) {
  const [cart] = useState(() => new Cart(data));
  const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
  const [loading, setLoading] = useState(false);

  const handleSubmit = async () => {
    try {
      setLoading(true);
      await cartService.submit({ products: selectedCodes });
    } catch (error) {
      Toast.show(error.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <ScrollList>
        {cart.lines.map(line => (
          <CartLine
            key={line.productCode}
            line={line}
            checked={selectedCodes.includes(
              line.productCode
            )}
            onCheck={checked => {
              setSelectedCodes(current =>
                checked
                  ? [...current, line.productCode]
                  : current.filter(
                      code =>
                        code !== line.productCode
                    )
              );
            }}
          />
        ))}
      </ScrollList>

      <Button loading={loading} onClick={handleSubmit}>
        提交
      </Button>
    </>
  );
}

现在的代码逻辑看着也比较清晰,这个时候产品又提出了一个需求,需要我们对提交的商品根据类型进行自动合并分组,如果是日配的商品合并在一块提交。

ini 复制代码
import React, { useState, useMemo } from 'react';

function CartList({ data }: { data: CartLineData[] }) {
  const cart = useMemo(() => new Cart(data), [data]);
  const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
  const [loading, setLoading] = useState(false);

  const totalAmount = useMemo(() => {
    return cart.lines
      .filter(line => selectedCodes.includes(line.productCode))
      .reduce((sum, line) => {
        const lineTotal = line.totalAmount ?? (line.price * line.quantity);
        return sum + lineTotal;
      }, 0);
  }, [cart, selectedCodes]);

  const handleSubmit = async () => {
    if (selectedCodes.length === 0) {
      Toast.show('请先选择要结算的商品');
      return;
    }

    try {
      setLoading(true);

      // 1. 获取选中的完整商品对象数组
      const selectedItems = cart.lines.filter(item => 
        selectedCodes.includes(item.productCode)
      );

      // 2. 根据 item.isDaily 进行分类
      const dailyProducts = selectedItems.filter(item => item.isDaily);
      const normalProducts = selectedItems.filter(item => !item.isDaily);

      // 3. 按照要求组装 products 结构
      // (如果某一组没有商品,通常用 .filter 过滤掉空组,若后端强制要求两组都传可去掉 filter)
      const productsPayload = [
        {
          groupType: 'daily',
          products: dailyProducts.map(item => item.productCode),
        },
        {
          groupType: 'normal',
          products: normalProducts.map(item => item.productCode),
        },
      ].filter(group => group.products.length > 0);

      // 4. 提交数据
      await cartService.submit({ products: productsPayload });
    } catch (error: any) {
      Toast.show(error?.message || '提交失败,请重试');
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <ScrollList>
        {cart.lines.map(line => (
          <CartLine
            key={line.productCode}
            line={line}
            checked={selectedCodes.includes(line.productCode)}
            onCheck={checked => {
              setSelectedCodes(current =>
                checked
                  ? [...current, line.productCode]
                  : current.filter(code => code !== line.productCode)
              );
            }}
          />
        ))}
      </ScrollList>

      <span>预估金额:¥{totalAmount.toFixed(2)}</span>

      <Button loading={loading} onClick={handleSubmit}>
        提交
      </Button>
    </>
  );
}

可以看到,只是一个小小的需求改动,代码的视觉复杂度和实际逻辑复杂度就直线上升。如果这时候产品再加一个需求:根据推荐送货日期判断日配商品和常规商品是否合并,并弹出提示框------按照直观的想法,我们会在原有日配商品的逻辑上继续堆叠新的判断与交互,其改动后的混乱效果是可以预料的。

仅仅目前这些字段,要搞清楚其中的逻辑,就必须通读整个组件。随着业务累积,难以维护将成为必然。字段间的关系盘根错节,我们无奈地陷入了"牵一发而动全身"的境地。

例子2

再举一个例子,想必大家都经历过重构------无论是组件功能重构还是整体项目重构,其中都少不了对业务逻辑和策略的梳理。业务逻辑与策略通常是稳定的,而 UI 交互则具有更高的灵活度。但在实际开发中,一段"判断商品是否有效"的逻辑常常会在多个组件中被重复编写。在代码里没有恒定的业务实体去承载逻辑。

问题总结

面对这些问题,我们容易想到的对策是:抽象工具函数,或在设计组件时增强扩展性。但产品的一次需求变动,往往就能轻易打破我们曾以为的"精妙设计"。为什么?

因为这些对策仅仅技术层面的设计。试想,如果我们只是一味地堆叠技术设计,虽然能轻易将相同样式的代码提炼成组件,定义更通用的类,但这些组件和类却无法完全表达业务。由于技术模型与业务领域模型不匹配,业务一变,技术模型就需要重新设计。长此以往,我们极易掉入"重新设计 -> 设计被业务摧毁 -> 再次重新设计"的死循环。

所以,我们需要在前端建立一套能表达"业务语言"的模型。

**业务层面**的表达要求我们的代码需要匹配业务的抽象和策略,理想的情况下,代码可以被表达成一份需求文档。在前端进行领域驱动设计是解决如何用代码表达业务层面的知识、逻辑的一个解决方案。

领域模型驱动设计(DDD)是什么

领域驱动设计(英文:Domain-Driven Design,缩写DDD)是一种模型驱动设计的方法。

总的来说,领域模型是能够联结产品-后端-前端的统一语言。统一的语言既可以统一大家的理解,也方便后续业务知识的沉淀。减少了协同之间的歧义以及多余的表达。

前端如何进行领域驱动设计

过往领域模型常常由后端主导设计,前端该如何落地 DDD 呢?最近我们刚好在重构一个系统,下面总结了重构过程中构建领域模型的流程:

  1. 理解需求:这是我们构建模型的基础。
  2. 理解后端模型设计:后端通常负责服务端业务约束和数据一致性,其模型是前端模型构建的重要参考。但前端不应直接照搬接口 DTO,而应结合产品语言和前端场景建立自己的领域模型,并通过 Mapper 隔离差异。
  3. 分析关联,找出聚合边界和聚合根:在业务上,领域模型之间存在关联(比如商品和购物车)。
  4. 建立前端领域模型:在前端代码中构建业务实体。
  5. 把控接口约定:在接口层中构建防腐隔离。
  6. 开发中注意业务定义:实时同步、对齐产品与后端模型。

其中,最重要的是前三个流程,因为它们直接决定了后续模型建立的合理性。

建立前端的领域模型

从贫血模型到充血模型

在 DDD 中,充血模型和贫血模型代表了两种截然不同的面向对象设计哲学。它们的核心区别在于: "业务数据"与"业务行为(逻辑)"是否封装在同一个对象中。

在重构的实践中,我们设计的前端的领域模型是行为饱满的领域对象(即充血模型),它不只纯粹表达数据结构,还需要为页面交互留足空间。

根据前面的例子,我们可以先建立这样的一个贫血模型。

css 复制代码
/** 购物车行 */
class CartLine {
  /** 购物车行 ID */
  cartLineId: string;
  /** 门店编码 */
  shopCode: string;
  /** 商品编码 */
  productCode: string;
  /** 数量 */
  quantity: number;
  /** 展示名称 */
  displayName: string;
  /** 主图 */
  mainImage: string;
  /** 规格文本 */
  specText?: string;
  /** 当前销售单价 */
  salePrice: number;
  /** 行金额 */
  lineAmount: number;
  /** 是否支持日配 */
  supportDailyDelivery: boolean;
}

这个模型可以提供类型约束,但它只回答了"购物车行有哪些数据",没有回答:

  • 这条购物车行是否有效?
  • 数量是否符合购买规则?
  • 它是否属于日配商品?
  • 它应该如何计算金额?

为了避免这些知识散落在组件、Hook 和工具函数中的情况。我们可以建立下面这样的充血模型

typescript 复制代码
export interface CartLineData {
  cartLineId: string;
  productCode: string;
  displayName: string;
  quantity: number;
  salePrice: number;
  saleStep: number;
  minBuyQuantity: number;
  effective: boolean;
  supportDailyDelivery: boolean;
}

export class CartLine {
  constructor(private readonly data: CartLineData) {}

  get quantity() {
    return this.data.quantity;
  }

  get amount() {
    return this.data.salePrice * this.data.quantity;
  }

  get isDailyDelivery() {
    return this.data.supportDailyDelivery;
  }

  get canSubmit() {
    return this.data.effective && this.validateQuantity().length === 0;
  }

  validateQuantity(): string[] {
    ....
    // 存放一些校验逻辑
  }

  changeQuantity(quantity: number) {
    return new CartLine({ ...this.data, quantity });
  }
}

接口模型的适配

我们在前端定义好了模型,但后端接口会随业务迭代发生变化。返回的结构和字段命名往往无法直接开箱即用(例如存在多余字段)。此时,我们可以为购物车实体建立 Mapper,承担防腐层的作用。当后端字段变化时,修改集中在 Mapper 中即可,领域层和视图层继续使用稳定的业务语言。

yaml 复制代码
export const toCartLine = (dto: CartLineDTO) =>
  new CartLine({
    cartLineId: String(dto.cartLineId),
    productCode: dto.productCode,
    displayName: dto.displayName,
    quantity: dto.quantity,
    salePrice: dto.salePrice,
    saleStep: dto.saleStep,
    minBuyQuantity: dto.minBuyQuantity,
    effective: dto.effective,
    supportDailyDelivery: dto.supportDailyDelivery,
    ...
  });

领域服务

在模型设计时,你可能会遇到这样的问题:想建模一个领域概念,但将其放在实体上不合适,放在值对象上也不合适。这时你或许会怀疑自己的建模思路出了问题。别担心,领域服务就是用来处理这种场景的。

承接上文的例子,在项目演进过程中,我们的策略也会逐渐复杂。假如我们有一个下面表格中的策略,可以通过创建一个 SplitStrategyService 来专门负责此事。

typescript 复制代码
// 方式1:可以写成纯函数领域服务
export const SplitStrategyService = (
  cartLines: CartLine[],
  recommendation: DeliveryDateRecommendationInfo | undefined,
  canDailyDelivery: boolean
) => {
 const hasDailyProduct =
   canDailyDelivery &&
   cartLines.some(line => line.isDailyDelivery && line.canSubmit);

 const hasNormalProduct = cartLines.some(
   line => !line.isDailyDelivery && line.canSubmit
 );

  if (!hasDailyProduct || !hasNormalProduct) {
    return DailyDeliveryStrategy.NONE;
  }

  const { commonDate, dailyDate } = getRecommendDeliveryDatePair(recommendation);

  if (commonDate.isSame(dailyDate, 'day')) {
    return DailyDeliveryStrategy.UNIFIED;
  }

  if (commonDate.isAfter(dailyDate)) {
    return DailyDeliveryStrategy.SEPARATE;
  }

  return DailyDeliveryStrategy.NONE;
};

// 方式2:或者通过类工厂扩展聚合能力
export class Cart {
  constructor(protected readonly cartLines: CartLine[]) {}
  ...
}

export class SplitStrategyService {
  static extend(BaseCart: typeof Cart) {
    return class SplittableCart extends BaseCart {
      decideSplitStrategy(
        recommendation: DeliveryDateRecommendationInfo | undefined,
        canDailyDelivery: boolean
      ): DailyDeliveryStrategy {
        ....
        return DailyDeliveryStrategy.NONE;
      }
    };
  }
}

领域服务服务没有标准范式,如果只有少量的领域服务内容,纯函数反而更加直观。

页面中原本需要通读才能理解的多 if语句,转变成了明确直观的业务句子:

ini 复制代码
// -----使用1-----
const strategy = decideSplitStrategy(
  cartLines,
  recommendation,
  shop.canDailyDelivery
);
// -----使用2-----
const SplittableCart =
  SplitStrategyService.extend(Cart);

const cart = new SplittableCart(cartLines);

const strategy = cart.decideSplitStrategy(
  recommendation,
  shop.canDailyDelivery
);
领域服务和 utils 有什么区别

二者在语法上都可能只是一个纯函数,区别在于它们表达的知识不同。

类型 关注点 示例
工具函数 通用技术能力 价格的格式化、标题的组装等
领域服务 业务规则和策略 decideDailyDeliveryStrategy、业务提交上的校验

一个函数是否应该进入领域层,我总结了可以用三个判断的小方法:

  1. 它是否包含稳定的业务规则、策略,而不是 UI 或框架细节?
  2. 它是否因为涉及多个实体,无法自然地放到某一个实体中?
  3. 如果再让你重构这一块的代码逻辑,这是不是你一定要关注的业务逻辑?

如果答案大多为"是",它更应该在领域服务中进行表达。

交互模型

本文把承载一次用户用例执行过程的对象定义为"交互模型"。它在传统 DDD 分层中更接近应用服务。负责连接 Vi视图、领域模型和基础设施。

上面我们已经成功定义了实体、实现了领域服务,解决了"领域知识、业务规则放在哪里"。但还没有解决:

  • 页面初始化需要请求哪些数据?
  • 多个接口是否可以并行?
  • 用户操作会触发哪些业务规则?
  • 当前状态是否允许下一步行为?
  • 如何根据业务策略生成最终提交参数?
为什么需要交互模型

以订单提交页面为例:

复制代码
进入页面
↓
请求配送信息(包含送货日期、配送路线等)
↓
请求购物车商品信息
↓
用户选择商品
↓
计算价格
↓
点击提交
↓
校验业务规则
↓
生成提交参数
↓
调用接口

没有交互的话,页面组件将会承担所有职责。

构建交互模型

在 React 项目中,交互模型通常可以表现为业务 Hook。

上面的流程我们可以写为:包含初始化和提交,当然如果我们页面的功能比较繁杂,比如操作比较多,我们可以拆分出专门的一个action-hook去承接操作的表达。

scss 复制代码
function useSettlementInteraction(){
 const [orders,setOrders] = useState([])
 const initialize = async()=>{
    const [      cart,      delivery,      promotion    ] = await Promise.all([
      fetchCart(),
      fetchDelivery(),
      fetchPromotion()
    ])
 }
 const submit = async()=>{
    const strategy =
       decideDeliveryStrategy(
          orders
       )
    const params =
       createSubmitParams({
          orders,
          strategy
       })
    await submitOrder(params)
 }
 return {
    orders,
    initialize,
    submit
 }
}

在视图层的使用

至此,大家可能会有一个疑惑:我们写了这么多代码逻辑,却还没编写一行视图代码。但这正是将业务逻辑从视图中解放出来的必经之路。

前面我们通过领域模型表达了业务知识,通过领域服务承载了跨实体策略,通过交互模型组织了完整的用户用例。那么还剩最后一个问题:View 层还应负责什么?

一个页面理想情况下应该只关心:

  • 当前应该展示什么状态;
  • 用户进行了什么操作;
  • 如何反馈操作结果。

仍是以前面的例子进行实践,此时我们可以得到一个扁扁的视图层,看上去是不是很干净利落:

可以看到,页面并不知道业务细节。

我们在视图层仅仅是对写好的交互模型进行调用,相关的业务知识都已经被隐藏到了领域模型中。

javascript 复制代码
function SettlementPage(){
  const {
    orders,
    loading,
    submit
  } = useSettlementInteraction();

  return (
    <>
      <OrderList 
        data={orders}
      />

      <Button
        loading={loading}
        onClick={submit}
      >
        提交订单
      </Button>
    </>
  )
}

总结

回应前文

回应前文:回到文章开头提出的两个痛点------业务逻辑与交互逻辑交织难以维护,以及重构时苦于梳理业务逻辑。到现在,这两个问题都有了明确的解法:

  1. 业务变化不再牵连视图: 领域模型表达了业务本质,且领域层已与视图层分离。后续业务变化时,我们只需更新领域层的知识,减少对视图层的连锁修改。
  2. 多端复用与框架无关: 领域模型从视图中抽离后,无论是业务重构还是多端需求(如 PC 端与 APP 端视图不同),领域层的代码都能在多端、不同框架间直接复用。

领域模型设计不是银弹

在仙侠世界里我们常常能看到诸如"一剑破万法"的神通。然而在现实中,设计往往都有其适用范围和局限性。领域模型驱动解决的是:业务复杂度失控的问题,并非是所有代码组织问题的万能方案。

对于一些简单场景,引入完整领域模型反而可能增加成本。例如:

  • 一个展示页面;
  • 一个简单表单;
  • 一个 CRUD 管理页面;
  • 没有复杂业务规则的数据列表。

其次,领域模型驱动设计是一种思想,即使我们在一个全新的项目上进行了这一思想的贯彻也并不是就万事大吉了。业务的迭代是一个长期的过程,在这个过程里,我们模型也需要进行对应的迭代。如何维护好我们的设计,避免脏代码污染了我们的模型,设定一个合适的准入标准也是非常重要的。

最后想说的是,前端的领域驱动设计并不要求大家套用死板的文件模板,非得建一个写满细节的领域类。架构设计的唯一标准在于:你是否体现出了领域层的思想,把业务实体表达清楚了。

针对日常开发中的一段逻辑该放在哪里,我总结了一个简单的分类标准:

  1. 视图层:像 loading 状态、弹窗展示控制这类纯视图交互,直接写在组件里。
  2. 交互模型层:页面初始化的数据请求、流程校验等用例编排内容,写进对应的 Hook 中。
  3. 领域层:业务强相关的逻辑(如店铺状态判断)、拆单策略,统统写入领域层。值得注意的是,这一层是必须与框架解耦的。
相关推荐
Hilaku1 小时前
为什么跳槽涨薪 30% 的时代彻底结束了?聊聊 2026 前端薪资的天花板
前端·javascript·程序员
愚公搬代码1 小时前
【愚公系列】《Web应用安全》006-HackBar插件的使用
前端·安全
CoovallyAIHub2 小时前
系统越上越多、问题越查越慢,制造业厂长的真实痛点
人工智能·agent·数据可视化
超爱吃香菜的菜鸟2 小时前
关于pnpm 部分使用(一)
前端·vue.js
Vuji2 小时前
Pi 插件解剖|git-checkpoint.ts:只用 53 行,让 fork 恢复代码状态
前端·agent
大家的林语冰2 小时前
✌️ 让 Rust 再次伟大,pnpm 12 抛弃 TypeScript,移植 Rust 原地起飞!
前端·javascript·node.js
安逸sgr3 小时前
循环神经网络 RNN、LSTM、GRU 是什么?为什么能处理序列?
人工智能·ai·大模型·agent·智能体
XPoet3 小时前
AI 编程工程化:实战——从 0 到 1 搭建 AI 编程工作流
前端·后端·ai编程