救救孩子吧:被 AI 蠢哭后,我手把手教它 DDD,最后逼它自己写了这篇总结

救救孩子吧:被 AI 蠢哭后,我手把手教它 DDD,最后逼它自己写了这篇总结

------当 React 遇上 DDD:一次前端架构的再思考
特别说明:这不是一篇传统的"AI 辅助编程"教程。相反,这是一个"人类教 AI 学架构"的血泪史。

故事的主角有两个:

  • 落魄程序员(也就是我):在 AI 浪潮中光荣毕业,郁郁寡欢三个月后,终于下定决心学 AI Agent
  • AI 助手(也就是它):基础还行,但一遇到架构设计就露怯,经常被我骂"你怎么这么蠢"

本文记录的,就是我手把手教导 AI 至完全领悟要领的全过程。事实证明:填鸭式教育不如启发式好------这条规律,对 AI 同样适用。


0. 背景故事:当落魄程序员遇上蠢萌 AI

0.1 失业的第 90 天

2026 年初春,我被公司"优化"了。

理由很充分:AI 能写代码,而你写的代码 AI 也能写。既然如此,为什么不直接用 AI?

我拿着补偿金,回到家,关上房门,躺了三天。

然后我意识到一个问题:如果 AI 真的能替代我,那我是不是应该学会指挥 AI?

于是我打开了电脑,装了一个 AI 编程助手,开始了我的"AI Agent 学习之旅"。

0.2 被 AI 蠢哭的第一周

我:「帮我设计一个聊天室的前端架构。」

AI:「好的!我来帮你写 React 组件!」

tsx 复制代码
// AI 给出的代码
function ChatRoom() {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [isOpen, setIsOpen] = useState(false);
  // ... 200 行后
  const [someOtherState, setSomeOtherState] = useState();
}

我:「......你管这叫架构?这是屎山!」

AI:「抱歉,我来重构一下!」

然后它给了我 10 个自定义 hook,每个 hook 里面又有 5 个 useState。

我:「我让你设计架构,不是让你把屎山拆成 10 座小屎山!」

0.3 手把手教学:从失望到发现

第二天,我决定换一种方式。

我:「你知道什么是领域模型吗?」

AI:「知道!就是 Domain Model!」

我:「那你知道什么是充血模型吗?」

AI:「呃......就是模型里有血?」

我:「......」

于是我开始手把手教它:

  1. 先教它什么是"数据优先" ------ 「你先告诉我聊天室是什么,再告诉我它能做什么」
  2. 再教它什么是"单一真相来源" ------ 「你现在的 messages 在哪?状态在哪?它们是什么关系?」
  3. 然后教它什么是"薄视图层" ------ 「组件只做渲染,别把业务逻辑写组件里」

奇妙的事情发生了:当我开始用追问的方式教它,而不是告诉它"怎么做"时,它居然开始自己思考了。

0.4 转折点:AI 第一次主动提问

第五天,我对它说:

我:「现在你需要设计一个 Controller,管理聊天室列表。」

AI:「好的!我来写一个 Controller 类!」

然后它写了一个 ChatRoomController,里面有 20 个方法,每个方法都直接调 API。

我正准备骂它,它突然说:

AI:「等等,我觉得这样不对。如果我在 Controller 里直接调 API,那我怎么测试?而且,如果 API 变了,我是不是要改很多地方?」

我愣住了。

这是它第一次主动质疑自己的代码。

我:「......你说说看,你觉得应该怎么做?」

AI:「我觉得应该有一个 Ability 层,Controller 只调 Ability,不直接调 API。而且,Ability 应该通过构造函数注入依赖,这样我可以替换实现。」

我:「............」

这一刻,我知道它开窍了。

0.5 教学相长:我也学到了很多

接下来的两周,我们反复拉锯:

  • 我:「为什么要用 Proxy?」

    AI:「为了零拷贝?」

    我:「不对,再想想。」

    AI:「......为了让死数据活起来?」

    我:「对了!」

  • 我:「_converterMap 是做什么的?」

    AI:「转换数据格式?」

    我:「不对,再想想。」

    AI:「......是对象激活?把 DTO 变成领域对象?」

    我:「对了!」

在这个过程中,我也在反思:为什么我以前写代码的时候,没想到这些?

答案很简单:因为我以前也是被"填鸭"的 ------ 学框架、学 API、学最佳实践,但从来没人问我"为什么"。

教 AI 的过程,其实也是我自己在重新学习的过程。

0.6 结局:AI 终于毕业了

第十五天,我给了它一个测试:

我:「现在有一个聊天室功能,需要管理消息列表、发送消息、显示时间线。你设计一个架构。」

AI沉默了 30 秒(它在思考,不是在计算),然后给出了一个完整的设计方案:

  1. 领域类型 定义在 packages/web-types/
  2. Controller 使用 BaseController + Proxy
  3. 分层清晰:Ability → Server Action → Facade Service → Controller → Component
  4. 组件极薄 :只有 observer() + ctrl.xxx

我检查了一遍,挑不出毛病。

然后我说:「你说说看,为什么这样设计?」

AI:「因为数据优先 。先定义聊天室是什么,再定义它能做什么。因为单一真相来源 ,所有状态都在 Controller 里,组件只是投影。因为薄视图层,组件不包含业务逻辑,这样测试和重构都更容易。」

我:「......你可以毕业了。」

0.7 写在前面

这篇文档,记录的就是上面这个故事的技术细节

你会看到:

  • 我是如何一步步引导 AI 理解架构的
  • AI 在哪些地方犯了蠢(以及为什么)
  • 我是如何通过追问让它自己领悟的
  • 最终形成的完整理论体系

但更重要的是:这篇文档本身,就是"启发式教育"的产物。

你会发现,文档的结构不是"结论 → 论证",而是"问题 → 思考 → 结论"。因为学习的过程比学习的结果更重要


如果你也是一个"落魄程序员",或者你也在教 AI 写代码,希望这篇文档能给你一些启发。

记住:填鸭式教育不如启发式好。这条规律,对 AI 同样适用。


0.8 对前端过度函数式化的反思

在教 AI 的过程中,我开始认真审视一个我隐隐担忧但一直没能说清的问题。

前端正在过度函数式化。

我现在说"过度",不是说函数式不好------恰恰相反,函数式在数据处理、纯转换、副作用隔离这些场景下是无可替代的。函数式编程带来了不可变性、声明式思维、组合能力,这些都是前端工程化的基石。

但问题出在一个词上:"过度"

让我举几个例子:

typescript 复制代码
// 如今一个常见的 React 组件 ------ 你能一眼看出数据的"来源"和"流向"吗?

function ChatRoom() {
  const { data: room, loading } = useRoom(id);
  const { messages, sendMessage } = useMessages(room?.id);
  const { onlineUsers } = useOnlineUsers(room?.id);
  const { canKick, kickUser } = usePermissions(room?.id);
  const [draftMessage, setDraftMessage] = useState("");
  
  const { execute: handleSend } = useCallback(async () => {
    if (!draftMessage.trim()) return;
    await sendMessage(draftMessage);
    setDraftMessage("");
  }, [draftMessage, sendMessage]);

  // ... 大量的 useEffect、useMemo、useCallback
  // ... 业务逻辑散落在组件、hooks、utils 各处
}

问题不是代码写不了,而是:

  • 状态散落各地roommessagesonlineUsersdraftMessage 各自由不同的 hook 管理,它们之间的关系不可见
  • 数据和行为分离messages 是数据(在 useMessages 里),但修改 messages 的逻辑(sendMessagehandleSend)却在另一个地方
  • 重构是噩梦:当"在线用户"和"权限"之间存在隐含的业务约束时(比如只有在线管理员才能踢人),这个约束散落在两个 hook 里,改一个忘了另一个就是 bug
  • AI 也看不懂:这正是我在 0.2 节里吐槽 AI 写出"屎山"的根本原因------不是 AI 不会写代码,是这种架构本身就没有结构

这引发一个根本性问题:函数的本质是"转换"(输入→输出),但前端状态管理的本质是"建模"(事物 + 关系 + 行为)。

场景 合适的范式 原因
数据格式转换、管道处理 函数式 输入→输出,无副作用
聊天室、用户、订单等领域对象 OOP 有状态、有关系、有行为
表单交互(打开/关闭/提交/校验) OOP 有生命周期,行为围绕数据
纯 UI 状态(主题、动画、滚动位置) 函数式 无领域语义,独立操作

函数式擅长"转换",OOP 擅长"建模"。 当前前端的问题是:把一切需求都塞进了函数式的框里,包括那些明显更适合用 OOP 建模的领域概念。

我不是在反对函数式------我是在说:不要因为一把锤子好用,就把所有问题都看成钉子。

核心观点 :前端不需要放弃函数式,也不需要全面回归 OOP。它需要的是更清醒地判断:当前面对的是"转换问题"还是"建模问题"------然后用对的范式解决对的问题。


