知识归纳:从全栈 CRUD 到 Oak 组件树
前面的主教程不是只做了一个任务列表。我们先用 Todo 完成 Web CRUD,再增加 Group 与 Todo 的一对多关系,最后把同一套业务节点带到小程序、Desktop 和 Native。
现在回头整理这些代码,可以把 Oak 理解为:用实体统一数据事实,用组件树统一前端数据节点与待提交操作,再用不同 render 适配不同平台。
1. 先看最终业务模型
数据库关系是:
Group 1 -------- N Todo
|
+-- groupId
每个 Todo 必须属于一个 Group。它在 Oak 中由两份实体源文件表达:
src/entities/Group.ts:分组名称;src/entities/Todo.ts:任务标题、完成状态,以及指向 Group 的引用。
执行 npm run make:domain 后,不要凭经验猜反向关系名,要到生成领域中确认。这个项目生成的是:
todo$group
它表示“通过 Todo.group 这条关系,反向取得当前 Group 下的 Todo 列表”。
2. 数据库知识如何映射到 Oak
| 数据库概念 | 教程项目 | Oak 对应 |
|---|---|---|
| 表 | group、todo | 实体 Storage |
| 一行 | 一个分组、一条任务 | 实体 Schema |
| 主键 | id | EntityShape 提供的 id |
| 外键 | todo.groupId | Todo.Schema.group 引用 |
| 一对多 | 一个 Group 有多个 Todo | 生成关系 todo$group |
| SELECT 列 | id、name、title | projection |
| WHERE | 只查某组任务 | 组件树关系节点与 filter |
| ORDER BY | 新任务优先 | sorters |
| INSERT | 新增 Group/Todo | addItem 形成 create |
| UPDATE | 改名、改标题、完成任务 | update / updateItem |
| DELETE | 删除记录 | removeItem |
| migration | 给已有表加字段或关系 | 数据库升级计划 |
实体与数据库迁移是两件事
修改实体后运行:
npm run make:domain
npm run make:locale
这会更新代码、类型、Storage 与多语言数据,但不会擅自修改一个已有生产数据库。已有数据库仍要生成、审核并执行升级计划。这与传统 ORM 中“改 model”和“执行 migration”是两件事完全一致。
projection 是明确的数据合同
例如 TodoList 只需要:
projection: {
id: 1,
title: 1,
completed: 1,
$$createAt$$: 1,
},
它类似明确写出 SQL 的 SELECT 列。projection 还会被生成类型检查,因此字段拼错、关系不存在或结果使用方式错误,都能更早暴露。
3. 普通 React 组件树与 Oak 组件树的区别
普通 React 组件树主要说明“谁渲染谁”。Oak 组件树还要说明:
- 当前组件对应哪一个实体数据节点;
- 查询结果缓存在哪个节点;
- create、update、remove 操作属于哪个节点;
execute()从树的哪一层收集并提交操作。
教程项目最终的树是:
frontend/home Group ListNode
└── frontend/home.<groupId> Group SingleNode
└── frontend/home.<groupId>.todo$group Todo ListNode
└── ...todo$group.<todoId> Todo SingleNode Upsert
Group ListNode
首页是 entity: 'group'、isList: true,负责查询所有 Group、渲染 tabs、创建 Group 草稿,并持有 Group Modal 的保存和取消。
Group SingleNode
选中一个 tab 后,页面用:
<GroupPanel oakPath={`${oakFullpath}.${selectedGroupId}`} />
把 GroupPanel 挂到具体 Group 节点。GroupPanel 不重复查询 todo,也不自己保存 todo;它只负责继续挂载关系节点。
todo$group ListNode
GroupPanel 使用:
<TodoList oakPath={`${oakFullpath}.todo$group`} />
这里不是把 groupId 当普通 props 传给 TodoList,而是把 TodoList 挂到 Group 的反向关系节点。runningTree 因此知道这些 Todo 属于当前 Group。
Todo SingleNode Upsert
新增或编辑 Todo 时,TodoList 在 Modal 中挂载:
<TodoUpsert oakPath={`${oakFullpath}.${editingTodoId}`} />
Upsert 只负责字段编辑:标题输入调用 this.update({ title }),完成状态调用 this.update({ completed })。Modal 的打开、保存和取消由拥有列表操作的父组件负责。
4. oakPath 不是普通 props
oakPath 是组件在 runningTree 中的地址。它决定子组件连接哪个数据节点,也决定操作如何沿组件树组织。
可以把它类比为 React key、路由 path、ORM relation path 和表单字段 path 的结合,但它同时承担这些职责,所以不能随意拼一个“看起来唯一”的字符串。关系段必须使用生成领域中的真实名字,例如 todo$group。
5. Upsert 为什么同时支持新增和更新
TodoList 新增时先调用:
const id = this.addItem({
title: '',
completed: false,
});
addItem() 在 Todo ListNode 中建立 create 操作并返回新 id。随后 TodoUpsert 挂到这个 id 的 SingleNode,输入字段时继续修改这条待创建记录。
编辑已有 Todo 时,挂载方式完全相同,只是 id 指向数据库已有记录,this.update(...) 形成 update 操作。因此同一个表单既能 insert,也能 update,这就是本教程中 Upsert 的含义。
6. 最容易误解的规则:操作属于哪个节点
新增 Group
Group ListNode.addItem() 创建 Group,因此保存 Group 时由首页 Group ListNode 执行:
await this.execute();
如果只在 GroupUpsert SingleNode 中执行,父列表持有的 create 可能没有被一起提交。
新增关系 Todo
Todo ListNode.addItem() 在 group.todo$group 节点创建 Todo,但这条 create 还依赖父 Group 提供关系上下文。只执行 Todo SingleNode 不够;只执行关系 ListNode,也可能缺少把新 Todo 绑定到父 Group 所需的级联上下文。
教程项目取得 TodoList 的父路径并执行 Group SingleNode:
async saveTodo() {
const todoListPath = this.state.oakFullpath;
if (!todoListPath) {
throw new Error('Todo list node is not mounted');
}
const separatorIndex = todoListPath.lastIndexOf('.');
const groupPath = todoListPath.slice(0, separatorIndex);
await this.execute(undefined, undefined, groupPath);
}
这样 runningTree 会从 Group 节点收集关系子树中的操作,级联提交时为 Todo 绑定 groupId。
这条规则可以归纳为:
先判断 create/update/remove 建立在哪个节点
-> 再判断关系外键依赖哪一层祖先上下文
-> 从能覆盖完整操作子树的节点 execute
不要把“离保存按钮最近的组件”当成默认执行节点。
7. data、formData、properties 与 render
| 合同 | 用途 | 教程例子 |
|---|---|---|
data | 组件本地状态和稳定初值 | selectedGroupId、editingTodoId |
formData | 把实体结果整理成 render 需要的形状 | groups、todos |
properties | 父组件传入的业务参数 | 只有确实需要普通输入时声明 |
methods | render 可调用的业务交互 | saveTodo、setTitle |
| 框架注入 | Oak 节点状态与标准方法 | oakFullpath、oakLoading |
当前 Oak 编译器会根据同目录 index.ts 生成 render props。标准 TSX render 写:
export default function Render(props) {
不要手写宽泛 props 类型。render 使用了未声明字段时,应回到真实来源修复:父组件输入放进 properties,查询派生结果放进 formData,本地状态放进 data,交互函数放进 methods。
小程序 XML 也使用同一合同检查字段、事件、组件属性和 class。any、never、@ts-nocheck 或虚假的 props 声明都只是隐藏问题,不会建立真实运行时合同。
8. 前端、后端和 Oak 的职责边界
| 问题 | 应放的位置 |
|---|---|
| Modal 是否打开、当前 tab | 页面或组件 data |
| Group/Todo 查询与待提交操作 | Oak component node |
| 标题字段编辑 | Upsert this.update(...) |
| 标题不能为空 | checker |
| 完成任务后写审计记录 | trigger |
| 一次处理多个实体的复合业务 | aspect |
| 第三方 HTTP 回调 | endpoint |
| 定期处理过期任务 | timer / watcher |
| 数据库存取 | backend context + oak-db |
普通 CRUD 不需要为每个实体重复写 Controller、DTO 和 API client,因为实体、operation、context 与 runningTree 已经形成统一合同。但跨入口都必须成立的业务规则,仍必须放到 checker、trigger 或其它后端扩展点,不能只写在四个平台的 render 中。
9. 四个平台共享什么
| 层 | Web | 小程序 | Desktop | Native |
|---|---|---|---|---|
| Group/Todo 实体 | 共享 | 共享 | 共享 | 共享 |
关系名 todo$group | 共享 | 共享 | 共享 | 共享 |
| Group/Todo 组件逻辑 | 共享 | 共享 | 共享 | 共享 |
| 节点执行层级 | 共享 | 共享 | 共享 | 共享 |
| 渲染技术 | React/AntD | XML/WXML | React/Fluent UI | React Native |
| 平台事件适配 | DOM | 小程序 event | DOM/Fluent | Native event |
跨端复用不是强迫四端使用同一套标签,而是共享业务事实、组件节点和操作语义。render 只负责把这些能力翻译成平台 UI。
因此正确顺序是:先在 Web 把实体关系和组件树验证稳定,再增加小程序、Desktop、Native render。否则每个平台都会重复放大同一个模型错误。
10. 完整数据链
flowchart TD
A["Group.ts / Todo.ts"] --> B["make:domain"]
B --> C["EntityDict / Storage / todo$group"]
C --> D["Group ListNode"]
D --> E["Group SingleNode"]
E --> F["todo$group ListNode"]
F --> G["Todo Upsert SingleNode"]
G --> H["runningTree pending operations"]
H --> I["execute correct ancestor"]
I --> J["backend context"]
J --> K["checker / trigger / oak-db"]
K --> L["SQLite / MySQL / PostgreSQL"]
L --> M["opRecords and cache refresh"]
M --> N["Web / MP / Desktop / Native render"]
11. 新增需求时的固定步骤
假设要给 Todo 增加截止日期:
- 从数据库角度判断字段类型、是否可空、旧数据和索引。
- 修改 Todo 实体与 locale。
- 运行
make:domain、make:locale,检查生成类型。 - 把日期加入 TodoList 和 TodoUpsert projection。
- 在 Upsert
index.ts增加统一 setter。 - Web 先实现日期控件并完成真实 CRUD。
- 若日期规则必须全端成立,写 checker,不在 render 中复制。
- 再为小程序、Desktop、Native 增加平台日期控件。
- 对已有数据库生成并审核升级计划。
12. 提交前检查
- 实体变化后运行
make:domain、make:locale、build和项目要求的升级命令。 - 真实验证 Group 与 Todo 的新增、查询、更新、删除。
- 确认新增 Todo 的
groupId正确,不只是界面显示在某个 tab 下。 - 运行目标平台构建,处理 XML、render props 和 Less Module 检查。
- 搜索并清除
any、never、@ts-nocheck和手写宽泛 props。 - 不提交数据库密码、token、
local.properties、keystore 或本地数据库文件。