Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

新手入门:从 Web CRUD 到完整 Oak 组件树

本教程面向第一次接触 Oak 的开发者。我们从一个不安装 oak-general-businessoak-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. 先建立正确的学习顺序

第一次学习请按顺序完成:

  1. 安装 Oak Assistant (oak-team)
  2. 创建无公共业务依赖的 Oak 项目。
  3. 定义 Todo 实体并生成领域代码。
  4. 理解 ListNode、SingleNode 和 oakPath
  5. 用 Todo 列表 + Modal Upsert 完成基础 CRUD。
  6. 真实初始化数据库并逐项验证 CRUD。
  7. 新增 Group,与 Todo 建立一对多关系。
  8. 把 Web 页面升级为完整关系组件树。
  9. 再进入微信小程序DesktopNative
  10. 最后阅读知识归纳

不要一上来背 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
  • 不使用 anynever@ts-nocheck 绕过错误;
  • XML 使用到的字段必须由 dataproperties 或编译器可识别的 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 类似一张表,titlecompleted 是业务字段,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 创建列表草稿;
  • updateItemremoveItem 修改某一项;
  • 执行整条列表分支。

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>
        </>
    );
}

为什么保存必须由列表执行

addItemcreate 操作放在 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

按顺序验证:

  1. 新增任务并刷新页面,记录仍存在;
  2. 编辑标题并刷新,标题已更新;
  3. 切换完成状态并刷新,状态已保存;
  4. 删除任务并刷新,记录不再出现;
  5. 新增任务后点击取消,刷新后没有该记录。

不要只看“操作成功”提示。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. 完整验证分组关系

重新初始化或升级数据库后验证:

  1. 新增 Group,刷新后仍存在;
  2. 新增两个 Group,tabs 可切换;
  3. 在 A 组新增 Todo,切到 B 组看不到它;
  4. 切回 A 组能看到该 Todo;
  5. 编辑 Todo、切换完成状态、删除并刷新;
  6. 取消新增 Group 或 Todo,数据库不产生记录;
  7. 构建无 TypeScript、XML 或样式 diagnostics。
npm run make:locale
npm run build
git add .
git commit -m "feat: group Todo tasks through component tree"

12. 再增加其它端

现在才进入多端,因为此时业务模型和组件树已经稳定:

多端不是复制业务逻辑。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_HOMEnative/android/local.properties -> sdk.dir。没有 Android SDK 时,仍可单独执行 Metro production bundle 验证 JS/render 链路,详见 Native 专题。

14. 你现在真正学会了什么

  • 数据库:实体、字段、外键和一对多关系;
  • 前端:列表、Modal、表单、tabs 和多端 render;
  • 后端:operation 最终由服务端校验并持久化;
  • Oak:ListNode、SingleNode、oakPath、关系路径、草稿 operation、祖先执行与级联外键绑定。

下一步阅读知识归纳,把这些经验整理成以后开发其它业务对象时可以复用的方法。