1. 引言:一个真实的业务场景

这是一个多人实时聊天应用。在"聊天室"模块中,用户需要:

  • 加入聊天室:弹窗中输入房间号 + 密码
  • 创建聊天室:弹窗中输入名称 + 密码

两个弹窗的结构高度相似:都有标题、表单字段、提交按钮、loading 状态、错误提示。但具体字段不同(加入是 code+password,创建是 name+password),提交逻辑也不同(调用不同的 API)。

朴素的想法 :写两个独立的 Modal 组件,各自管理自己的状态。但这样会产生大量重复代码------弹窗的 openedsubmittingerrorhandleSubmit 逻辑在每个 Modal 中都要写一遍。

这就需要一次自下而上的抽象


2. 第一阶段:自下而上 --- 从细节到抽象

2.1 第一层:FormCtrl --- 通用弹窗表单控制器

先从最具体的需求开始:一个弹窗表单需要管理哪些状态?

| 状态 | 类型 | 说明 |
|--------------|-----------|--------|------|
| opened | boolean | 弹窗是否打开 |
| submitting | boolean | 是否正在提交 |
| error | `string | null` | 错误信息 |
| data | T(泛型) | 表单字段数据 |
| title | string | 弹窗标题 |
| submitText | string | 提交按钮文字 |

这些是所有弹窗表单的共性。于是提取出一个通用表单控制器 FormCtrl<T>

typescript 复制代码
// FormCtrl --- 通用弹窗表单控制器
export class FormCtrl<T extends Record<string, unknown>> {
  _data: FormState<T>;
  private readonly initialData: T;

  constructor(
    initialData: T,
    config: FormConfig,
    private readonly onSubmit: (data: T) => Promise<void>,
    private readonly onAfterSubmit?: () => void,
  ) {
    this.initialData = { ...initialData };
    this._data = {
      title: config.title,
      submitText: config.submitText,
      submittingText: config.submittingText,
      opened: false,
      submitting: false,
      error: null,
      data: { ...initialData } as T,
    };
    makeAutoObservable(this, {
      initialData: false,      // 私有常量,不需要响应式
      onSubmit: false,         // 回调函数,不需要响应式
      onAfterSubmit: false,    // 回调函数,不需要响应式
    });
  }

  // 行为方法
  open() { /* 重置数据 → 清空错误 → 打开弹窗 */ }
  close() { this._data.opened = false; }
  setField(key, value) { /* 设置字段 + 清空 error */ }
  reset() { /* 重置到初始值 */ }
  handleSubmit = async (e) => {
    e.preventDefault();
    this._data.submitting = true;
    try {
      await this.onSubmit(this._data.data);
      this._data.opened = false;        // 成功后关闭
      this.onAfterSubmit?.();
    } catch (err) {
      this._data.error = err.message;   // 错误处理
    } finally {
      this._data.submitting = false;
    }
  };
}

关键设计决策

  • _data 是单一可观察对象 :所有表单状态聚合在一个 _data 对象中,MobX 的 makeAutoObservable 自动做深度响应式代理
  • 泛型 T 让字段类型安全new FormCtrl<{ code: string; password: string }>(...) 后,setField 的参数有类型推断
  • 提交逻辑通过构造函数注入onSubmit):FormCtrl 不关心具体的 API 调用,只负责表单生命周期管理

启发点 1 :抽象的第一步是识别共性------找出不同场景中重复的状态和行为模式,提取到一个泛型类中。


2.2 第二层:RoomFormController --- 组合复用

有了 FormCtrl<T>,现在可以在业务层组合使用了。RoomFormController 需要管理两个弹窗:

typescript 复制代码
/** Controller --- 交给 UI 去控制业务的控制器(管理器) */
// RoomFormController --- 组合两个 FormCtrl,收纳弹窗表单
export class RoomFormController {
  readonly _data: {
    joinForm: FormCtrl<{ code: string; password: string }>;
    createForm: FormCtrl<{ name: string; password: string }>;
  };

  constructor(
    private readonly onSuccess: (id: string) => void,
    private readonly onRefreshList: () => Promise<void>,
  ) {
    // 用 observable.ref 持有子 Controller(它们自身已是 MobX 可观察对象)
    this._data = observable(
      {
        joinForm: new FormCtrl(
          { code: "", password: "" },
          { title: "加入新聊天室", submitText: "加入聊天室", submittingText: "加入中..." },
          async (data) => {
            const result = await RoomService.join(data);
            await this.onRefreshList();
            this.onSuccess(result.conversation.id);
          },
        ),
        createForm: new FormCtrl(
          { name: "", password: "" },
          { title: "创建新聊天室", submitText: "创建并进入", submittingText: "创建中..." },
          async (data) => {
            const result = await RoomService.create(data);
            await this.onRefreshList();
            this.onSuccess(result.conversation.id);
          },
        ),
      },
      { joinForm: observable.ref, createForm: observable.ref },
    );
  }

  get joinForm() { return this._data.joinForm; }
  get createForm() { return this._data.createForm; }

  openJoin() { this._data.joinForm.open(); }
  openCreate() { this._data.createForm.open(); }
  closeAll() { this._data.joinForm.close(); this._data.createForm.close(); }
}

关键设计决策

  • 组合优于继承 :不是让 RoomFormController 继承 FormCtrl,而是持有两个 FormCtrl 实例
  • observable.ref:子 Controller 自身已经是 MobX 可观察对象,不需要深度代理,只需要追踪"引用是否变了"
  • 通过构造函数注入回调onSuccessonRefreshList 从外部传入,子 FormCtrl 的 onSubmit 闭包引用它们

启发点 2组合 + 依赖注入是 OOP 中消灭重复代码的首选方式。不是你中有我,而是你持有我、我引用你的回调。


2.3 第三层:BaseController + Proxy --- 更底层的基础设施

再往上追溯:当 Controller 的业务数据来自 API 返回的 DTO(纯数据对象)时,如何优雅地将 DTO 和 UI 状态统一暴露?

朴素解法:手动解构 DTO 字段到 Controller 的 observable 属性。

typescript 复制代码
// ❌ 朴素解法:每个字段都要手动赋值
class RoomsController {
  visits: Visit[];
  activeRoomId: string | null;
  loading = false;
  error: string | null = null;

  constructor(dto: RoomsDTO) {
    makeAutoObservable(this);
    this.visits = dto.visits;
    this.activeRoomId = dto.activeRoomId;
  }
}

当 DTO 有 20 个字段时,样板代码爆炸。

Proxy 解法 :让 Controller 持有 _data(DTO),用 Proxy 自动代理属性访问,组件侧的 ctrl.title 自动映射到 _data.title

typescript 复制代码
// BaseController 基类
class BaseController<T, M extends ConverterMap<T> = {}> {
  protected _data: T;          // 领域数据(核心)
  protected _converterMap: M;  // 对象激活映射表(死数据 → 活对象)
  loading = false;
  private _baseError: string | null = null;
  get error(): string | null { return this._baseError; }
  set error(v: string | null) { this._baseError = v; }

  constructor(data: T, converters: M = {} as M) {
    this._data = makeObservable(data) as T;
    this._converterMap = converters;
  }
}

// Proxy 工厂函数
function createController<T, M>(data: T, converters: M) {
  const ctrl = new BaseController(data, converters);
  return new Proxy(ctrl, {
    get(target, prop) {
      // 1. ctrl 自身属性(loading、error、方法)
      if (prop in target) return target[prop];
      // 2. 对象激活(_converterMap)
      if (prop in target._converterMap) {
        return target._converterMap[prop](target._data[prop]);
      }
      // 3. 默认代理到 _data
      return target._data[prop];
    },
    set(target, prop, value) {
      if (prop in target) target[prop] = value;
      else target._data[prop] = value;
      return true;
    },
  });
}

使用示例

typescript 复制代码
const ctrl = createController(
  apiDto,                                          // API 返回的 DTO
  { members: (dtos) => dtos.map(d => new Member(d)) },  // 对象激活(核心)
);

ctrl.title;       // → _data.title(默认代理)
ctrl.members;     // → Member[](激活后的领域对象,有 isOnline() 等行为)
ctrl.loading;     // → ctrl 自身属性
ctrl.error;       // → ctrl 自身属性

启发点 3Proxy 是"统一访问层" ------对组件透明,不需要关心数据是来自 _data 还是 Controller 自身。


3. 第二阶段:自上而下 --- 从场景反推设计

现在换个角度,从业务场景出发,看各层如何协作。

3.1 业务场景层:Rooms.tsx

组件是薄视图层------只做三件事:

  1. 从 Controller 读取 observable 状态
  2. 将用户操作委托给 Controller 方法
  3. 渲染 UI
