新手入门:从 Web CRUD 到完整 Oak 组件树
本教程面向第一次接触 Oak 的开发者。我们从一个不安装 oak-general-business、oak-pay-business 的最小项目开始,先在 Web 中完成真正的增删改查,再引入任务分组和一对多关系,最后把同一棵组件树带到微信小程序、Desktop 和 Native。
完成后,项目具有以下能力:
- Group 分组的新增、编辑、删除;
- Todo 任务的新增、编辑、完成切换、删除;
- 页面顶部用 tabs 切换分组;
- 新增和编辑都在 Modal 中打开 Upsert 子组件;
- Todo 列表通过生成关系
todo$group挂在 Group 节点下; - Web、小程序、Electron Desktop、React Native 共用实体和 Oak 节点逻辑。
本章对应真实参考项目
oak-tutorial-todo。示例不是只通过类型检查:Web CRUD 已连接 SQLite 验证持久化,小程序 XML 检查为 0 diagnostics,Electron 已生成 NSIS 安装包,Native 已生成 Android production Metro bundle。
1. 先建立正确的学习顺序
第一次学习请按顺序完成:
- 安装 Oak Assistant (oak-team)。
- 创建无公共业务依赖的 Oak 项目。
- 定义 Todo 实体并生成领域代码。
- 理解 ListNode、SingleNode 和
oakPath。 - 用 Todo 列表 + Modal Upsert 完成基础 CRUD。
- 真实初始化数据库并逐项验证 CRUD。
- 新增 Group,与 Todo 建立一对多关系。
- 把 Web 页面升级为完整关系组件树。
- 再进入微信小程序、Desktop和Native。
- 最后阅读知识归纳。
不要一上来背 Oak 名词。本章每引入一个概念,都会先说明它解决什么问题,再给出代码。
2. 安装编辑器能力
在 VS Code 扩展市场安装:
Oak Assistant (oak-team)
Oak render 的 props 由编译器根据同目录 index.ts 生成,小程序 XML 也会被转换成虚拟 TypeScript 检查。没有插件时,很多问题只能等到 npm run build 才出现;安装插件后,错误可以直接显示在 TSX、XML 和 Less 文件中。
当前规范:
- render 中写
function Render(props),不要手写宽泛的WebComponentProps; - 不使用
any、never或@ts-nocheck绕过错误; - XML 使用到的字段必须由
data、properties或编译器可识别的formData合同提供; - 可见文本放进 locale;
- 修改实体后重新运行领域和 locale 生成命令。
详细安装和配置见Oak Assistant。
3. 创建最小项目
oak-cli create oak-tutorial-todo
cd oak-tutorial-todo
npm install
git init
git add .
git commit -m "chore: scaffold standalone Oak application"
创建时选择 SQLite,Oak 公共业务依赖全部取消。此类项目默认只保留 frontend namespace 和一个展示页,不需要用户、登录、console、token 或支付模块。
先运行欢迎页:
npm run start:web
看到页面后停止服务。此时只需要认识三个目录:
src/entities 你编写的业务数据模型
src/oak-app-domain make:domain 生成的类型和关系
src/pages/frontend/home 当前 Web 首页
src/oak-app-domain 是生成产物,不要手改。
4. 定义 Todo 实体
创建 src/entities/Todo.ts:
import { Boolean, String } from '@oak-domain/types/DataType';
import { EntityShape } from '@oak-domain/types/Entity';
import { EntityDesc } from '@oak-domain/types';
export interface Schema extends EntityShape {
title: String<120>;
completed: Boolean;
}
export const entityDesc: EntityDesc<Schema> = {
locales: {
zh_CN: {
name: '任务',
attr: {
title: '标题',
completed: '是否完成',
},
},
en_US: {
name: 'Todo',
attr: {
title: 'Title',
completed: 'Completed',
},
},
},
};
这里可以先套用数据库知识理解:Todo 类似一张表,title 和 completed 是业务字段,EntityShape 提供 id、创建时间、更新时间和软删除字段。
生成并检查:
npm run make:domain
npm run make:locale
npm run build
git add .
git commit -m "feat: define Todo domain entity"
在 src/oak-app-domain/EntityDict.ts 中应当能找到 todo。
5. 写页面前先理解 Oak 组件树
普通 React 组件树主要描述“谁渲染谁”。Oak 组件树还描述“数据操作属于哪个节点”。
第一版 CRUD 使用下面的树:
frontend/home Todo ListNode
└── frontend/home.<todoId> Todo SingleNode,Upsert 表单
三个概念先这样理解:
5.1 ListNode
entity: 'todo' 且 isList: true 的组件是 Todo 列表节点。它负责:
- 查询多条 Todo;
addItem创建列表草稿;updateItem、removeItem修改某一项;- 执行整条列表分支。
5.2 SingleNode
entity: 'todo' 且 isList: false 的 Upsert 是单条 Todo 节点。路径末尾是 Todo id,例如:
$todo/tutorial-list.9f...2a
同一个 Upsert 既能编辑数据库已有记录,也能编辑 addItem 新建的草稿,所以叫 Upsert。
5.3 oakPath
oakPath 不是普通 React key。它告诉 runningTree:这个子组件对应组件树中的哪一个数据节点。
<TodoUpsert oakPath={`${oakFullpath}.${editingTodoId}`} />
oakFullpath 是当前列表节点完整路径,拼上 id 后得到 Todo SingleNode 路径。
6. 创建 Todo Upsert 组件
创建目录:
src/components/todo/upsert/
├── index.ts
├── web.tsx
└── locales/
index.ts:
export default OakComponent({
entity: 'todo',
isList: false,
projection: {
id: 1,
title: 1,
completed: 1,
},
});
web.tsx 使用框架抽象 Upsert:
import React from 'react';
import { Upsert } from '@project/components/AbstractComponents';
export default function Render(props) {
const { oakFullpath } = props.data;
return oakFullpath ? (
<Upsert
entity="todo"
oakPath={oakFullpath}
attributes={['title', 'completed']}
layout="vertical"
/>
) : null;
}
注意:Upsert 组件只负责字段编辑。保存、取消和 Modal 状态放在父列表中,因为新建操作属于父 ListNode。
7. 用 Modal 完成第一版 Web CRUD
首页 index.ts 先作为 Todo ListNode:
export type TodoView = {
id: string;
title: string;
completed: boolean;
};
export default OakComponent({
entity: 'todo',
isList: true,
projection: {
id: 1,
title: 1,
completed: 1,
$$createAt$$: 1,
},
sorters: [{
sorter: {
$attr: { $$createAt$$: 1 },
$direction: 'desc',
},
}],
data: {
todos: [] as TodoView[],
},
formData({ data }) {
const todos: TodoView[] = (data || []).flatMap((item) => {
if (typeof item.id !== 'string') {
return [];
}
return [{
id: item.id,
title: item.title || '',
completed: item.completed === true,
}];
});
return { todos };
},
methods: {
async toggleTodo(id: string, completed: boolean) {
this.updateItem({ completed: !completed }, id);
await this.execute();
},
async removeTodo(id: string) {
this.removeItem(id);
await this.execute();
},
},
});
web.tsx 的关键结构:
import React, { useState } from 'react';
import { Button, Checkbox, Modal, Table } from 'antd';
import TodoUpsert from '@project/components/todo/upsert';
import type { TodoView } from './index';
export default function Render(props) {
const { todos = [], oakFullpath, oakExecuting } = props.data;
const { addItem, toggleTodo, removeTodo, execute, clean } = props.methods;
const [editingTodoId, setEditingTodoId] = useState('');
return (
<>
<Button
type="primary"
onClick={() => {
setEditingTodoId(addItem({
title: '',
completed: false,
}));
}}
>
新增任务
</Button>
<Table<TodoView>
rowKey="id"
dataSource={todos}
pagination={false}
columns={[
{
title: '完成',
render: (_, row) => (
<Checkbox
checked={row.completed}
onChange={() => void toggleTodo(row.id, row.completed)}
/>
),
},
{ title: '标题', dataIndex: 'title' },
{
title: '操作',
render: (_, row) => (
<>
<Button onClick={() => setEditingTodoId(row.id)}>
编辑
</Button>
<Button danger onClick={() => void removeTodo(row.id)}>
删除
</Button>
</>
),
},
]}
/>
<Modal
open={Boolean(editingTodoId)}
confirmLoading={oakExecuting}
onOk={async () => {
await execute();
setEditingTodoId('');
}}
onCancel={() => {
clean();
setEditingTodoId('');
}}
>
{editingTodoId && oakFullpath ? (
<TodoUpsert oakPath={`${oakFullpath}.${editingTodoId}`} />
) : null}
</Modal>
</>
);
}
为什么保存必须由列表执行
addItem 把 create 操作放在 Todo ListNode。Upsert 中输入标题时,字段更新发生在 Todo SingleNode。如果在 Upsert 子组件中直接 execute(),只会执行子节点,可能得到一条针对新 id 的 update,而父节点的 create 没有提交。
因此 Modal 由父列表持有,保存时父列表调用 execute()。runningTree 会组合父级 create 和子级字段修改。
这是本教程最重要的第一个 Oak 规则:在哪个节点创建操作,就从能覆盖该节点的祖先执行。
8. 初始化数据库并亲手验证 CRUD
另开终端:
npm run server:init
npm run server:start
这里能执行 server:init,是因为教程使用刚创建的空 SQLite 数据库。当前模板的初始化模式会重建非 static 表;数据库一旦有需要保留的数据,后续就只能使用审核后的结构升级和数据迁移,不能重复把 server:init 当作“同步实体”命令。
再启动 Web:
npm run start:web
按顺序验证:
- 新增任务并刷新页面,记录仍存在;
- 编辑标题并刷新,标题已更新;
- 切换完成状态并刷新,状态已保存;
- 删除任务并刷新,记录不再出现;
- 新增任务后点击取消,刷新后没有该记录。
不要只看“操作成功”提示。Oak 前端会先反映乐观状态,真正的验证必须包含刷新或数据库查询。
npm run build
git add .
git commit -m "feat: build Todo web CRUD with modal upsert"
9. 第二阶段:引入任务分组
现在需求升级:任务属于某个分组,用户先选择分组,再管理该组任务。
创建 src/entities/Group.ts:
import { String } from '@oak-domain/types/DataType';
import { EntityShape } from '@oak-domain/types/Entity';
import { EntityDesc } from '@oak-domain/types';
export interface Schema extends EntityShape {
name: String<64>;
}
export const entityDesc: EntityDesc<Schema> = {
locales: {
zh_CN: { name: '任务分组', attr: { name: '分组名称' } },
en_US: { name: 'Task group', attr: { name: 'Group name' } },
},
};
修改 Todo.ts:
import { Schema as Group } from './Group';
export interface Schema extends EntityShape {
title: String<120>;
completed: Boolean;
group: Group;
}
Oak 会把对象关系生成成存储外键和关系路径。重新生成:
npm run make:domain
npm run make:locale
npm run build
然后在生成的 EntityDict.ts 中搜索:
todo$group
不要凭经验猜关系名。以生成代码为准。
数据库结构已变化。学习项目可以重新初始化一个空 SQLite 文件;已有业务数据的项目必须走正式 schema upgrade,不能直接删除数据库。
10. 把页面升级为完整关系组件树
最终结构:
frontend/home Group ListNode
└── frontend/home.<groupId> Group SingleNode
└── frontend/home.<groupId>.todo$group Todo ListNode
└── ...todo$group.<todoId> Todo SingleNode Upsert
目录建议:
src/components/
├── group/
│ ├── panel/
│ └── upsert/
└── todo/
├── list/
└── upsert/
10.1 首页改为 Group ListNode
首页查询 Group,顶部 tabs 的数据来自该 ListNode。新增分组时:
const id = addItem({ name: '' });
setSelectedGroupId(id);
setEditingGroupId(id);
Modal 中挂载:
<GroupUpsert oakPath={`${oakFullpath}.${editingGroupId}`} />
保存由首页 Group ListNode 执行:
await execute();
setEditingGroupId('');
10.2 GroupPanel 只负责向下挂关系
src/components/group/panel/index.ts:
export default OakComponent({
entity: 'group',
isList: false,
projection: { id: 1, name: 1 },
});
web.tsx:
import React from 'react';
import TodoList from '@project/components/todo/list';
export default function Render(props) {
const { oakFullpath } = props.data;
return oakFullpath ? (
<TodoList oakPath={`${oakFullpath}.todo$group`} />
) : null;
}
todo$group 表示“当前 Group 作为 group 被哪些 Todo 引用”。TodoList 不需要再手写 { groupId } 过滤器,关系节点已经携带父上下文。
10.3 TodoList 仍使用 Upsert Modal
TodoList 的新增、编辑 UI 与第一版相同,但它现在位于 Group 下方。新增 Todo 时,create data 只写自身字段:
this.addItem({
title: '',
completed: false,
});
不要手工填写 groupId。框架在执行 todo$group 级联操作时绑定父键。
10.4 保存 Todo 要执行 Group 节点
关系树比第一版多一层。Todo create 位于 todo$group ListNode,但它必须在 Group SingleNode 的级联操作中提交,后端才能得到父 Group 并写入 groupId。
TodoList 中的保存方法:
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);
}
如果只在 TodoList 自己的路径执行,create operation 没有父关系上下文,后端会报 Todo 的 groupId 不能为空。这个错误不是让你手填外键,而是在提醒你执行层级不对。
11. 完整验证分组关系
重新初始化或升级数据库后验证:
- 新增 Group,刷新后仍存在;
- 新增两个 Group,tabs 可切换;
- 在 A 组新增 Todo,切到 B 组看不到它;
- 切回 A 组能看到该 Todo;
- 编辑 Todo、切换完成状态、删除并刷新;
- 取消新增 Group 或 Todo,数据库不产生记录;
- 构建无 TypeScript、XML 或样式 diagnostics。
npm run make:locale
npm run build
git add .
git commit -m "feat: group Todo tasks through component tree"
12. 再增加其它端
现在才进入多端,因为此时业务模型和组件树已经稳定:
- 微信小程序:复用节点逻辑,补 XML render
- Desktop:Electron/Tauri 壳与 Fluent UI render
- Native:React Native render 与 Android 构建
多端不是复制业务逻辑。index.ts、实体、projection、关系路径和 execute 层级保持一致;不同端主要替换 render 和交互控件。
13. 最终命令清单
# 实体变化
npm run make:domain
npm run make:locale
# Oak 严格检查
npm run build
# Web
npm run start:web
npm run build:web
# 微信小程序,添加 workspace 后
npm run build:mp:wechatMp
# Electron Desktop,添加 workspace 后
npm run build:desktop:desktop-electron
# Native,添加 workspace 后
npm run build:native:native:android
Android production build要求本机配置 ANDROID_HOME 或 native/android/local.properties -> sdk.dir。没有 Android SDK 时,仍可单独执行 Metro production bundle 验证 JS/render 链路,详见 Native 专题。
14. 你现在真正学会了什么
- 数据库:实体、字段、外键和一对多关系;
- 前端:列表、Modal、表单、tabs 和多端 render;
- 后端:operation 最终由服务端校验并持久化;
- Oak:ListNode、SingleNode、
oakPath、关系路径、草稿 operation、祖先执行与级联外键绑定。
下一步阅读知识归纳,把这些经验整理成以后开发其它业务对象时可以复用的方法。