救救孩子吧:被 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:「呃......就是模型里有血?」
我:「......」
于是我开始手把手教它:
- 先教它什么是"数据优先" ------ 「你先告诉我聊天室是什么,再告诉我它能做什么」
- 再教它什么是"单一真相来源" ------ 「你现在的 messages 在哪?状态在哪?它们是什么关系?」
- 然后教它什么是"薄视图层" ------ 「组件只做渲染,别把业务逻辑写组件里」
奇妙的事情发生了:当我开始用追问的方式教它,而不是告诉它"怎么做"时,它居然开始自己思考了。
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 秒(它在思考,不是在计算),然后给出了一个完整的设计方案:
- 领域类型 定义在
packages/web-types/ - Controller 使用
BaseController + Proxy - 分层清晰:Ability → Server Action → Facade Service → Controller → Component
- 组件极薄 :只有
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 各处
}
问题不是代码写不了,而是:
- 状态散落各地 :
room、messages、onlineUsers、draftMessage各自由不同的 hook 管理,它们之间的关系不可见 - 数据和行为分离 :
messages是数据(在useMessages里),但修改 messages 的逻辑(sendMessage、handleSend)却在另一个地方 - 重构是噩梦:当"在线用户"和"权限"之间存在隐含的业务约束时(比如只有在线管理员才能踢人),这个约束散落在两个 hook 里,改一个忘了另一个就是 bug
- AI 也看不懂:这正是我在 0.2 节里吐槽 AI 写出"屎山"的根本原因------不是 AI 不会写代码,是这种架构本身就没有结构
这引发一个根本性问题:函数的本质是"转换"(输入→输出),但前端状态管理的本质是"建模"(事物 + 关系 + 行为)。
| 场景 | 合适的范式 | 原因 |
|---|---|---|
| 数据格式转换、管道处理 | 函数式 | 输入→输出,无副作用 |
| 聊天室、用户、订单等领域对象 | OOP | 有状态、有关系、有行为 |
| 表单交互(打开/关闭/提交/校验) | OOP | 有生命周期,行为围绕数据 |
| 纯 UI 状态(主题、动画、滚动位置) | 函数式 | 无领域语义,独立操作 |
函数式擅长"转换",OOP 擅长"建模"。 当前前端的问题是:把一切需求都塞进了函数式的框里,包括那些明显更适合用 OOP 建模的领域概念。
我不是在反对函数式------我是在说:不要因为一把锤子好用,就把所有问题都看成钉子。
核心观点 :前端不需要放弃函数式,也不需要全面回归 OOP。它需要的是更清醒地判断:当前面对的是"转换问题"还是"建模问题"------然后用对的范式解决对的问题。
1. 引言:一个真实的业务场景
这是一个多人实时聊天应用。在"聊天室"模块中,用户需要:
- 加入聊天室:弹窗中输入房间号 + 密码
- 创建聊天室:弹窗中输入名称 + 密码
两个弹窗的结构高度相似:都有标题、表单字段、提交按钮、loading 状态、错误提示。但具体字段不同(加入是 code+password,创建是 name+password),提交逻辑也不同(调用不同的 API)。
朴素的想法 :写两个独立的 Modal 组件,各自管理自己的状态。但这样会产生大量重复代码------弹窗的 opened、submitting、error、handleSubmit 逻辑在每个 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 可观察对象,不需要深度代理,只需要追踪"引用是否变了"- 通过构造函数注入回调 :
onSuccess和onRefreshList从外部传入,子 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 自身属性
启发点 3 :Proxy 是"统一访问层" ------对组件透明,不需要关心数据是来自 _data 还是 Controller 自身。
3. 第二阶段:自上而下 --- 从场景反推设计
现在换个角度,从业务场景出发,看各层如何协作。
3.1 业务场景层:Rooms.tsx
组件是薄视图层------只做三件事:
- 从 Controller 读取 observable 状态
- 将用户操作委托给 Controller 方法
- 渲染 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()、role、kick() 等方法)。这是对象激活 ------从"死数据"到"活对象",服务于领域模型。
真正的"简单值转换"只有 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 状态(loading、error),所以用 _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 适用性判断:有没有"领域语义的状态"
这套理论适用范围的判断标准只有一个:你的前端有没有"领域复杂度"的状态?
| 场景 | 领域对象示例 | 为什么适合 |
|---|---|---|
| 协作工具(聊天室、项目管理) | Conversation、Member、Message |
对象间有丰富关系,行为复杂(踢人、权限判断) |
| 电商(购物车、订单) | Cart、Order、Product |
有业务规则(库存、折扣计算),不只是 CRUD |
| 表单密集型应用 | FormCtrl<T> 激活后的领域表单 |
表单有提交逻辑、校验逻辑,不是纯 UI |
| 实时应用(SSE/WebSocket) | Timeline、MessageStream |
数据流持续更新,需要统一的响应式入口 |
核心特征:状态之间有联系,修改一个状态会影响其他状态的推导。
6.4 不适用场景:无领域语义
| 场景 | 原因 |
|---|---|
| 纯展示组件库(shadcn/ui、MUI) | 组件应该是无状态的,领域模型在消费侧 |
| 静态页面 / 文档站 | 没有客户端状态,不需要 Controller |
| 极简 CRUD(只有列表+详情+表单) | 领域逻辑太薄,useState + Server Action 足够 |
| 以 Server Components 为主的应用 | 状态在服务端,客户端只有少量交互态 |
6.5 何时是"过度设计"?
这套理论的成本是抽象层级 ------需要定义类型、Controller 类、可能的 _converterMap。
判断标准很简单:
领域行为代码量 > 抽象代码量 → 值得
领域行为代码量 < 抽象代码量 → 过度
举例:
RoomsController里有enterRoom、deleteRoom、refreshList、处理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 对业务需求的响应质量明显提升------它不会在 roomVisits 和 conversations 之间来回挣扎。
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 上 (方法、
loading、error),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 的理解经历了三次变化:
- 最初 :
_converterMap是"激活层"------把 POJO 变成活对象 - 误判 :看到递归
_data用得更多,就认为激活不重要,把"简单值转换"当成了核心 - 修正(最终) :
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(aliasrooms-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/await、try/catch? - 组件内没有数据转换逻辑(
.map()、.filter())?------业务转换在 Controller - 没有解构 controller?(
const { visits, loading } = controller❌,const c = controller✅) - 所有数据都通过
controller.xxx访问?
如果以上都满足,这个组件就是合格的"薄视图层"。
完整示例演练:新增"聊天室消息列表"功能
假设要实现聊天室内的消息列表,包含发送消息、显示时间线。
步骤 0 --- 判断 :Message、Timeline 是业务概念 → 走完整流程。
步骤 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 数据 + 需要激活 MessageDTO → Message(可能需要 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.visits、ctrl.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)+ 前端状态(loading、error) |
| 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>
)}
</>
);
});