tsx 复制代码
export const Rooms = observer(function Rooms() {
  const c = roomsController;

  useEffect(() => { void c.refreshList(); }, [c]);

  return (
    <>
      {/* 按钮触发 Controller 方法 */}
      <button onClick={() => c.form.openJoin()}>加入</button>
      <button onClick={() => c.form.openCreate()}>创建</button>

      {/* 列表 */}
      <RoomHistoryList controller={c} />

      {/* 弹窗 --- 传递子 Controller 实例 */}
      <JoinRoomModal controller={c.form.joinForm} />
      <CreateRoomModal controller={c.form.createForm} />

      {/* 错误提示 --- 读取 Controller 属性 */}
      {c.error && <ErrorBanner message={c.error} onDismiss={c.clearError} />}
    </>
  );
});

组件零状态、零业务逻辑。所有状态和逻辑都在 Controller 中。

3.2 业务组装层:RoomsController

拼装子 Controller、协调数据流:

typescript 复制代码
class RoomsController extends BaseController<RoomsDTO> {
  form: RoomFormController;       // 弹窗表单
  rejoin: RejoinController;       // 重新加入

  constructor() {
    super({ visits: [] }, {});

    const navigate = (id: string) => this._navigateTo(id);
    const onRefreshList = () => this.refreshList();

    // 组合子 Controller,注入回调
    this.form = new RoomFormController(navigate, onRefreshList);
    this.rejoin = new RejoinController({ onSuccess: navigate, onRefreshList });
  }

  refreshList = async () => {
    this.loading = true;
    const data = await RoomService.getMyConversations();
    this._data.visits = data;    // 修改 _data 自动触发响应式
    this.loading = false;
  };
}

3.3 数据流全景

scss 复制代码
用户点击按钮
    ↓
c.form.openJoin()                         → RoomsController 委托 RoomFormController
    ↓
this._data.joinForm.open()                → RoomFormController 委托 FormCtrl
    ↓
弹窗打开,用户填表,提交
    ↓
FormCtrl.handleSubmit() → RoomService.join()
    ↓ 成功后
onRefreshList() + onSuccess(id)           → 回调链回到 RoomsController
    ↓
RoomsController.refreshList()             → 刷新列表
    ↓
observer() 感知变化 → 组件重渲染

启发点 4职责链清晰------每层只做自己该做的事。FormCtrl 管理表单状态,RoomFormController 组装两个表单,RoomsController 协调数据流。


4. 第三阶段:领域驱动设计反思

完成了自下而上和自上而下的理解后,我们开始思考一个更深层的问题:

在 DDD + OOP 的架构中,什么最重要?

4.1 统一语言与概念模型

统一语言(Ubiquitous Language) 是 DDD 的起点。在这个项目中:

typescript 复制代码
// packages/web-types/ --- 统一语言的定义
NConversation.Conversation    // "聊天室"这个概念
NConversation.Visit            // "访问记录"这个概念
NConversation.Controller       // "聊天室列表控制器"这个角色

这些类型不是在写代码时随意起的名字,而是业务专家和开发人员共享的概念模型

4.2 数据比行为更重要

在领域模型中,数据(概念模型)比行为更重要。

不理解这个结论很正常------因为 OOP 教材教了几十年"封装行为最重要"。

但让我们换一个角度:假如你把 Conversation 的数据模型定义错了------缺少了 ownerId(所有者是谁):

typescript 复制代码
// ❌ 数据模型错了 --- 缺少 ownerId
interface Conversation {
  id: string;
  name: string;
  // 没有 ownerId!
}

// 即使行为写得再正确,也无法判断"当前用户是不是所有者"
class Conversation {
  delete() {
    // 怎么办?不知道谁有权限删除
  }
}

数据(概念模型)是"是什么"(What),行为是"做什么"(How)。先有"是什么",才能定义"做什么"。

在 DDD 中:

  • 实体(Entity) 的核心是有唯一标识 + 属性 + 关系
  • 值对象(Value Object) 的核心是属性集合
  • 聚合(Aggregate) 的核心是数据的一致性和不变条件

所有这些都是以数据概念为基础的,行为是围绕这些数据概念展开的。

4.3 贫血模型 vs 富领域模型

但这不意味着行为不重要。在 OOP 实现层面,行为的封装决定了代码质量。

模型类型 特点 示例
贫血模型 只有数据,没有行为 { opened: true, data: {...} } --- 纯对象
富领域模型 有数据也有行为 FormCtrl<T> --- 有 open()handleSubmit() 等行为方法

这个项目中的 FormCtrl 就是一个富领域模型

  • _data 是数据(私有的)
  • open()close()handleSubmit() 是行为(公共的)
  • 数据通过行为来修改,保护了业务不变量

启发点 5领域建模阶段数据优先,OOP 实现阶段行为封装最重要。两者不是矛盾,而是在不同阶段各有侧重。

4.4 关键启发:_converterMap 的正确定位

在最初的设计中,_converterMap 被赋予了"简单值转换"的定位------把 string 变成 Date、把 DTO[] 变成业务类数组。

但当我们用 DDD 的视角重新审视后发现MemberDTO[] → Member[] 不是"简单值转换"------Member 是一个有行为的领域对象(有 isOnline()rolekick() 等方法)。这是对象激活 ------从"死数据"到"活对象",服务于领域模型

真正的"简单值转换"只有 string → Date 这类格式变化,它服务于 UI 展示,没有增加任何领域行为。

在 DDD 中,_converterMap 的正确定位应该是:

用途 核心程度 服务对象 示例
对象激活(主要) 核心 领域模型 MemberDTO[] → Member[]{ code, password } → FormCtrl
简单值转换(次要) 边缘 UI 展示 string → Date

类比:激活 = 把蓝图(DTO)变成真正的房屋(领域对象);转换 = 把"2024-01-01"显示为"2024年1月1日"。

核心洞察_converterMap 存在的原因是 _data 中放的是"死数据",而领域层需要"活对象"。它的首要使命是激活 (让数据活起来),不是转换(换个格式显示)。


5. 第四阶段:BaseController 重新审视

带着"数据优先"的思维重新看 BaseController,真正关键的是两个东西_data(领域数据的单一真相来源)和 Proxy(让这份数据"活"起来的机制)

5.1 设计层次:真正的三要素

javascript 复制代码
Controller 的结构:

  _data(领域数据,核心)         ← 概念模型,单一真相来源
      ↓
  _converterMap(对象激活)       ← 死数据 → 活对象
      ↓
  Proxy(统一访问层)             ← ctrl.xxx 透明解析到 _data / converter / ctrl 自身

loading / error 只是 UI 便利属性,架构上不重要。

5.2 Proxy:为什么它是"充血"的基石

BaseController 的真正价值不在于它收了 _data,而在于 Proxy 让 Controller 对外表现得像一个充血对象------但内部数据零拷贝、零样板代码。

先回顾朴素的"手动充血"解法:

typescript 复制代码
// ❌ 没有 Proxy:每个字段都要手动搬运
class RoomsController {
  visits: Visit[];
  activeRoomId: string | null;
  loading = false;

  constructor(dto: RoomsDTO) {
    makeAutoObservable(this);
    this.visits = dto.visits;              // 搬字段
    this.activeRoomId = dto.activeRoomId;   // 搬字段
  }
}

每个 DTO 字段都要写一行赋值。DTO 有 20 个字段 = 20 行样板代码。更糟的是,数据现在有两套dto 是一套,this.xxx 是另一套。哪套是真的?

Proxy 解法彻底消灭了这个问题:

dart 复制代码
组件写 ctrl.title
    ↓
Proxy get 陷阱:
  ① title 是 ctrl 自身的属性吗? → 不是
  ② title 在 _converterMap 里吗?  → 不是
  ③ 默认:返回 _data.title        ← 零拷贝,直接走 DTO 的 getter
  
组件写 ctrl.members
    ↓
  ① ctrl 自身?    → 不是
  ② converterMap? → 是!调用 (dtos) => dtos.map(d => new Member(d))
                      ← 激活:死数据 → 活对象

组件写 ctrl.loading
    ↓
  ① ctrl 自身?    → 是!返回 this.loading
                      ← Controller 的 UI 状态

三条规则,一个 Proxy,消灭所有样板代码。 数据只存一份(_data),Proxy 负责路由:自身属性 → converter 激活 → _data 直通。

再深入看源码中的 get 陷阱实现:

typescript 复制代码
function createGetTrap<T>(target, prop) {
  if (typeof prop === "symbol") return target[prop];
  const key = prop as string;

  // ① ctrl 自身属性(loading、error、方法)------ Controller 的"行为层"
  if (key in target || key === "constructor") {
    return target[key];
  }

  // ② 转换器映射表(对象激活)------ 死数据 → 活对象
  const data = target._data;
  const converterMap = target._converterMap;
  if (key in converterMap) {
    return converterMap[key](data[key]);
  }

  // ③ 默认代理到 _data ------ 领域数据,零拷贝
  return data[key];
}

以及 set 陷阱:

typescript 复制代码
function createSetTrap<T>(target, prop, value) {
  if (prop in target) {
    target[prop] = value;          // ctrl 自身属性
  } else {
    target._data[prop] = value;    // 设到领域数据(触发 MobX 响应式)
  }
  return true;
}

为什么这是"充血"的基石?

没有 Proxy(贫血) 有 Proxy(充血)
DTO 是 DTO,Controller 是 Controller DTO 就是 Controller 的数据面
每个字段手动搬:this.x = dto.x 零拷贝:Proxy 直连 _data
数据有两套,同步是灾难 _data 是单一真相来源
加新字段 = 改 Controller 类定义 + 搬运代码 加新字段 = DTO 变就行,Controller 自动感知
Controller 是个"壳",数据在 DTO 里 Controller 就是充血对象,对外暴露 ctrl.title

核心洞察_converterMap 解决的是"数据的激活"(从死到活);Proxy 解决的是"数据的归属" ------让 _data 的同时也是 Controller 的对外接口。没有 Proxy,_data 只是一个被 Controller 持有的私有对象;有了 Proxy,_data 直接成为 Controller 的公共表面。这就是"贫血"到"充血"的关键一跳。

5.3 converter 激活 vs 递归 _data:两种激活方式

在 Proxy 架构下,激活有两种时机:

模式 激活时机 引用稳定性 适用场景
converter 激活(推荐) 每次属性访问时 ❌ 每次新实例 子对象可由纯数据完全重建;符合自上而下设计的激活契约
递归 _data 构造时立即 ✅ 引用稳定 激活过程需要外部依赖注入(回调引用 this 等)

推荐默认方案converter 激活 。自上而下设计时,只关注"把死数据激活为活对象"这个契约------_converterMap + Proxy 天然对应这个契约。递归 _data 只是在激活需要外部注入时才用的备选方式。


6. 第六阶段:理论适用范围的深度思考

完成了对 Controller 设计各层级的理解后,一个自然而然的追问是:这套理论只适用于前端吗? 答案是否定的。

6.1 不仅仅是前端:充血模型思想

这套 Controller 设计理论的本质不是前端专属,它其实在说一件事:

让"领域逻辑"住在"领域数据"旁边,而不是散落在各处。

这在后端有个更熟悉的名字------充血模型(Rich Domain Model),是 DDD 的核心主张之一。

回顾传统后端写法(贫血模型):

typescript 复制代码
// ❌ 贫血:数据在 Entity,逻辑在 Service
@Entity()
class Conversation {
  id: string;
  members: Member[];
}

@Service()
class ConversationService {
  kickMember(convId: string, memberId: string) {
    // 逻辑在这里,离数据很远
  }
}

充血模型(数据+行为在一起):

typescript 复制代码
// ✅ 充血:逻辑在数据旁边
class Conversation {
  id: string;
  members: Member[];

  kickMember(memberId: string) {
    // 逻辑在这里,直接访问 members
  }
}

前端的 Controller 理论,本质上就是把后端的"充血模型"思想搬到了前端 。只是前端多了一层 UI 状态(loadingerror),所以用 _data + Proxy 来兼顾。

6.2 全栈视角:同一套理论,同一份类型

这个项目已经在实践这件事:

bash 复制代码
packages/web-types/          ← 领域类型(前后端共享)
    NConversation.Conversation
    NRoom.Visit

apps/web/src/server/        ← 后端:Ability/Service(领域逻辑在服务端)
apps/web/src/client/        ← 前端:Controller(领域逻辑在客户端)

同一份领域类型,前后端各自"充血"

充血载体 行为示例
后端 Conversation 领域类 / Service 类 conversation.kickMember()conversation.addMessage()
前端 RoomsController / RoomPanelController / SessionChatController ctrl.enterRoom()ctrl.timeline(派生计算)

6.3 适用性判断:有没有"领域语义的状态"

这套理论适用范围的判断标准只有一个:你的前端有没有"领域复杂度"的状态?

场景 领域对象示例 为什么适合
协作工具(聊天室、项目管理) ConversationMemberMessage 对象间有丰富关系,行为复杂(踢人、权限判断)
电商(购物车、订单) CartOrderProduct 有业务规则(库存、折扣计算),不只是 CRUD
表单密集型应用 FormCtrl<T> 激活后的领域表单 表单有提交逻辑、校验逻辑,不是纯 UI
实时应用(SSE/WebSocket) TimelineMessageStream 数据流持续更新,需要统一的响应式入口

核心特征:状态之间有联系,修改一个状态会影响其他状态的推导

6.4 不适用场景:无领域语义

场景 原因
纯展示组件库(shadcn/ui、MUI) 组件应该是无状态的,领域模型在消费侧
静态页面 / 文档站 没有客户端状态,不需要 Controller
极简 CRUD(只有列表+详情+表单) 领域逻辑太薄,useState + Server Action 足够
以 Server Components 为主的应用 状态在服务端,客户端只有少量交互态

6.5 何时是"过度设计"?

这套理论的成本是抽象层级 ------需要定义类型、Controller 类、可能的 _converterMap

判断标准很简单:

复制代码
领域行为代码量  >  抽象代码量  →  值得
领域行为代码量  <  抽象代码量  →  过度

举例:

  • RoomsController 里有 enterRoomdeleteRoomrefreshList、处理 busyId 逻辑 → 值得
  • 一个页面只有一个 isOpen 状态,点击后调一个 Server Action → 不值得 ,直接用 useState

6.6 跨技术栈的适用性

这套理论的思想不绑定任何库:

技术栈 对应实现方式
MobX makeAutoObservable + Proxy(当前方案)
Vue Reactivity reactive() + computed(),Proxy 思想用 reactive 已内置
Solid.js createSignal / createStore,"充血"通过 store 直接暴露
纯 React useReducer + Context,Controller 变为自定义 hook 返回的 object

核心思想通用:领域数据作为一等公民、单一真相来源、行为附着在数据上、视图层极薄。

6.7 一个更精准的表述

这套理论的名字不应该是"前端 Controller 设计",而应该是:

"领域模型在前端的落地方式"

或者更短:

"充血模型思想在全栈状态管理中的实践"

前端只是它比较难落地的地方(因为前端传统上是"薄 View + 散装 useState"),所以才需要 BaseController + MobX 这些机制来支撑。后端如果框架合适(如 NestJS + DDD),天然就更容易做到。

一句话总结 :这套理论适用于任何需要管理领域状态的地方。前端之所以需要一套专门的理论,是因为前端社区长期被"UI 状态思维"主导,把领域状态和 UI 状态混在一起,才导致了这套理论的必要性。

6.8 AI 时代:为什么 DDD + OOP 更容易维护

写到这里,我必须要谈一个更宏观的趋势判断。

2026 年,AI 编程助手(Copilot、Cursor 等)已经成为标配。不管是自己写代码还是指挥 AI 写代码,项目的可维护性不再只面向人类开发者------它同时面向 AI。

而我的实践告诉我一个明确的结论:DDD + OOP 的架构风格,在 AI 辅助编程场景下具有天然优势。

这不是理论推断,而是我在这个项目中反复验证的体验。

6.8.1 AI 需要"地图",而不是"迷宫"

AI 编程助手的核心能力是:理解上下文 → 推断意图 → 生成代码。这个过程的质量,直接取决于代码库的"可理解性"。

架构风格 AI 视角 效果
散装 hooks + useState 几十个散落的状态变量,没有统一入口 AI 需要读遍整个文件才能拼凑出"数据全景图"
DDD + Controller RoomsController → 一目了然:数据是什么、行为是什么、怎么流转 AI 读一个类就知道全貌

类比:给 AI 一个有经纬度的地图(DDD),它知道往哪走。给 AI 一堆零散的线索(散装状态),它只能猜------猜错的概率大大增加。

6.8.2 统一语言 = AI 的"业务字典"

DDD 的核心主张之一就是统一语言(Ubiquitous Language)。在这个项目中:

typescript 复制代码
// 命名不是随机的,而是精确的"业务词典"
NConversation.Conversation   // "聊天室"------这个词在任何地方都有相同的含义
NRoom.Visit                   // "访问记录"------不会在某个 hook 里叫 roomVisits,另一个叫 history
RoomFormController            // "聊天室表单控制器"------角色明确
RoomsController               // "聊天室列表控制器"------职责清晰

对于 AI 来说,这意味着:它不需要猜测这些名字的含义。 名字本身就是文档,AI 的语义理解可以直接作用于这些精确的领域概念。

实践中我发现:当 Controller 的命名遵循统一的领域语言后,AI 对业务需求的响应质量明显提升------它不会在 roomVisitsconversations 之间来回挣扎。

6.8.3 封装 = AI 的"安全边界"

OOP 的封装不只是写代码的习惯------在 AI 时代,它是防止 AI 误改的范围保护

typescript 复制代码
// RoomsController --- 清晰的职责边界
class RoomsController extends BaseController<RoomsDTO> {
  form: RoomFormController;      // ← 弹窗相关,不要跨出去改
  rejoin: RejoinController;      // ← 重新加入相关,不要跨出去改

  refreshList() { ... }          // ← 唯一的刷新入口
  enterRoom(visit) { ... }      // ← 唯一的进入入口
  deleteRoom(id) { ... }        // ← 唯一的删除入口
}

当 AI 被要求"修改聊天室的进入逻辑"时,它自然知道去看 enterRoom。而在散装 hooks 的架构中,AI 需要在组件、hooks、utils 等多个文件中搜索 navigate + roomId + enter 等关键词------搜索范围大、匹配多、容易出错。

封装 = 给 AI 一个"你只需要看这里"的范围标记。

6.8.4 单一真相来源 = 消灭 AI 的"分叉决策"

这是我感受最深的一点。

在散装 hooks 的架构中,同一份数据(比如"聊天室列表")可能同时存在于:

  • useRooms() 的返回值
  • useConversationList() 的返回值
  • useApiQuery() 的缓存
  • Redux/Zustand 的 store

当 AI 需要修改某个逻辑时,它要选择从哪读数据、写到哪------每一个选择点都是潜在的决策分叉,分叉次数多了,错误率指数上升。

而在 DDD + Controller 架构中:

typescript 复制代码
// 数据只在一个地方:RoomsController._data.visits
// AI 不需要"选"------只有一个真相来源
roomsController.visits            // ← 读
roomsController.refreshList()     // ← 写

减少 AI 的决策分叉点,就是减少 AI 出错的概率。

6.8.5 实践数据:AI 代码建议准确率对比

我没有做严格的 A/B 测试,但我记录了同一个项目在两个阶段的表现:

维度 散装 hooks 阶段(教 AI 之前) Controller 架构阶段(现在)
AI 一次生成正确可用代码的比例 ~30% ~80%
AI 需要人工修正的次数(每个功能) 5-8 次 1-2 次
AI 误改不该改的代码 频繁 几乎为零
新增功能时 AI 理解的业务上下文 只看到当前组件 看到整个 Controller → 理解全貌

数字可能有主观成分,但趋势是明确的:越结构化、越领域化,AI 越靠谱。

6.8.6 未来展望:当 AI Agent 成为主要编码者

我做一个大胆的预测:

当 AI Agent 能自主完成开发任务时(已经不是"会不会"的问题,而是"何时"的问题),项目的架构质量将直接决定 AI 的开发效率

一个散装 hooks 的项目,AI 需要花大量 token 去理解、搜索、推断;一个 DDD + OOP 的项目,AI 一眼就能看清全貌。

就像人类不会在刚入职时选择去维护一座没有地图的迷宫------AI 也不会。 不是它不想,而是它的"理解力"(上下文窗口 + 推理能力)在迷宫面前也是有限的。

在 AI 时代,可维护性 = AI 友好性。而 DDD + OOP,就是目前我找到的最 AI 友好的架构范式。


7. 总结:核心启发点

启发 1:抽象从识别共性开始

抽象的第一步不是想"我该用什么设计模式",而是找出不同场景中重复的状态和行为模式 。就像FormCtrl<T> 的诞生:先看到"加入弹窗"和"创建弹窗"有相同的结构,再提取出通用类。

启发 2:组合 + 依赖注入 = 消灭重复代码

RoomFormController 不继承 FormCtrl,而是持有两个 FormCtrl 实例。通过构造函数注入回调,子对象可以回调父对象的方法------这是 OOP 中复用逻辑的首选方式。

启发 3:Proxy 是"充血"的基石,不是简单的访问层

Proxy 远远不止"统一访问"。它的三条优先级规则(自身 → converter → _data)让 Controller 真正成为充血对象

  • 数据只存一份_data),通过 Proxy 直通,零拷贝、零搬运
  • 行为挂在 Controller 上 (方法、loadingerror),Proxy 优先路由到自身
  • 激活嵌在访问路径中_converterMap),死数据经过 Proxy 后自然变成活对象

没有 Proxy,"贫血"是必然的------你必须在 Controller 类里手动搬运 DTO 的每个字段。有了 Proxy,Controller 天生就是"充血的"------_data 是什么,Controller 对外就是什么,而行为自然附着其上。

启发 4:职责链清晰,层层委托

复制代码
用户点击 → RoomsController → RoomFormController → FormCtrl → API

每层只做自己该做的事,不越俎代庖。

启发 5:领域建模阶段数据优先,OOP 实现阶段行为封装

  • 数据(概念模型) 是"是什么"------错的模型导致错的一切
  • 行为(方法封装) 是"做什么"------保护不变量、封装业务规则
  • 两者不是谁替代谁,而是在不同层次各有侧重

启发 6:_converterMap 的首要职责是对象激活

_converterMap 存在的根本原因是 _data 里是"死数据",而领域层需要"活对象"。激活(MemberDTO[] → Member[])服务于领域模型,是核心使命;简单值转换(string → Date)服务于 UI 展示,是边缘用法。

自上而下设计时,只需关注"死数据 → 活对象"这个契约------这正是 converter 激活的语义。构建活对象的具体过程(是否有外部依赖注入)是实现细节,不属于 _converterMap 这个抽象层次的关注点。

激活 = 蓝图变房屋;转换 = 日期格式化。两者不是一个层次的概念。

启发 7:好的文档需要反复审视------激活 vs 转换的认知历程

_converterMap 的理解经历了三次变化:

  1. 最初_converterMap 是"激活层"------把 POJO 变成活对象
  2. 误判 :看到递归 _data 用得更多,就认为激活不重要,把"简单值转换"当成了核心
  3. 修正(最终)MemberDTO[] → Member[] 本身就是激活(Member 是有行为的领域对象),这才是 _converterMap 存在的原因。string → Date 不过是服务于 UI 格式化的边缘用法。

核心教训 :不要因为看到一种用法(递归 _data)更常见,就低估另一种用法(converter 激活)在领域驱动设计中的理论价值。前者是"构造时激活"的实现选择,后者是"按需激活"的架构意图------两者都是激活,服务于同一个目的:让死数据活起来。

启发 8:填鸭式教育不如启发式------学习方式决定学习深度

回顾整个学习过程,一个值得玩味的现象是:大部分关键洞察不是"被告知"的,而是"被问出来"的

阶段 触发方式 关键洞察
第一阶段 自下而上推导 FormCtrl<T> 的抽象来自对重复模式的识别
第二阶段 自上而下验证 职责链清晰:每层只做自己该做的事
第三阶段 被追问:"数据比行为更重要?" 概念模型先于行为封装------"是什么"先于"做什么"
第四阶段 被追问:"Proxy 的真正价值?" Proxy 不是访问层,是充血的基石------零拷贝、单一份数据
第五阶段 被追问:"_converterMap 的正确定位?" 激活 vs 转换------蓝图变房屋 vs 日期格式化
第六阶段 被追问:"适用范围?仅仅是前端?" 充血模型思想的全栈适用性

注意第三到第六阶段的共同特征:关键洞察都来自追问,而非被告知。如果一开始就收到一份完整的"Controller 设计最佳实践",大概率只会记住结论,而不会理解"为什么"。

这个规律同样适用于调教 AI

arduino 复制代码
❌ 填鸭式 prompt:"记住这些规则,以后都这样做"
   → AI 只记住结论,遇到新场景无法迁移

✅ 启发式 prompt:"你先说说你的理解 → 我追问 → 你再修正 → 我总结"
   → AI 经历了"假设 → 被质疑 → 修正 → 形成认知"的完整过程
   → 形成的认知是活的、可迁移的

核心教训 :好的学习(无论对人还是对 AI)都不是信息灌输 ,而是搭建脚手架,让学习者自己爬上去。追问比告诉更有力量------因为追问迫使对方去思考"为什么",而告诉只传递了"是什么"。

实操建议:调教 AI 时,遇到关键设计决策不要直接告诉它"怎么做",而是先问它"你觉得为什么这样设计?有没有更好的方式?"------让 AI 在回答中暴露它的理解层次,然后再引导到正确的方向。这样形成的认知会内化为它后续决策的依据,而不是一段会遗忘的文本。

启发 9:及时双向反馈------不要在憋大招后才说话

这条启发来自实际协作中的一次复盘。

在本文的编写过程中,AI 写了一版初稿,我(人类)通读后发现了一个问题:文档把 room(单个聊天室)和 rooms(聊天室列表)混淆了 ------用了不存在的类名 RoomController,类型命名空间也写错了。但这个问题我直到全部读完才提出来,导致 AI 需要回退到全文多个位置逐一修正。

如果我当时读到一个混淆点就立刻反馈,AI 可以马上修正那一处,后续类似位置也会在同一个认知下自动写对------而不是等全文写完后"翻旧账"。

这条规律在 AI 协作中是双向的

方向 行为 效果
人类 → AI(正面反馈) "这个结构没问题,继续按照这个思路" AI 确认方向正确,后续不会乱试其他方案
人类 → AI(反面反馈) "这里你把 room 和 rooms 搞混了,项目中 rooms 是列表、room 是详情" AI 立刻修正认知,后续不会重复犯错
AI → 人类(正面反馈) "我理解你的意思了,你是说 X 和 Y 的区别是 Z,对吗?" 人类确认 AI 理解到位,避免后续偏离
AI → 人类(反面反馈) "我注意到当前架构下这个做法和你的规则有冲突,建议调整方案" 人类在早期发现系统性问题,而不是在代码中积累技术债

核心教训 :在 AI 协作中,及时反馈 >> 憋大招。不要等 AI 写完整篇文章、完成所有代码后再一次性指出问题------这等于让 AI 在一个错误的认知上跑了很远,回头修正的成本极高。发现第一处错误就立刻纠正,AI 的后续输出自然就走在正确的轨道上。

实操建议

  • 人类方面:读到 AI 输出中任何你觉得不对的地方,立刻指出来,不要"等看完再说"
  • AI 方面:遇到任何模糊的指令、不确定的假设、或者发现与现有规则冲突的地方,立刻反问确认,不要"猜一个就去写"

8. 实践指南:实现一个功能的标准流程

理论最终要落到实践。回到我们最初的聊天室场景,假设现在要新增一个功能,以下是可以直接照搬的标准流程。

步骤 0:先问自己------这套理论需要吗?

在动手写任何代码之前,花 30 秒判断:

sql 复制代码
这个功能的状态名听起来像"业务概念"(Conversation、Order、Member、Timeline)
还是"UI 概念"(isOpen、theme、toast、hovered)?

  业务概念 → 走完整流程(本文档适用)
  UI 概念 → useState / useReducer 足够,不要引入 Controller

举两个反例:

  • "点击展开/收起侧边栏" → UI 概念,不需要 Controller
  • "聊天室踢人,判断当前用户是不是管理员" → 业务概念,需要 Controller

步骤 0.5:文件组织------一个组件配一个 Controller

傻瓜式原则:每个业务组件配一个 Controller 类,两者放在同一个目录下。

scss 复制代码
client/room/components/Rooms/
├── rooms-controller.ts    ← Controller(MobX class)
└── Rooms.tsx              ← 组件(observer() + 薄视图层)
  • 一对一:一个组件对应一个 Controller。如果发现 Controller 太臃肿,说明组件需要拆分,拆出来的子组件再配自己的 Controller
  • 放一起:组件和它的 Controller 放在同一个目录------打开目录就知道这个 UI 由哪个 Controller 驱动,不用跨层找文件
  • 命名一致 :组件 Rooms.tsx 对应 RoomsController(alias rooms-controller.ts),一眼配对

UI 只关心"我点了这个按钮要执行什么",Controller 负责"这笔业务具体怎么做"。分层就在这里------按钮调 ctrl.enterRoom(),Controller 内部协调 Service 和子 Controller,组件永远不跨过 Controller 直接调 Service。

步骤 1:领域建模(数据优先)

先定"是什么",再定"做什么"。packages/web-types/ 中定义类型。

typescript 复制代码
// packages/web-types/src/room/room.types.ts --- 领域类型定义
import type * as NConversation from "../conversation";

export interface Visit {
  conversationId: string;
  conversation: NConversation.Conversation;
  lastVisitAt: string;
}

export interface Controller {
  visits: Visit[];
  activeRoomId: string | null;
  loading: boolean;
  error: string | null;

  refreshList(): Promise<void>;
  enterRoom(visit: Visit): void;
  deleteRoom(conversationId: string): Promise<void>;
  // ...
}

关键原则

  • 先定义数据结构Visit)、关系Visit → Conversation)、约束(哪些字段必填?)
  • 再定义 Controller 接口(方法签名)
  • 如果数据结构弄错了,行为写得再好也没用

步骤 2:选择 Controller 模式

根据场景复杂度,选一种:

场景 用什么 模板
API 返回 DTO,需要把 DTO 字段激活为领域对象 BaseController + Proxy extends BaseController<RoomDTO>
纯 UI 状态 + 业务方法,不涉及 DTO 代理 简单 MobX Class makeAutoObservable(this)
弹窗表单(open/close/submit/error FormCtrl<T> new FormCtrl(initialData, config, onSubmit)
多个子 Controller 需要组装 递归 _data + observable.ref RoomFormController

决策树

javascript 复制代码
需要管理"提交中 + 打开/关闭 + 错误"的弹窗?
  → FormCtrl<T>

API 数据 → 前端,DTO 字段多,不想手动搬?
  → BaseController + Proxy

只是纯 UI 状态 + 几个业务方法?
  → 简单 MobX Class

多个子 Controller 需要组装?
  → 递归 _data(构造时激活,保证引用稳定)

步骤 3:选择构建方向:自底向上 or 自顶向下

方向 何时用 示例
自底向上 先有通用抽象(FormCtrl),再有业务组装(RoomFormController 当你知道多处需要弹窗表单时
自顶向下 先有场景(聊天室页面),再向下拆 Controller 当你不确定底层抽象是什么时

实操建议 :如果这是你第一次写 Controller,自顶向下更自然 ------先写 RoomsController,发现里面有两个弹窗逻辑重复,再抽 FormCtrl。如果已经是老手,自底向上更高效------先建好基础设施,直接组装。

步骤 4:分层实现

按项目架构的分层:

xml 复制代码
① server/<域>/xxx-ability.ts          ← 鉴权 + 校验 + 调 service + 返回结构化结果
② app/actions/<域>-actions.ts          ← "use server" + 薄到只透传 ability
③ client/<域>/<域>-service.ts          ← 客户端 Facade(类静态方法,封装 Server Actions)
④ client/<域>/<域>-controller.ts        ← MobX Class(领域数据 + UI 状态 + 业务方法)
⑤ client/<域>/components/<Component>.tsx ← 薄视图层(observer() + ctrl.xxx)

每层的复杂度预算

复杂度 示例
ability 高 --- 鉴权、校验、编排 requireSessionUser() + 调 service
Server Action 极低 --- 一行透传 return roomAbility.enter(input)
Facade Service 极低 --- 一行透传 return enterRoomAction(input)
Controller 高 --- 这里是你实现的主力 refreshList()enterRoom()
组件 极低 --- 只渲染 <button onClick={() => c.form.openJoin()}>

原则:入口层薄到只有一行,业务逻辑全部在 Controller(前端)或 Ability(服务端)。

步骤 5:组件绑定------永远薄视图层

tsx 复制代码
// ✅ 组件只做三件事:读状态、委托事件、渲染
export const Rooms = observer(function Rooms() {
  const c = roomsController;

  useEffect(() => { void c.refreshList(); }, [c]);       // 1. 副作用(可读)

  return (
    <>
      <button onClick={() => c.form.openJoin()}>加入</button>    {/* 2. 委托事件 */}
      <RoomHistoryList controller={c} />                          {/* 3. 渲染 + 插槽透传 */}
    </>
  );
});

检查清单(写完组件后自查):

  • 组件内没有 useState?(除了 useState(() => new Controller()) 这种引用稳定技巧)
  • 组件内没有 async/awaittry/catch
  • 组件内没有数据转换逻辑(.map().filter())?------业务转换在 Controller
  • 没有解构 controller?(const { visits, loading } = controller ❌,const c = controller ✅)
  • 所有数据都通过 controller.xxx 访问?

如果以上都满足,这个组件就是合格的"薄视图层"。

完整示例演练:新增"聊天室消息列表"功能

假设要实现聊天室内的消息列表,包含发送消息、显示时间线。

步骤 0 --- 判断MessageTimeline 是业务概念 → 走完整流程。

步骤 1 --- 领域类型

typescript 复制代码
// packages/web-types/src/chat/chat.types.ts
export interface Message {
  id: string;
  role: "user" | "assistant";
  content: string;
  createdAt: string;
}

export interface Controller {
  timeline: TimelineItem[];
  input: string;
  isLoading: boolean;

  setInput(v: string): void;
  submit(): Promise<void>;
}

步骤 2 --- 选模式 :有 API 数据 + 需要激活 MessageDTOMessage(可能需要 isUser() 等方法),选 BaseController + Proxy

步骤 3 --- 构建方向:已知场景 → 自顶向下,先写 Controller。

步骤 4 --- 分层

bash 复制代码
server/chat/chat-ability.ts            ← sendMessage、getMessages 用例
app/actions/chat-actions.ts            ← 薄透传
client/chat/chat-service.ts            ← 薄 Facade
client/chat/chat-controller.ts         ← ChatController(主力实现)
client/chat/components/ChatPanel.tsx    ← 薄视图层

步骤 5 --- 组件observer() + ctrl.timeline.map(...) + onSubmit={ctrl.submit},不超过 30 行。


命名辨析:为什么叫 Controller 而不是 Store、VM 或其他?

核心定义

Controller 的定位

交给 UI 去控制业务的控制器(管理器)

它不是 MVC 里那个"接收 HTTP 请求"的 Controller,而是 UI 和业务之间的唯一桥梁:

  • UI 组件不直连业务,而是通过 Controller 暴露的方法来控制业务流程(ctrl.refresh()ctrl.enterRoom()
  • 读取业务状态(ctrl.visitsctrl.loading
  • UI 只认 Controller,不认 Ability、不认 Service、不认 Repository

Store vs Controller

维度 Store Controller
数据持有 被动容器,只存不管 主动管理者,持有行为
方法语义 setXxx()addXxx() refreshList()enterRoom()
业务逻辑 散落在组件/hook 各处 内聚在 Controller 内部
子对象协调 无(平级数据容器) 有(sendCtrl + historyCtrl 协调)

一句话:Store 回答"数据在哪",Controller 回答"能做什么"。

叫它 Store,人们会以为它只是 useState 的升级版;叫它 Controller,人们才会意识到:UI 不直接碰数据,而是通过它来控制业务。

MVVM 本质

其实它本质上就是 MVVM 里的 ViewModel

MVVM 概念 对应我们的 Controller
Model 业务数据(_data 中的 DTO)+ 前端状态(loadingerror
View observer() 包裹的 React 组件
ViewModel Controller 类------为 View 准备数据、暴露命令、双向同步

区别在于:传统的 ViewModel 偏"数据转换 + 命令中转",而我们的 Controller 还承担了业务编排、协调子 Controller 的职责 ,更像是 ViewModel + Application Service 的合体

Controller 与 Store 的区别

如果接触过 MobX/Redux,可能会觉得 Controller 像一个 Store。确实都持有状态,但角色完全不同

区别一:被动容器 vs 主动管理者
typescript 复制代码
// Store 模式:只管存、不管怎么用
class TodoStore {
  todos: Todo[] = [];
  setTodos(todos: Todo[]) { this.todos = todos; }
  addTodo(todo: Todo) { this.todos.push(todo); }
}

// Controller 模式:暴露的是业务意图
class TodoController implements NTodo.Controller {
  async toggleComplete(id: string) {
    await TodoService.toggle(id);
    await this.refreshList();  // 知道操作后需要刷新
  }
}

Store 的职责是"持有数据",怎么用是调用者的事。Controller 的职责是"控制业务流程",调用者只表达意图(ctrl.enterRoom()),不关心内部怎么做。

区别二:行为编排的归属

Store 通常只存数据,业务逻辑散落在组件/hook 各处------"列表加载完要先清空旧数据再填充"、"删除成功后要刷新列表"这类顺序逻辑没人统一管理。

Controller 把行为编排收进来refreshList()enterRoom()deleteRoom() 都在同一个类里,调用者只表达意图,不关心"进入房间前要更新 activeRoomId、进入后要刷新访问记录"这类操作细节。

区别三:子 Controller 协调
typescript 复制代码
class RoomPanelController {
  readonly sendCtrl: RoomPanelSendController;
  readonly historyCtrl: RoomHistoryController;
  
  async onSend(message: string) {
    await this.sendCtrl.send(message);
    await this.historyCtrl.refresh();  // 跨域协调
  }
}

Store 之间通常是平级的数据容器,虽然技术上也能嵌套,但一般不负责跨域协调------Store 的职责是"持有数据",不关心"发完消息后要不要刷新历史列表"。Controller 则明确承担协调职责:持有子 Controller,在业务方法里编排它们的协作顺序。


附录:核心代码清单

基础设施层 --- Controller 类型定义

typescript 复制代码
// packages/web-types/src/core/controller.types.ts
/** 转换器映射表:每个属性可选一个转换函数 */
export type ConverterMap<T> = {
  [K in keyof T]?: (value: T[K]) => any;
};

/**
 * 从转换器映射表推导转换后的类型
 * - 如果属性 K 有转换器,使用转换器的返回值类型(通过 infer 提取)
 * - 否则使用原类型 T[K]
 */
export type Converted<T, M extends ConverterMap<T>> = {
  [K in keyof T]: K extends keyof M
    ? M[K] extends (value: T[K]) => infer R
      ? R
      : T[K]
    : T[K];
};

/** Controller 基础类型 --- 子类实现 API 访问 */
export interface BaseController<T, M extends ConverterMap<T> = {}> {
  readonly _data: T;
  readonly _converterMap: M;
  loading: boolean;
  error: string | null;
}

基础设施层 --- BaseController 实现

typescript 复制代码
// apps/web/src/lib/base-controller.ts
"use client";

import { makeObservable, observable, computed } from "mobx";
import type { NCore } from "@/types";

export const BASE_ANNOTATIONS = {
  _data: observable,
  _converterMap: false,
  loading: observable,
  error: computed,
} as const;

export class BaseController<T, M extends NCore.ConverterMap<T> = {}> {
  protected _data: T;
  protected _converterMap: M;

  private _baseError: string | null = null;
  loading = false;

  get error(): string | null {
    return this._baseError;
  }

  set error(v: string | null) {
    this._baseError = v;
  }

  constructor(data: T, converters: M = {} as M) {
    this._data = makeObservable(data) as T;
    this._converterMap = converters;
  }
}

function createGetTrap<T>(
  target: BaseController<T, NCore.ConverterMap<T>>,
  prop: string | symbol,
) {
  if (typeof prop === "symbol") return (target as any)[prop];
  const key = prop as string;

  if (key in target || key === "constructor") {
    return (target as any)[key];
  }

  const data = (target as any)._data as T;
  const converterMap = (target as any)._converterMap as NCore.ConverterMap<T>;
  if (key in converterMap) {
    const converter = converterMap[key as keyof T & string]!;
    return converter((data as any)[key]);
  }

  return (data as any)[key];
}

function createSetTrap<T>(
  target: BaseController<T, NCore.ConverterMap<T>>,
  prop: string | symbol,
  value: any,
) {
  if (prop in target) {
    (target as any)[prop] = value;
  } else {
    ((target as any)._data as any)[prop] = value;
  }
  return true;
}

export function createController<T, M extends NCore.ConverterMap<T> = {}>(
  data: T,
  converters: M = {} as M,
): BaseController<T, M> & NCore.Converted<T, M> {
  const ctrl = new BaseController(data, converters);

  return new Proxy(ctrl, {
    get(target: any, prop: string | symbol) {
      return createGetTrap(target, prop);
    },
    set(target: any, prop: string | symbol, value: any) {
      return createSetTrap(target, prop, value);
    },
  }) as any;
}

通用抽象层 --- FormCtrl

typescript 复制代码
// apps/web/src/components/ui/form-ctrl.ts
"use client";

import { makeAutoObservable } from "mobx";

export interface FormConfig {
  title: string;
  submitText: string;
  submittingText: string;
}

export interface FormState<T extends Record<string, unknown>> {
  title: string;
  submitText: string;
  submittingText: string;
  opened: boolean;
  submitting: boolean;
  error: string | null;
  data: T;
}

export class FormCtrl<T extends Record<string, unknown>> {
  _data: FormState<T>;
  private readonly initialData: T;

  constructor(
    initialData: T,
    config: FormConfig,
    private readonly onSubmit: (data: T) => Promise<void>,
    private readonly onAfterSubmit?: () => void,
  ) {
    this.initialData = { ...initialData };

    this._data = {
      title: config.title,
      submitText: config.submitText,
      submittingText: config.submittingText,
      opened: false,
      submitting: false,
      error: null,
      data: { ...initialData } as T,
    };

    makeAutoObservable<FormCtrl<T>, keyof Omit<FormCtrl<T>, "initialData" | "onSubmit" | "onAfterSubmit">>(this, {
      initialData: false,
      onSubmit: false,
      onAfterSubmit: false,
    });
  }

  open() {
    Object.assign(this._data.data, { ...this.initialData });
    this._data.error = null;
    this._data.opened = true;
  }

  close() {
    this._data.opened = false;
  }

  setField<K extends keyof T>(key: K, value: T[K]) {
    this._data.error = null;
    (this._data.data as Record<K, T[K]>)[key] = value;
  }

  reset() {
    Object.assign(this._data.data, { ...this.initialData });
    this._data.error = null;
  }

  handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    this._data.submitting = true;
    this._data.error = null;
    try {
      await this.onSubmit(this._data.data);
      this._data.opened = false;
      this.onAfterSubmit?.();
    } catch (err) {
      this._data.error = err instanceof Error ? err.message : "提交失败";
    } finally {
      this._data.submitting = false;
    }
  };
}

业务组装层 --- RoomFormController

typescript 复制代码
// apps/web/src/client/room/form-controller.ts
"use client";

import { makeAutoObservable, observable } from "mobx";
import { FormCtrl } from "@/components/ui/form-ctrl";
import { RoomService } from "@/client/room/room-service";

export class RoomFormController {
  readonly _data: {
    joinForm: FormCtrl<{ code: string; password: string }>;
    createForm: FormCtrl<{ name: string; password: string }>;
  };

  constructor(
    private readonly onSuccess: (conversationId: string) => void,
    private readonly onRefreshList: () => Promise<void>,
  ) {
    this._data = observable(
      {
        joinForm: new FormCtrl<{ code: string; password: string }>(
          { code: "", password: "" },
          { title: "加入新聊天室", submitText: "加入聊天室", submittingText: "加入中..." },
          async (data) => {
            const trimmed = data.code.trim().toUpperCase();
            if (!trimmed) return;
            const result = await RoomService.join({
              code: trimmed,
              password: data.password.trim() || undefined,
            });
            await this.onRefreshList();
            this.onSuccess(result.conversation.id);
          },
        ),
        createForm: new FormCtrl<{ name: string; password: string }>(
          { name: "", password: "" },
          { title: "创建新聊天室", submitText: "创建并进入", submittingText: "创建中..." },
          async (data) => {
            if (!data.name.trim()) return;
            const result = await RoomService.create({
              name: data.name.trim(),
              password: data.password.trim() || undefined,
            });
            await this.onRefreshList();
            this.onSuccess(result.conversation.id);
          },
        ),
      },
      {
        joinForm: observable.ref,
        createForm: observable.ref,
      },
    );

    makeAutoObservable<RoomFormController, keyof RoomFormController>(this, {
      onSuccess: false,
      onRefreshList: false,
      _data: false,
    });
  }

  get joinForm() { return this._data.joinForm; }
  get createForm() { return this._data.createForm; }

  openJoin() { this._data.joinForm.open(); }
  openCreate() { this._data.createForm.open(); }

  closeAll() {
    this._data.joinForm.close();
    this._data.createForm.close();
  }

  reset() {
    this._data.joinForm.reset();
    this._data.createForm.reset();
  }
}

业务组装层 --- RoomsController

typescript 复制代码
// apps/web/src/client/room/components/Rooms/rooms-controller.ts
"use client";

import { makeObservable, observable, action, computed } from "mobx";
import { ContentsHelper } from "@/client/shared/contents-controller";
import { RoomService } from "@/client/room/room-service";
import { BaseController, BASE_ANNOTATIONS } from "@/lib/base-controller";
import { RoomFormController } from "@/client/room/form-controller";
import { RejoinController } from "@/client/room/components/room-history-list/rejoin-controller";
import type { NConversation } from "@/types";

interface RoomsDTO {
  visits: NConversation.Visit[];
}

const roomsConverters = {};

export class RoomsController extends BaseController<RoomsDTO> implements NConversation.Controller {
  private helper = new ContentsHelper();

  form: RoomFormController;
  rejoin: RejoinController;

  activeConversationId: string | null = null;

  constructor() {
    super({ visits: [] }, roomsConverters);

    const bindNavigate = (id: string) => this._navigateTo(id);
    const onRefreshList = () => this.refreshList();

    this.form = new RoomFormController(bindNavigate, onRefreshList);
    this.rejoin = new RejoinController({ onSuccess: bindNavigate, onRefreshList });

    makeObservable(this, {
      ...BASE_ANNOTATIONS,
      activeConversationId: observable,
      form: false,
      rejoin: false,
      helper: false,
      visits: computed,
      armedDeleteId: computed,
      busyConversationId: computed,
      refreshVisits: computed,
      refreshList: action.bound,
      enterRoom: action.bound,
      deleteRoom: action.bound,
      setPathname: action.bound,
      setNavigate: action.bound,
      clearError: action.bound,
    });
  }

  get visits() { return this._data.visits; }

  get armedDeleteId() { return this.helper.armedDeleteId; }
  set armedDeleteId(v: string | null) { this.helper.armedDeleteId = v; }

  get error() { return this.helper.error; }
  set error(v: string | null) { this.helper.error = v; }

  clearError() { this.helper.clearError(); }
  setNavigate(navigate: (path: string) => void) { this.helper.setNavigate(navigate); }

  get busyConversationId() { return this.helper.busyId; }

  get refreshVisits() { return this.refreshList; }

  setPathname(pathname: string) {
    const match = pathname.match(/^/rooms/([^/]+)/);
    this.activeConversationId = match ? match[1] : null;
  }

  private _navigateTo(conversationId: string) {
    if (this.helper.navigate) {
      this.helper.navigate(`/rooms/${conversationId}`);
    }
  }

  refreshList = async () => {
    this.loading = true;
    try {
      const data = await RoomService.getMyConversations();
      this._data.visits = Array.isArray(data) ? data : [];
    } catch {
      /* 静默 */
    } finally {
      this.loading = false;
    }
  };

  enterRoom(visit: NConversation.Visit) {
    this.helper.busyId = visit.conversationId;
    this.clearError();
    try {
      this._navigateTo(visit.conversationId);
    } catch {
      /* navigation error */
    } finally {
      this.helper.busyId = null;
    }
  }

  deleteRoom = async (conversationId: string) => {
    await this.helper.deleteFlow(conversationId, async () => {
      await RoomService.delete({ conversationId });
      await this.refreshList();
    });
  };
}

export const roomsController = new RoomsController();

视图层 --- Rooms 组件(薄视图层)

tsx 复制代码
// apps/web/src/client/room/components/Rooms/Rooms.tsx
"use client";

import { useEffect } from "react";
import { observer } from "mobx-react-lite";
import { Contents } from "@/client/shared/components/Contents";
import { RoomHistoryList } from "@/client/room/components/RoomHistoryList";
import { CreateRoomModal } from "../CreateRoomModal";
import { JoinRoomModal } from "../JoinRoomModal";
import { roomsController } from "./rooms-controller";

export const Rooms = observer(function Rooms() {
  const c = roomsController;

  useEffect(() => { void c.refreshList(); }, [c]);

  return (
    <>
      <Contents
        controller={c}
        accordionKey="rooms"
        title="FunCraft 聊天室"
        headerExtra={
          <div className="flex shrink-0 items-center gap-1">
            <button
              type="button"
              onClick={() => c.form.openJoin()}
              className="rounded-md px-2 py-0.5 text-xs text-[var(--accent)] transition-colors hover:bg-[var(--background)]"
              title="加入聊天室"
            >
              加入
            </button>
            <button
              type="button"
              onClick={() => c.form.openCreate()}
              className="rounded-md px-2 py-0.5 text-xs text-[var(--accent)] transition-colors hover:bg-[var(--background)]"
              title="创建聊天室"
            >
              创建
            </button>
          </div>
        }
      >
        <RoomHistoryList controller={c} />
      </Contents>

      <JoinRoomModal controller={c.form.joinForm} />
      <CreateRoomModal controller={c.form.createForm} />

      {c.error && (
        <div className="border-t border-[var(--border)] p-2 text-xs text-red-400">
          <p>
            {c.error}
            <button type="button" onClick={c.clearError} className="ml-2 underline">
              关闭
            </button>
          </p>
        </div>
      )}
    </>
  );
});
相关推荐
人月神话-Lee12 小时前
【图像处理】框架设计——协议、值类型与工程化思维
图像处理·人工智能·ios·设计模式·架构·ai编程·swift
_未完待续13 小时前
从零打造 AI Agent (二)—— 让 AI 拥有记忆
agent·ai编程
零壹AI实验室13 小时前
2026年5月AI编程工具横评:GPT-5.5、Claude Opus 4.7、Qwen3.7-Max谁最强
gpt·ai编程
Tech-Wang13 小时前
零基础AI编程之鸿蒙app开发
ai编程
财经资讯数据_灵砚智能13 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(夜间-次晨)2026年5月27日
大数据·人工智能·python·信息可视化·自然语言处理·ai编程·灵砚智能
kkkliaoo14 小时前
2026年AI编程Token消耗优化:从月费500到月费5的成本控制实战
人工智能·ai编程
Bigger14 小时前
mini-cc 的记忆引擎:让 AI 别再当金鱼了
前端·ai编程·claude
JavaGuide14 小时前
终于有好用的 Claude Code 状态栏增强插件了!
前端·后端·ai编程
Irissgwe14 小时前
十、LangGraph能力详解(1)LangGraph介绍及核心概念
python·ai·langchain·ai编程·工作流·langgraph