Oak框架简介

Oak是一个现代的应用系统快速开发框架,它实现了对业务系统相对抽象层次上的功能抽象,使应用开发者可以将自己的注意力完全集中于实现业务逻辑本身,而无须关注众多构建应用系统需要考虑的(共性)问题,例如:

  • 我该选用什么样的数据库,如何建立索引?
  • 我该怎样设计并实现全局一致的用户权限?(几乎没有多少应用系统能完美解决这一问题)
  • 我该怎样设计前后端接口才能保证低耦合且可重用?(传统MVC设计模式下controller如果不加以规范的组织设计/定期重构,对于长期开发而言是一场灾难)
  • 我该如何在各层次上保证数据的一致性?(前端->后端->分布式,每一个层面上都有各种细节问题需要考虑)
  • 我该如何保持前后端常量和逻辑的一致性?(有很多库被设计专门用来解决这一问题,使得整个项目变得更加复杂)
  • 我的应用如果有多个前端,如何在代码复用和保证高可持续开发性之间取得平衡?
  • 如何发现并杜绝我的项目在各个层次上的安全漏洞?

Oak的背景与目标

作为软件开发者,在当今这个年代要开发业务软件既是幸福的也是痛苦的。一方面,众多成熟的技术方案及SaaS类服务几乎能解决应用开发中遇到的各种问题(对比20年前,光是一个图片上传预览就要手写解决无数的问题),另一方面,开发软件需要不断学习和使用的新技术/开源库也越来越多,甚至可以说,几乎没有人的学习速度能赶得上新技术的出现速度。技术栈的爆炸既造成了应用开发者之间的鸿沟(我的项目代码只有我能维护的动😭),也让绝大多数小团队或者个人开发者难以赋予项目一个健壮、可持续的基础架构。当一个项目持续开发一年以上,代码往往就成为了著名的“屎山”。

Oak框架的设计目标就是解决软件开发过程中的这些问题,让现代典型的应用系统开发达到一致、可持续、高可用的目标。

Oak的技术选择

Oak框架使用Typescript作为开发语言。Oak设计之初就希望以一种语言统一编写前后端(前后端一致性),让团队中编写应用的程序员变成“完全对等”的。在此前提下,Javascript语言几乎是唯一的选择。同时为了提高代码的规范性,当前最流行的Typescript成为了我们的选择。

Typescript语言在开发过程中非常消耗计算机资源,请确保您的开发机器拥有Intel 12代以上的Cpu以及16GB以上的内存空间,以获得较好的开发体验😁

Oak框架主要针对项目的整体架构及公共功能抽象,其本身并没有限制过多的技术栈。例如,后端数据库默认使用MySQL,但您也可以通过编写一些适配性的代码来使之运行在PostgreSQL或者MongoDB之上;在前端,目前我们选择使用React,但这也并不意味着您不能使用vue(当然,这需要您自己实现一套vue上对等的逻辑转换)。在未来,我们将积极拥抱开源,希望集各方之力,服务于数量广大的应用开发工程师。

Oak的定位

纵观计算机软硬件技术的发展历史,都可以用“抽象”两个字来概括,例如,操作系统就是对各种硬件的抽象,数据库就是对于数据存储查询的抽象。从这个角度看,Oak框架也是一种抽象,它是最接近业务层次的抽象,尽可能的将业务需要遇到的各种共性问题统一进行了处理,并制订了业务逻辑编写的开发规范,以致力于使业务开发者不写一行多余的代码这一目标。

Oak定位

需要强调的是:正如操作系统和数据库一样,Oak也并非能解决所有的应用开发问题,它的设计目标仅仅是为了提高应用软件开发的效率和规范。一名优秀的工程师仍然应当掌握更多基础技术的实现,以应付项目中的更多问题

Oak仍然在不断开发完善中,如有问题,也欢迎加入讨论小组,给出您的宝贵建议。

基础知识

  • Oak使用Typescript语言,因此您需要提前掌握以下知识:
  1. javascript语言基础: 学习资料
  2. Nodejs环境: 学习资料 官方文档
  3. Typescript语言基础: 官方文档
  • 在前端,Oak目前使用React作为网页端框架(尽管这不是必须,但由于团队技术力量等原因,短期内并没有计划去适配vue等其它框架),因此您也需要掌握React的一些基本概念。如果您需要开发App或者小程序,也需要去了解一些Oak所采用的技术本的相关技术。
  1. React: 官方站点
  2. React-native 官方站点
  3. 微信小程序开发 官方站点

对于其它更多的前端环境,Oak也将在未来进行适配。Oak的前端技术路线介绍请参见:目录文件结构

对于新手开发者,可能对上述这么多的前置知识学习感到望而生畏。没关系,理论上只要了解并掌握基础概念即可进行开发,更多的技术细节可以在开发过程中再不断学习补充。

开发环境

开发环境只要配置NodeJs 18以上即可,推荐使用Microsoft的Vs Code作为开发IDE

编译项目Entity

使用Oak框架的项目,需要将项目中对象结构(Entity)的定义(src/entities目录下),编译成为Oak框架能使用的完整定义(src/oak-app-domain目录下)。当开发人员 修改了相关Entity后,也必须执行下述命令:

npm run make:domain

项目的Entity编写规范参见:entities

开发(前台模式)

在Oak框架的设计中,前后端并无区分。在开发模式下,可以将后端逻辑直接运行在前端环境中,使开发过程更加简洁高效。 前台模式也是推荐的开发模式,可以做到极速的“可见即所得”,编写业务大部分情况下应当使用这种开发模式

运行web端

在项目目录下运行

npm run start:web

运行成功后浏览器会自动打开

http://localhost:3000 即可进行开发调试。推荐使用Chrome浏览器,要注意的是,因为项目只运行在浏览器内部,其数据是和外界隔离的。

运行小程序端

在项目目录下运行

npm run start:mp

运行成功后,用微信开发工具打开wechatMp/dist目录

运行App端

Oak的App开发使用react-native技术栈,按照react-native开发教程配置好环境,连接手机后,在项目目录下运行:

npm run run:android/ios

也可以先运行:

npm run run:bundle

来进行原生安装文件打包,再执行

npm run start:native

启动react-native调试服务器。

开发(前后台模式)

如果你的应用开发过程中有以下需求,则需要使用前后台开发模式。

  1. 需要(后端)调用本地的其它应用程序
  2. 需要(后端)访问其它webservice接口
  3. 需要多前端(web + 小程序)同时调试程序

编译并运行后端

在项目目录下编译整个项目

npm run build

编译成功后,在lib目录下是编译后的js代码。 在项目目录的configuration/mysql.json中编辑本地数据库设置,并执行SQL语句先创建好相应的数据库。

create database xxx default character set utf8mb4;

当前框架要求使用MySQL 8.0以上版本。

再在项目目录下执行:

npm run server:init

初始化数据库。

然后执行:

npm run server:start

运行成功后后端服务器启动,并监听在3001端口上。

运行前端

在项目目录下执行

npm run start:web:server

即以前后端模式运行前端部分代码,此时前端会自动请求localhost:3001端口去访问后端服务器。同理,也可以以前后端模式运行小程序

npm run start:mp:server

要注意的是,如果在前端开发模式和前后端开发模式之间切换的话,需要先清除缓存,以保证切换成功(观察浏览器的Network是否发出请求是确认当前是否成功运行在前后端开发模式下的关键)。清缓存的命令是:

npm run clean:cache

部署

todo

项目目录结构

一个典型的Oak项目的主要目录结构如下:

- src
    - aspects
    - assets
    - checkers
    - components
    - configuration
    - context
    - data
    - endpoints
    - entities
    - features
    - locales
    - pages
    - ports
    - routines
    - timers
    - triggers
    - watchers
- lib
- web
- wechatMp
- native
  • src目录:存放项目主要的业务逻辑代码。目录按Oak的各种概念又分成多个子目录,关于src下面各种概念的介绍,在本章节将逐一对之进行介绍
  • lib目录:存放项目编译后的js文件
  • web目录:存放项目在web端的入口文件和路由文件等
  • wechatMp目录:存放项目小程序端的入口文件和各种配置文件
  • native目录:存放项目App端的入口文件和路由文件等,以及iOs/Android层的项目代码

项目开发

开发一个应用系统,主要编写的代码是在src的以下的目录当中。每个目录的含义和如何编写,我们将在本章的各节按照先后顺序分别介绍。

如果是开发web应用,在web目录下您还需要编写:

  • web/src/app/namespace下面的各namespace配置及布局
  • web/src/app/components下面被namespace引用的公共组件(如整个网站的header/footer等)

如果是开发小程序,在wechatMp目录下您还需要编写:

  • wechatMp/src/app.ts(app.less) 全局的一些事件处理和样式设计
  • wechatMp/src/project.config.json 小程序全局配置
  • wechatMp/src/app.json 小程序路由配置

如果是开发App,在native目录下您还需要编写:

  • native/App.tsx native/index.tsx 全局的一些事件处理和插件加载
  • native/router/index.tsx App路由配置

编写对象

对象和属性

Entity在Oak框架中代表一个实体对象,应用开发首先应将需求分解,设计出最基础的对象及相对应的关系,并在src/entities目录中对之加以定义。

一个对象拥有若干属性,在Entity定义文件中,可以像以下这样来定义对象及其属性。我们引用的代码是oak-general-business包当中的Address对象,它代表着一个地址。

import { String, Int, Boolean, Text } from 'oak-domain/lib/types/DataType';
import { EntityShape } from 'oak-domain/lib/types/Entity';
import { Schema as Area } from './Area';
import { EntityDesc } from 'oak-domain/lib/types/EntityDesc';

export interface Schema extends EntityShape {
    detail: String<32>;
    area: Area;
    phone: String<12>;
    name: String<32>;
    default: Boolean;
    remark?: Text;
    entity?: String<32>;
    entityId?: String<64>;
};

设计并编写Entity的一些规范如下:

  1. 需要用 export interface Schema extends EntityShape 来定义对象。我们设计Entity时,要么继承EntityShape(在其中定义了id等几个通用属性),要么继承并扩展已经设计好的对象(在下一小节的User对象中你们就可以看到例子),
  2. 需要使用 'oak-domain/lib/types/DataType' 中的类型来声明对象的属性
  3. 可以引用其它Entity,并将之声明为当前Entity的某一属性(此时称之为定义了对象之间的多对一关系)。例如在Address对象中就声明了area属性是代表(地址所属的)地区。

对象的属性也可以指向其自身。例如,在Area对象的定义中,就有一个 parent 属性指向它自己(Schema),代表该地区的上级地区。

动态多对一关系

在一些应用中,某对象A可能和不同的对象B/C/D……具有多对一关系(但这种关系是互斥的,不可能同时和多于一个对象具有这种关系)。此时,如果像这样设计A对象:

export interface A extends EntityShape {
    b?: B;
    c?: C;
    d?: D;
};

显然是一种比较臃肿的方式,同时也不具备良好的扩展性(比方说随着业务的发展,A又要指向更多的对象时,必须来修改其Entity定义)。

此时可以通过声明两个特殊的属性,告诉Oak框架,此对象可能会指向多个不同的其它对象。

export interface A extends EntityShape {
    entity: String<32>;
    entityId: String<64>;
};

注意,这里entityentityId是类型关键字,您不能拿它们去声明自身对象的其它属性,同时它们的类型也是固定的。

回到上面的Address对象的例子中,由于oak-general-business是较底层的抽象模块,我们想实现Address的功能,但并不想限制应用地址对象必须归属于哪个对象(例如,在某个应用系统中,不仅“用户”对象拥有地址,“公司”对象也拥有地址)。

问题来了,如何声明当前(拥有动态多对一关系的)对象和其它哪些对象可能有关联呢?只需要在要关联的对象中像下面这样声明就可以了,这里我们引用的代码来自oak-general-business中的User对象

import { Schema as ExtraFile } from './ExtraFile';
import { Schema as WechatQrCode } from './WechatQrCode';
import { Schema as Address } from './Address';
import { Schema as User } from 'oak-domain/lib/entities/User';

export interface Schema extends User {
    passwordSha1?: Text;
    birth?: Datetime;
    gender?: 'male' | 'female';
    idCardType?: 'ID-Card' | 'passport' | 'Mainland-passport';
    idNumber?: String<32>;
    files: Array<ExtraFile>;
    codes: Array<WechatQrCode>;
    isRoot?: Boolean;
    addresses?: Address[];
};

用户这个对象,(以一对多的方式)关联了三个拥有动态多对一设计的对象:ExtraFile, WechatQrCode, Address。这里我们不去深究其具体含义,只要知道,这三个对象中都设计有 entity/entityId 属性,同时我们在User对象的定义中声明了,User和它们具有一对多关系。这里用Array和[]的写法都是可以的。

如果一个对象想拥有超过一个的动态多对一关系怎么办?很遗憾,Oak并不支持这种设计。而且根据我们的经验,在实际应用开发中,出现如此复杂的对象往往意味着你应该重新审视你的对象设计是否合理了。

当一个对象A定义了与另一个对象B具有多对一关系时(无论是否是动态的),我们称B对象为A对象的父对象,同时称A对象为B对象的子对象

更多属性相关

Oak当前支持的基本属性类型可以参见:oak-domain/lib/types/DataType

其中,FloatDouble类型已经不再推荐,请尽量使用Decimal来定义非整型数值

除去这些类型之外,Oak还支持:

  • 以枚举的方式定义。例如在上一小节中Usergender属性
  • 以对象的方式定义。如果某个属性非常复杂,不能用基本类型表示。你既可以定义其为Object,也可以用TS来定义其具体格式

以下代码节选自oak-general-businessApplication对象。每个Application对象代表着系统的一个应用(Web/小程序/App……),因此它需要丰富的配置信息。

export type AppType = 'web' | 'wechatMp' | 'wechatPublic' | 'native';

export type NativeConfig = {
    type: 'native',
    passport?: Passport[];
};

export interface Schema extends EntityShape {
    name: String<32>;
    description: Text;
    type: AppType;
    system: System;
    config: WebConfig | WechatMpConfig | WechatPublicConfig | NativeConfig;
    style?: Style;
    sessions?: Session[];
    domain?: Domain;
};

在这里,type属性指向了一个枚举类型,config属性和style属性都指向更复杂的对象,config对象甚至指向一个复杂对象的枚举。

Action和State

在应用中的一些对象(核心业务对象),往往具有状态的概念。通过在Entity中定义其状态和动作,以及两者之间的关系,可以使应用更加严谨和可维护。

以上面所提到的 oak-general-business 中的User对象为例,在我们的设计中,用户这个对象有两种状态的转变:

  1. 对象状态:分为四种:正常用户/被禁用用户/被合并用户/影子用户。在这里我们不去深究后面两种的语义,只需要知道这代表着User的状态可能发生变化。比如说如果发现某个用户经常违规,管理员可以将之禁用,此时用户就变成了被禁用状态,将无法再登录系统。
  2. 对象身份状态:有的应用需要验证用户的真实身份,此时用户的身份状态就有三种:未认证/认证中/已认证

很显然这两种状态的转变是彼此独立的,我们可以通过以下的声明代码,来规范这两种状态。除了状态之外,我们也可以定义对User这个对象可能的动作,来规范状态的转变(以及后面的权限管理)。

export type IdAction = 'verify' | 'accept' | 'reject';
export type IdState = 'unverified' | 'verified' | 'verifying';
export const IdActionDef: ActionDef<IdAction, IdState> = {
    stm: {
        verify: ['unverified', 'verifying'],
        accept: [['unverified', 'verifying'], 'verified'],
        reject: [['verifying', 'verified'], 'unverified'],
    },
    is: 'unverified',
};

export type UserAction = 'activate' | 'disable' | 'enable' | 'mergeTo' | 'mergeFrom';
export type UserState = 'shadow' | 'normal' | 'disabled' | 'merged';
export const UserActionDef: ActionDef<UserAction, UserState> = {
    stm: {
        activate: ['shadow', 'normal'],
        disable: [['normal', 'shadow'], 'disabled'],
        enable: ['disabled', 'normal'],
        mergeTo: [['normal', 'shadow'], 'merged'],
        mergeFrom: ['normal', 'normal'],
    },
};

export type Action = UserAction | IdAction;

在这里,我们声明了两组状态和动作,以及状态转换矩阵,同组中的状态/动作/转换矩阵的前缀必须要一致。状态转换矩阵规定了某个Action将把User的当前(对应的状态值)修改成什么,这种转换在规定之后应当是一目了然的。其中,is定义的是初始状态(若对象创建时没有赋初始状态则默认使用这种状态值)。

最后,我们通过export type Action来声明了当前对象上的所有Action。对当前对象的操作将被限制在这些自定义的Action和通用的Action当中。通用的Action包括:

  • create
  • update
  • remove
  • select
  • count
  • download
  • aggregate
  • stat

在定义您自有的Action时请避免使用这些关键字。

Action是否必须与State相关联?并不是,完全可以定义某个Action并不改变对象的状态,甚至不改变对象本身的任何属性!实际上,我们鼓励根据应用的需求,尽量细的划分不同动作,以便实现更好的程序严谨性和权限管理。某种意义上,可以将自定义的Action看成一种特殊的update

Relation

几乎所有的应用系统都有一个共同的核心对象——用户。用户就是使用应用系统的人,而应用系统要对用户使用应用系统的行为加以甄别和管理(权限分配和限制),前提就是要将系统中的业务对象“分配”给合适的用户。Relation所表达的,就是当前对象和用户的关系

如果一个对象和用户具有Relation,则可以像下面一样定义,下面的代码节选自oak-general-business中的Session对象定义。

export type Relation = 'partner';

Session对象代表的是一个会话,而partner关系表达的就是参与这场会话的人。如果一个User和一个Session存在partner关系,则代表这个用户参与了这场会话。

Relation的底层实现

oak-domain/src/entities中,定义了一些公共对象,这些对象存在于所有的Oak项目中(尽管或许个别项目不会用到它们全部)。其中有一个对象UserRelation,其代表的就是用户和某个对象之间的Relation

import { Schema as User } from './User';
import { Schema as Relation } from './Relation';

export interface Schema extends EntityShape {
    user: User;
    relation: Relation;
    entity: String<32>;
    entityId: String<64>;
};

如果你理解了本章的内容,应当能理解UserRelation对象中各个属性的含义了吧。😁

EntityDesc

最后,我们需要为我们所有定义的内容进行描述。描述的目的有两个:

  1. 使此对象上的一些命名和风格全局一致(例如对象命名、对象属性命名等)
  2. 使此对象在存储时得到优化

对对象进行描述的代码如下面这样,我们仍然使用oak-general-business对象中的User对象举例:

export const entityDesc: EntityDesc<
    Schema,
    Action,
    '',
    {
        userState: UserState;
        idState: IdState;
        gender: Required<Schema>['gender'];
        idCardType: Required<Schema>['idCardType'];
    }
> = {
    locales: {
        zh_CN: {
            name: '用户',
            attr: {
                name: '姓名',
                nickname: '昵称',
                birth: '生日',
                password: '密码',
                passwordSha1: 'sha1加密密码',
                gender: '性别',
                idCardType: '证件类型',
                idNumber: '证件号码',
                ref: '指向用户',
                files: '相关文件',
                userState: '用户状态',
                idState: '认证状态',
                codes: '微信分享二维码',
                isRoot: '是否超级用户',
                addresses: '收货地址',
            },
            action: {
                activate: '激活',
                accept: '同意',
                verify: '认证',
                reject: '拒绝',
                enable: '启用',
                disable: '禁用',
                mergeTo: '合并',
                mergeFrom: '使合并',
            },
            v: {
                userState: {
                    shadow: '未激活',
                    normal: '正常',
                    disabled: '禁用',
                    merged: '已被合并',
                },
                idState: {
                    unverified: '未认证',
                    verifying: '认证中',
                    verified: '已认证',
                },
                gender: {
                    male: '男',
                    female: '女',
                },
                idCardType: {
                    'ID-Card': '身份证',
                    passport: '护照',
                    'Mainland-passport': '港澳台通行证',
                },
            },
        },
    },
    indexes: [
        {
            name: 'index_birth',
            attributes: [
                {
                    name: 'birth',
                    direction: 'ASC',
                },
            ],
        },
        {
            name: 'index_fulltext',
            attributes: [
                {
                    name: 'name',
                },
                {
                    name: 'nickname',
                },
            ],
            config: {
                type: 'fulltext',
                parser: 'ngram',
            },
        },
        {
            name: 'index_userState_refId',
            attributes: [
                {
                    name: 'userState',
                },
                {
                    name: 'ref',
                },
            ],
        },
    ],
    style: {
        icon: {
            verify: '',
            accept: '',
            reject: '',
            activate: '',
            enable: '',
            disable: '',
            mergeTo: '',
            mergeFrom: '',
        },
        color: {
            userState: {
                normal: '#0000FF',
                disabled: '#FF0000',
                merged: '#9A9A9A',
                shadow: '#D3D3D3',
            },
            idState: {
                unverified: '#FF0000',
                verified: '#0000FF',
                verifying: '#EEE8AA',
            },
            gender: {
                male: '#0000FF',
                female: '#EE82EE',
            },
            idCardType: {
                'ID-Card': '#E0FFFF',
                'Mainland-passport': '#2E8B57',
                passport: '#2F4F4F',
            },
        },
    },
};

在对象描述的类型定义EntityDesc中,可以传入四个参数,分别是对象定义Schema,对象动作定义Action,对象关系定义Relation,以及对象的相关状态和枚举属性定义字典。而对对象描述中,主要有:

  • locale定义,对对象本身/属性/属性枚举值/状态/动作/关系进行命名,这里的命名将被Oak框架编译成i18n的数据,实现全局命名的一致性(对编写优秀的应用极其重要!)
  • indexes定义,定义相关索引项。在这里定义的索引会在数据库建立的时候自动创建,也会规范对对象的查询方式(例如如果没有声明全文索引,则无法进行全文搜索)
  • style定义,定义对象的动作/关系/状态的颜色和图标(尚未实现),使前端渲染时可以获取相应的颜色,以实现颜色的全局一致性(同样对编写优秀的应用极其重要!)

由于TS的优秀的规范性,这里的定义如何详细书写不再赘述。

Entities的作用

Entity的定义可以说是整个应用业务的核心,通过执行

npm run make:domain

Oak框架会在src/oak-app-domain目录下编译出完整的应用对象定义,这份定义既包括开发人员编写代码时的Typescript类型定义,也包括程序运行过程中访问对象时的正确性检查。因此请牢记:

  1. 对Entity的任何修改都应当执行 npm run make:domain,使修改后的定义生效
  2. 如果应用已经上线迭代,对Entities目录下的任何修改,都应当在upgrade目录下维护好对数据库(结构或数据)的升级脚本,新版本上线时进行升级

查询和操作对象

编译

在编写或更新了Entity定义后,都需要执行命令来编译完整的对象数据字典

npm run make:domain

编译出来的数据字典声明在src/oak-app-domain目录下,同时也会编译出来一个数据的存储格式供框架引用,可以在代码中像这样去引用它们:

// EntityDict是数据字典声明,StorageSchema是存储格式定义
import { EntityDict, StorageSchema } from '@project/oak-app-domain';

数据字典和存储格式是整个Oak框架最核心的内容,贯穿于使用框架的各个层面,因此需要深刻理解。本章节将使用上小节的AddressArea对象,介绍一些查询和操作的核心概念。

编译后的对象结构

编译后的对象原生结构称为OpSchema,其结构仅仅在用户定义的属性上增加了一些通用的属性类型,以及将引用对象转化成了外键。

每个Entity的OpSchema可以在编译后的oak-app-domain/${Entity}/Schema.ts中查看,本章下面的大多数数据结构都是如此

对象上增加的通用属性包括:

属性类型含义
idstring<36>主键,uuid
$$createAt$$number创建时间戳(Date.now())
$$updateAt$$number更新时间戳
$$deleteAt$$number删除时间戳
$$seq$$int递增序列

查询(Select)

当查询一个对象时,传入的Select结构如下:

{
    data: Projection;
    filter: Filter;
    sorter: Sorter;
    indexFrom: number;
    count: number;
}

Projection

当查询对象时,通过Projection可以定义要查询对象的哪些属性(projection的命名本身就借鉴了数据库中的“投影”概念)。例如,对上一节中所定义的Address对象,查询时可以指定投影为:

{
    detail: 1,
    name: 1,
    phone: 1,
}

可以根据对象之间的关系将projection扩展到多对一的父对象上,实现级联查询

{
    detail: 1,
    name: 1,
    phone: 1,
    area: {     // 查询相关联的Area
        id: 1,
        name: 1,
        parent: {       // 查询Area所相关的更高层Area
            id: 1,
            name: 1,
        },
    },
}

也可以扩展到一对多的子对象上的级联查询,例如我们查询Area对象时,可以将之关联的Address一并查找出来:

{
    id: 1,
    name: 1,
    address$area: { // 查询和area相关的所有address对象
        $entity: 'area',
        data: {
            id: 1,
            name: 1,
            phone: 1,
        },
    },
}

通过这样的扩展,可以很方便的通过一次查询获得一个对象相关联的所有数据,查询结果会是一个复杂的数据对象(Schema

Oak框架还支持表达式查询和函数计算,例如如果想返回一个name和phone连接的字符串,可以这样写:

{
    id: 1,
    $expr: {
        $concat: [
            "姓名:",
            {
                '#attr': "name",
            },
            "手机号:",
            {
                '#attr': "phone",
            }
        ]
    }
}

Schema

Schema是对对象进行Select查询获得的数据结果格式。此时返回的对象除了自身的属性之外,还可能级联了其父对象与子对象的数据。 例如,在上面的例子里,查询到的Address数据结果中包含了其父对象Address的数据:

{
    id: 'xxx',
    name: 'xxxxx',
    phone: '139xxxxxxxx',
    areaId: 'xxxx',
    area: {
        id: '310100',
        name: '杭州市',
        parentId: '330000',
        parent: {
            id: '330000',
            name: '浙江省',
        },
    },
}

而查询到的Area数据结果就会包含其子对象Address的数据:

{
    id: '310100',
    name: '杭州市',
    address$area: [
        {
            id: 'xxx',
            name: 'xxxxx',
            phone: '139xxxxxxxx',
        },
        {
            id: 'xxx',
            name: 'xxxxx',
            phone: '139xxxxxxxx',
        }
    ]
}

Filter

Filter代表查询某个对象的条件,例如,我们要查询Area为杭州市,手机号码以139开头的Address,就可以这样写:

{
    areaId: '310100',
    phone: {
        $startsWith: '139',
    },
}

可见Filter的算子语法比较接近MongoDB,Oak框架在此基础上做了大量的扩展,例如,如果不知道杭州市的areaId,我们也可以这样写:

{
    area: {     // 把filter扩展到Area父对象上
        name: '杭州市
    },
    phone: {
        $startsWith: '139',
    },
}

同样的,Filter也可以被扩展到子对象上,比方说我们查询Area,条件是“该Area上至少有一条相关的Address,其手机号码以139开头”(不用考虑这个查询是否合理):

{
    address$area: {
        phone: {
            $startsWith: '139',
        },
    }
}

甚至我们可以查询“该Area上不能有任何一条手机号以139开头的ddress”:

{
    address$area: {
        phone: {
            '#sqp': 'not in',
            $startsWith: '139',
        },
    }
}

具体的Filter算子的语法比较丰富,我们罗列在下方,部分复杂的语法用户可以查阅oak-domain/src/types/Demand.ts

算子参数类型作用
$gtnumber | string大于
$gtenumber | string大于等于
$ltnumber | string小于
$ltenumber | string小于等于
$eqnumber | string | boolean等于
$nenumber | string | boolean不等于
$in(number | string)[]在……中
$nin(number | string)[]不在……中
$mod[number, number]取模
$between[number, number]在……之间(包括等于)
$startsWithstring以……开头
$endsWithstring以……结尾
$includesstring包含……
$existsboolean是否为空
$search
$language
string
'zh_CN'|'en_US'
全文检索(对象上必须声明了全文索引)
$andFilter[]
$orFilter[]

此外,Filter还能支持表达式计算和非结构化数据查询。todo

Sorter

Sorter表示查询时的排序。例如,我们查询Address时要求结果按手机号排序,可以这样写sorter:

[
    {
        $attr: {
            phone: 1,
        },
        $direction: 'ASC',
    },
]

写成数组的形式意味着我们可以按序支持多个sort条件,同时也支持将排序的属性扩展到多对一的父对象上:

[
    // 先按phone升序排,再按area.name降序排
    {
        $attr: {
            phone: 1,
        },
        $direction: 'ASC',
    },
    {
        $attr: {
            area: {
                name: 1,
            },
        },
        $direction: 'DESC',
    }
]

在查询中,如果不指定任何排序条件,则框架会自动添加一个$$createAt$$属性上的降序排序条件。

操作(Operate)

当要操作一个对象时,传入的数据结构如下:

{
    id: Uuid;
    action: Action;
    data: Data;
    filter: Filter;
}

Uuid

Operate的操作需要唯一编号,其作用是为了记录日志和在分布式环境下实现操作同步。可以使用下面两个函数来产生Uuid。

import { generateUuid, generateUuidAsync } from 'oak-domain/lib/utils/uuid';

一般来说,如果不是在有些前端环境中受到同步的限制,应优先使用异步产生函数。

Action

声明Operate的类型。有三种Action是公共的:create/update/remove,除此之外,用户在定义Entity时,所声明的Action也是有效的Action。

所有用户定义的Action从广义上来说都是update。如果用户在定义Action时,也定义了相应的状态转换矩阵(见编写对象),则在执行该动作时,会自动进行对象相应属性的状态检查以及更新其状态。

一般来说,对一个对象的操作如果有业务层面上的语义,推荐尽量细化成不同的Action,而不直接使用update。这样一来可以使对对象的操作历史更加清晰,二来也便于后续进行细粒度的权限控制。

Data

声明更新的数据。更新的数据可以是两种:

  1. 自身的数据属性
    例如要更新地址的phone和name:
    {
        phone: '138xxxxxxxx',
        name: '张小三',
    }

create操作必须传入id以及有效的所有声明非空属性,update只需要传入至少一个属性,而remove操作无需传入自身的属性。

  1. 级联数据属性 同Filter/Projection一样,更新的Data也支持级联更新,可以通过一次Operate请求,更新当前对象以及其级联对象上的属性。 例如,我们可以在更新Address时,同时去更新其相关联的父对象Area的数据(不考虑这个请求是否有意义):
    {
        phone: '138xxxxxxxx',
        name: '张小三',
        area: {
            id: '{uuid}',
            action: 'update',
            data: {
                name: '苏杭市',
            }
        },
    }

同样的,我们也可以在更新父对象Area时,更新其相关联的子对象Address的数据:

    {
        name: '苏杭市',
        address$area: {
            id: '{uuid}',
            action: 'update',
            data: {
                phone: '138xxxxxxxx',
            },
            filter: {
                name: '张小三',
            }
        }
    }

这条Operate在更新Area数据的同时,还会将“指向它的且『name为张小三』的所有的Address”数据的phone属性更新成138xxxxxxxx。

我们还可以在更新父对象的同时,插入一条子对象,像下面这样:

    {
        name: '苏杭市',
        address$area: {
            id: '{uuid}',
            action: 'create',
            data: {
                id: '{uuid}',
                phone: '138xxxxxxxx',
                name: '张小四',
                ....
            },
        }
    }

新插入的Address会自动和当前Area关联。

下面列出了框架所支持的级联更新的情况:

  • 子对象级联父对象
子对象父对象效果
createcreate父子对象和关联关系一起创建
updatecreate更新子对象、创建父对象及关联关系(如果原来子对象上有关联的父对象关系会丢失)
updateupdate更新子对象,同时更新关联的父对象
updateremove更新子对象,同时删除关联的父对象及关联关系
removeupdate移除子对象及关联关系,同时更新关联的父对象
removeremove同时移除父子对象,以及关联关系
  • 父对象级联子对象
父对象子对象效果
createcreate创建父子对象,并创建关联关系
updateupdate更新父对象,并更新关联的子对象
updateremove更新父对象,同时删除关联的子对象

编写组件/页面

设计完Entity后,您可立即进入应用页面的编写(Oak框架会自动处理发请求、取数据、缓存等一系列工作,不需要你再编写任何一行相关代码了😁)。

编写组件/页面可以认为是应用编写最复杂的部分,因此这部分内容也较为繁重。直接编写完组件/页面,您就已经拥有一个可以演示的应用了。

三层架构

在Oak框架中,前端部分的结构可以由高到低分为三个层次,如下图所示: 层次结构

namespace

namespace是指应用在最顶层被划分成几个命名空间,每个命名空间中包含若干页面,命名空间一般按顶层路由来划分,同一个命名空间中的整体布局是相同的。例如,一个典型的网站会分为frontendconsole两个命名空间,分别代表普通用户访问的前端和管理人员访问的控制台。前者有统一的header(页头)footer(页脚),而后者还会有统一的menu(菜单)。这些跨页面级别的组件摆放是在命名空间里处理的。

一般而言一个应用不会有过多的命名空间,命名空间被放置在web/src/app/namespaces目录下,命名空间的路由就是由目录名称决定的。

在命名空间目录下的index.json文件可以配置此命名空间的一些信息,例如在frontend/index.json中,往往有如下配置:

{
    "path": "/",
    "first": "/home",
    "notFound": "/result/404"
}

这代表此命名空间的路由就是根目录,首页是指向*/home*页面,而当应用出现无法识别的路由时,自动指向/result/404

一个命名空间下有哪些页面?在Oak框架中这是由src/pages目录下的目录结构自动决定的,在pages目录下,第一层目录是和命名空间名称对应的,其下的每个目录中的page就会被自动编译到对应的namespace下。

命名空间之间的跳转建议在全局统一且唯一(例如在frontend命名空间可以通过点击页上的“控制台”进入console命名空间),不要在普通页面之间跳转的时候跨越命名空间,这会容易让用户思维陷入混乱。

在大部分情况下,命名空间应该只存在于web端,对于小程序和App,一般不宜设计namespace,免得应用过于复杂。

page

一个page是前端的一个页面。page编写在src/pages/${namespace}目录下,每一个页面对应目录下的一个子目录。其路由和目录名保持相同。例如,在src/pages/frontend/home目录下的页面就会匹配到/home/(参见上一小节中frontend命名空间的配置)。

虽然并非强制,但推荐在src/pages/${namespace}下的page目录的第一层用该页面相关的entity名称命名,第二层可以用list/detail/upsert之一,或者该entity的某个Action来加以命名(如果页面的功能就是对此entity进行此action的话),这样此页面的功能看上去就一目了然。当然,对于像首页这样的复合性页面(并不是限定在某个entity上),您可以自由对之进行命名,只要这个命名让用户看上去很容易理解。

component

一个component代表一个固定功能的组件。组件编写在src/component目录下,同样是一个子目录代表一个组件。编写组件的目的是:

  1. 为了在多个page之间复用代码
  2. 避免page过于复杂

在Oak中,一个page可以包含多个component,一个component也可以引用其它component,但在component之间不能出现循环引用。

page之间不要相互引用,尽管这样做也不会错,但会让整个项目变得更加混乱,难以维护。

目录文件结构

src/pagessrc/components目录下,您可以编写应用页面和组件了。每个页面/组件都占据着唯一的子目录,在子目录下可能有如下若干文件:

文件名作用
index.ts定义页面/组件的逻辑(必需)
index.json与微信小程序的index.json作用相同
locales/zh-CN.json页面/组件的i18n内容
web.pc.tsx宽屏的html渲染
web.tsx窄屏的html渲染
web.pc.module.less宽屏样式
web.module.less窄屏样式
index.xml小程序渲染
index.less小程序样式
render.native.tsxApp渲染
render.native.module.lessApp渲染样式
render.ios.tsxios渲染
render.android.tsxandroid渲染

看上去有些复杂,但是实际上绝大多数项目都只会实现其中的部分文件。Oak框架在前端采取了一个较为保守的方案,以应对跨前端的一致性问题。其中心思想是:对页面的逻辑统一抽象(index.ts),而对页面的具体渲染则分别处理。

将前端各平台的渲染语法强行统一到一个框架之下(如taro, uniapp)的前景固然美好,但这会带来兼容性和扩展性的严重问题,同时也会造成与开源社区的割裂。这显然与Oak框架的设计目标背道而驰。因此Oak框架借鉴了微信小程序和React的设计思想,将页面的各种与渲染无关的逻辑部分抽象到index.ts当中,而将各个平台的渲染代码分割到各个文件之中,在对应平台下运行的时候进行加载,从而达到一致性和兼容性的较好平衡。

index.ts

在index.ts中,定义了本页面/组件的逻辑代码,此文件中应当避免引用任何平台相关的特性内容,而只关注于页面的逻辑能力。关于index.ts如何编写,请参见编写组件;而组件逻辑上的方法和数据项,可以参见定义组件对象

如果确实需要引用平台相关的特性内容,可以通过process.env.OAK_PLATFORM环境变量加以区别。例如,在支付时如果需要唤起平台的支付接口,可以像下面这样编写(代码引用自oak-pay-business/src/components/pay/detail/index.ts):

 if (process.env.OAK_PLATFORM === 'wechatMp') {
    const { prepayMeta } = meta as { prepayMeta: WechatMiniprogram.RequestPaymentOption };
    if (prepayMeta) {
        const result = await wx.requestPayment(prepayMeta);
        process.env.NODE_ENV === 'development' && console.log(result);
        return result;
    }
    else {
        features.message.setMessage({
            type: 'error',
            content: features.locales.t('startPayError.illegaPayData'),
        });
    }
}
else {
    features.message.setMessage({
        type: 'error',
        content: features.locales.t('startPayError.falseEnv', { env: 'wechatMp' }),
    });
}

在这里,当页面调用了支付事件时,如果判断当前环境是小程序,则调用wx.requestPayment方法唤起支付。

web端

如果您的应用是基于web端,只要在目录下编写web.pc.tsx和web.tsx,框架会在宽屏下自动载入前者,而在窄屏下自动载入后者。在tsx文件中,您可以按照React的规则使用任何您想用的组件,也可以使用useState等钩子函数。

如果您只建立了web.pc.tsx或web.tsx,则框架会在宽屏和窄屏下都使用这一文件渲染。这个判断建立在第一次编译项目之时,因此如果您在后面又加入了另一个文件,需要行执行npm run clean:cache清除掉编译缓存才能生效

微信小程序端

微信小程序端只需要编写index.xml,其语法和wxml完全一致,您也可以在index.json中的usingComponent域中声明所引用的其它组件。

App端

Oak框架的App端基于react-native,您只需要编写render.native.tsx,则可生成在IOs和Android下通用的页面。当然,与react-native的编译规则类似,您也可以编写render.ios.tsx或者render.android.tsx,分别在两个操作系统之下进行渲染。同样的,您可以在tsx文件中引用任何您想使用的第三方组件。

组件编写

定义完Entity后,可以马上开始编写组件/页面(以下统一用“组件”作为主语)。

在编写组件前,首先要明确一个概念:用Oak框架编写业务应用,其中几乎所有的组件和绝大部分页面都关联在某一个Entity之上,根据组件的功能特征,我们可以将这类Entity组件划分为以下三种类型:

  • List:列表页,查询并显示此Entity的多条数据
  • Detail:详情页,查询并显示此Entity的(指定id的)某条数据
  • Upsert:更新页,查询并对此Entity的(指定id的)某条数据进行更新,或者创建一条新的数据。

当然,也允许用户编写一个不关联在任何Entity上的组件,我们称之为Virtual组件。关于Virtual组件的作用和规范,我们在组织组件章节中会加以描述。

下面将以oak-general-business包当中的SystemApplication这两个Entity作为示例,介绍如何编写Entity组件。

oak-general-business是Oak框架的一个基础业务逻辑包,在其中按照Oak框架的规范,实现了大量较为底层的功能逻辑,如用户管理、应用管理、授权管理、消息管理,文件管理等等。关于oak-general-business包的详细介绍请参见:todo。在这里,我们只需要了解ApplicationSystem这两个对象的意义和大概结构,在oak-general-business中,System代表一个业务系统(就是你正在开发的这个业务系统),而Application代表业务系统的一个前端应用。两者的数据结构大致如下(代码来源于oak-general-business/src/entities,为了简化去掉了一些属性):

  • Application
export interface Schema extends EntityShape {
    name: String<32>;
    description: Text;
    type: AppType;
    system: System;
    config: WebConfig | WechatMpConfig | WechatPublicConfig | NativeConfig;
    style?: Style;
};
  • System
export interface Schema extends EntityShape {
    name: String<32>;
    description: Text;
    config: Config;
    super?: Boolean;
    style?: Style;
};

在系统上线前,您需要配置相应的ApplicationSystem,例如小程序appId/appsecret、网站域名之类的属性。在这里我们并不过多介绍这两个对象的作用,只需要知道,Oak应用本身也是通过这两个Entity来进行管理的,超级管理员在系统正式运行之前需要对它们进行相应的管理操作(通过oak-general-business提供的这些组件。而在绝大多数业务系统中,这些参数都是通过config文件配置为系统的全局变量,这显然不如将之也当成业务对象来管理更加灵活、通用)。我们接下来就结合代码描述这些相关组件的实现。

编写详情组件

本组件代码可参看oak-generail-buiness/src/components/system/detail

现在,我们需要有一个组件对本System的信息进行读取并显示。在这里注意,在很多情况下,访问对象的过程是先get list获得满足条件的列表,再对其中的某一条数据进行get detail。但System对象比较特殊,它代表着当前正在访问的业务系统,因此不需要通过先设计list组件去查询其id,而是通过其它方法获得。我们先不关心这一过程具体如何实现,假设当前System的id已经得知。

逻辑层(index.ts)

在index.ts逻辑层,我们直接通过定义要访问的这条System数据的相关属性来获取它,代码大致如下:

export default OakComponent({
    isList: false,
    Entity: 'system',
    projection: {
        id: 1,
        name: 1,
        config: 1,
        description: 1,
        super: 1,
        folder: 1,
    },
    formData({ data }) {
        return data || {};
    },
});

代码非常简洁直观,我们通过调用OakComponet来定义一个组件,组件访问的对象是System;此组件不是一个List组件,这意味着需要告知此组件要访问的数据行的id是多少(这是通过向组件传入OakId来实现的);在projection中定义要访问这行数据的属性有哪些,这里的属性明显和上面System对象的定义是保持一致的;最后在formData方法中,将取到的数据属性返回(提供给渲染层)。

通过这几行短短的代码,我们就已经实现了将一条System数据从后台取到前台的功能,接下来我们来渲染它。

渲染页面(web.pc.tsx)

在web.pc.tsx中,我们像下面这样来编写代码:

import React from 'react';
import { Tabs } from 'antd';
import { WebComponentProps } from 'oak-frontend-base';

export default function Render(props: WebComponentProps<EntityDict, 'system', false, {
    id: string;
    config: Config;
    name: string;
    style: Style;
}>) {
    const { id, config, oakFullpath, name, style } = props.data;
    const { t } = props.methods;
    
    if (id) {
        return (
            // 利用name、stype等属性进行页面渲染
        );
    }
    return null;
}

Render函数的唯一参数props中,Oak框架将以WebComponentProps定义的格式,注入了两个属性:

  • data: 在data中存放从逻辑层传递过来的数据,以及Oak框架的一些通用变量(例如上面代码中的oakFullpath,这类变量都以oak开头)。从逻辑层传递过来的数据则包括:
    • formData中返回的数据(在上面的例子里,是从所取到的system行当中展开的属性)
    • data和property中声明的数据。
  • methods: 在methods中存放从逻辑层注入的方法,以及Oak框架注入的一些通用方法(例如上面代码当中的t方法,就是框架所注入的i18n方法)。其中,组件自身逻辑层注入的方法声明在OakComponent所声明的methods属性中。

使用组件

下面需要将上面的组件嵌入到系统的某一个页面当中。如果您在初始化项目时依赖了oak-general-business库,则这个组件已经被pages/console/system/config页面所引用。

在该目录下的web.pc.tsx中,可以看到如下代码(已进行简化):

import React from 'react';
import SystemPanel from 'oak-general-business/es/components/system/panel';
export default function Render(
    props: WebComponentProps<
        EntityDict,
        'system',
        false,
        {
            systemId: string;
        }
    >
) {
    const { systemId, oakFullpath } = props.data;
    if (oakFullpath) {
        return (
            <SystemPanel
                oakId={systemId}
                oakPath={`${oakFullpath}.system`}
            />
        );
    }

    return null;
}

在这个页面上,我们将渲染oak-general-business所提供的system/panel组件(上面描述的system/detail组件是panel中的一个tab页,见下图),并传入了两个非常重要的参数:

  • oakId:对于非List页面,都需要传入该行的ID,这里我们将当前的systemId传入(至于从哪里取到这个id的,可以参见同目录下的index.ts,在此不作展开)
  • oakPath:对于所有关联Entity的组件,都需要指出它们在页面中的“路径”。在这个例子里,我们将SystemPanel组件放置在当前组件(其实是页面,其路径由oakFullpath所指代)的system子路径下。

关于页面和组件的“路径”规范及意义,我们将在todo章节详细解释。

现在,执行npm run start:web,运行项目后,进入 localhost:3000/console/system/config 页面,可以看到类似下面的效果: systemPanel

编写更新组件

在上一节,我们描述了如何编写System的详情组件,接下来我们描述如何编写一个System的更新组件。代码见oak-general-business/src/components/system/upsert目录。

更新组件也是针对对象的单行进行操作,因此,其index.ts和详情组件没有太大区别,如果我们查看代码,会发现在这里没有声明projection。

export default OakComponent({
    isList: false,
    entity: 'system',
    formData({ data }) {
        return data || {};
    },
});

可以这样做的原因,是我们在detail组件中将引用此upsert组件,并声明它的oakPath和detail的oakFullpath一致。这样两个组件就将共享路径上的结点,可以直接使用detail声明的projection。

更新数据

我们重点来看如何更新数据。在web.tsx文件中,可以看到如下代码:

import React from 'react';
import { Form, Switch, Input } from 'antd';

import { EntityDict } from '../../../oak-app-domain';
import { WebComponentProps } from 'oak-frontend-base';

export default function Render(
    props: WebComponentProps<
        EntityDict,
        'system',
        false,
        {
            name: string;
            description: string;
            folder: string;
            super: boolean;
        }
    >
) {
    const {
        name,
        description,
        folder,
        super: super2,
    } = props.data;
    const { t, update } = props.methods;

    return (
        <Form
            colon={true}
            labelCol={{ span: 6 }}
            wrapperCol={{ span: 16 }}
        >
            <Form.Item
                label={t('system:attr.name')}
                required
            >
                <Input
                    onChange={(e) => {
                        update({
                            name: e.target.value,
                        });
                    }}
                    value={name}
                />
            </Form.Item>
            <Form.Item
                label={t('system:attr.desc')}
                required
            >
                <Input.TextArea
                    onChange={(e) => {
                        update({
                            description: e.target.value,
                        });
                    }}
                    value={description}
                />
            </Form.Item>
            <Form.Item
                label={t('system:attr.isSuper')}
                required
                tooltip={t('tips.isSuper')}
            >
                <Switch
                    checkedChildren={t('common::yes')}
                    unCheckedChildren={t('common::no')}
                    checked={super2}
                    onChange={(checked) => {
                        update({
                            super: checked,
                        });
                    }}
                />
            </Form.Item>
        </Form>
    );
}

在这里,我们利用了antd当中的部分输入组件,并在props.methods中引出了一个框架提供的update方法,这个方法可以将更新的属性记录在当前组件的组件树结点之中。要注意的是,此时formData函数所取到的数据项data是应用了相关更新后的数据。

提交更新

通过上面的代码,我们已经可以更新某一对象的数据项了。如何将更新提交呢?可以使用框架提供的execute方法。

在这个例子中,我们并没有把execute交给system/upsert组件来执行,这是因为如果将提交按钮放在此组件上,会降低此组件的复用性(在其它页面或组件上,System的upsert可能只是一个子对象比如Application upsert的一部分,如果您现在还不能理解这个也没有关系,先跳过这一段即可)。

因此我们将提交动作放在system/detail组件的代码中,这部分代码大致如下:

export default function Render(
    props: WebComponentProps<
        EntityDict,
        'platform',
        false,
        {
            name: string;
            description: string;
            oakId: string;
            folder: string;
            super: boolean;
        }
    >
) {
    const { oakId, folder, name, description, 'super': isSuper, oakFullpath, oakExecutable, oakExecuting } = props.data;
    const { t, execute, clean } = props.methods;

    const [open, setOpen] = useState(false);
    if (oakFullpath) {
        return (
            <>
                <Modal
                    open={open}
                    onCancel={() => {
                        clean();
                        setOpen(false);
                    }}
                    width={500}
                    footer={
                        <Space>
                            <Button
                                // type='primary'
                                onClick={async () => {
                                    clean();
                                    setOpen(false);
                                }}
                                disabled={oakExecuting}
                            >
                                {t('common::action.cancel')}
                            </Button>
                            <Button
                                type="primary"
                                onClick={async () => {
                                    await execute();
                                    setOpen(false);
                                }}
                                disabled={
                                    oakExecutable !== true || oakExecuting
                                }
                            >
                                {t('common::action.confirm')}
                            </Button>
                        </Space>
                    }
                >
                    <div className={Styles.upsert}>
                        <SystemUpsert oakId={oakId} oakPath={oakFullpath} />
                    </div>
                </Modal>
                ...
                <Button
                    type="primary"
                    onClick={() => setOpen(true)}
                >
                    {t('common::action.update')}
                </Button>
                ...
            </>
        );
    }
    ...
}

在上述代码中,当点击“更新”按钮时,就弹出一个Modal对话框,Upsert组件放置在对话框中,并且其oakPath与当前组件保持一致(在宽屏下这是一个常见的设计模式)。当对话框的确定按钮被点击时,则触发execute行为,此时本结点(以及子孙结点)上所有的更新会被执行,因此这是一个异步动作。当执行完成后,Modal才被关闭。

system upsert

你可能会注意到,在上述例子中,有好几个框架级别的变量被使用,比如:OakExecuting表示是否在更新中,oakExecutable表示更新是否可行。关于系统有哪些变量,可以查阅todo章节。

新建数据

新建数据的逻辑和更新非常相似,需要对对象的属性进行update操作,并执行exeute来提交数据,这也是为什么在Oak框架里我们常常使用Upsert这个名词。有几个需要注意的要点:

  1. 如果一个组件在创建之时就没有设置oakId,则框架会自动执行create动作,为它创建出一条“新”数据。因此,当在使用Upsert组件来更新数据时,如果确认是更新,则应当等确认了主键之后再渲染该组件。像下面的代码(这里模拟的是在一个Application Upsert组件上,嵌入一个System的Upsert组件去插入或更新它所指向的System行,我们毌须去关注这个需求是不是合理):
<SystemUpsert oakId={application?.systemId} oakPath={`${oakFullpath}.system`} />

这样编写代码是很危险的,除非你确认跑到这里的时候,application一定已经确认取到值了,否则就会出现这样的情况:当SystemUpsert组件开始渲染的时候,application还未取到,所以其oakId就是undefined,组件初始化时,认为是一个create动作,所以新建了一行system数据,而当application数据取到后,再用其systemId去置oakId,此时系统会认为这是异常情况而报错。

正确的写法应当是:

{!!application && <SystemUpsert oakId={application.systemId} oakPath={`${oakFullpath}.system`} />}

这样系统就会正确的根据application.systemId是否有值,正确的决定相应的System应当是create还是update。

  1. 要判断当前组件是update还是create,有两种方法:
    • 在OakComponent中,调用*this.isCreation()*方法
    • 如果一行数据是create,则在formData中取到的data,其$$createAt$$值为1
  2. 一个create页面,在提交之后不会再自动进行create,如果您需要这个组件进行连续的create,需要像下面这样,在execute之后显式调用this.create
    ...
    await this.execute();
    await this.create({
        ...
    });
    ...

编写列表组件

接下来我们来看看列表组件的例子。在system/panel组件里,我们不仅想管理当前System的信息,还想管理属于它的Application对象信息,回忆我们在介绍system/detail组件中的这张图:

systemPanel2

在左侧点击“应用管理”时,我们希望展示(属于这个System)的所有Application的信息列表,这是一个列表组件,其代码见oak-general-business/src/components/system/application目录。

逻辑层

index.ts中,我们要声明这是一个List页面(isList属性为true),给出完整的projection。

export default OakComponent({
    entity: 'application',
    isList: true,
    projection: {
        id: 1,
        name: 1,
        config: 1,
        description: 1,
        type: 1,
        systemId: 1,
        domainId: 1,
    },
    formData({ data }) {
        return {
            applications: data || [],
        };
    },
});

注意,此时在formData中,data对象就是一个数组,其结构可以参见TypeScript的类型定义。

渲染层

渲染层和详情页面也是类似,在这里取得了props.data中的相关属性以后即可进行渲染(注意在上面的页面里,我们把Application的三行数据又渲染成了一行tabs页,因为在每个Application中也有很多设置需要展示)。

import React, { useState } from 'react';
import { Tabs, Modal, Button, Space } from 'antd';
import { WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '../../../oak-app-domain';

export default function render(props: WebComponentProps<EntityDict, 'application', true, {
    applications: EntityDict['application']['OpSchema'][];
    systemId: string;
}>) {
    const { oakFullpath, applications, oakExecutable, oakExecuting, systemId } = props.data;    

    if (oakFullpath && applications?.length > 0) {
        return (
            <Tabs>
                {
                    applications.map(
                        ele => <Tab>....</Tab>
                    )
                }
            </Tabs>
        );
    }

    return null;
}

组件的相对路径

在上面的例子中,你可能会意识到一个问题,详情组件和更新组件在初始props中会自带主键(oakId),列表组件在定义查询数据的时候难道没有约束条件吗?比如上面的system/application组件,所查询的Application对象应当是满足systemId=XXXXX(system的主键)约束才对(可以到这里复习一下这两个数据结构之间的关系)。没错,这个问题非常关键。在list组件中,我们可以通过filters域来定义对数据的查询约束,像下面这样:

export default OakComponent({
    entity: 'application',
    ...,
    properties: {
        systemId: '',           // 定义组件接受一个类型为string的属性systemId
    }
    filters: [
        {
            filter() {
                const { systemId } = this.props;        // 从this.props中可以取到对应的systemId
                return {
                    systemId,
                };          // 返回的filter要满足对应Entity上的定义。
            }
        }
    ],
});

不过在Oak框架里,我们更推荐用另一种方式来处理主外键之间的关系,就是通过设置组件之间的相对路径来实现。我们查看oak-app-domain中编译出来的Application对象声明,其Schema中有这样的定义:

(oak-general-business/src/oak-app-domain/Appliction/Schema.ts)

export type Schema = EntityShape & {
    name: String<32>;
    description: Text;
    type: AppType;
    systemId: ForeignKey<"system">;
    config: WebConfig | WechatMpConfig | WechatPublicConfig | NativeConfig;
    style?: Style | null;
    domainId?: ForeignKey<"domain"> | null;
    system: System.Schema;
    ...
};

同样,在SystemSchema定义中也有:

(oak-general-business/src/oak-app-domain/System/Schema.ts)

export type Schema = EntityShape & {
    name: String<32>;
    description: Text;
    config: Config;
    platformId?: ForeignKey<"platform"> | null;
    folder: String<16>;
    super?: Boolean | null;
    style?: Style | null;
    entity?: String<32> | null;
    entityId?: String<64> | null;
    application$system?: Array<Application.Schema>;
    ...

这表示,在Application中,其system属性是一个System对象,而在System对象中,其application$system属性是Application对象的数组。在Oak框架中,这种对象之间的关系被贯彻在方方面面。在这里,我们在组件之间也可以应用这种关系,来定义组件(关联的对象)之间是什么关系。

在上面这个例子里,我们可以在system/panel组件中像这样来使用system/application组件,用oakPath来标定两者之间的关系:

...
{
    ...
    children: (
        <ApplicationList
            oakPath={`${oakFullpath}.application$system`}
            systemId={id}
        />
    ),
},
...

用了这样的定义,相当于告诉框架“此applicationList组件所查询的Application,是关联在其父结点的System对象上(框架会要求其查询数据时符合systemId = 父system.id的约束)。

当你理解了上面这个例子,就会意识到,绝大多数的页面中的组件关系,和它们所属的对象关系是完全对应的。通过仔细划分这些对象到子组件上,可以让整体代码组织的相当优雅,并且易于重用和维护。在下一章节中,我们将进一步描述这种关系的细节。

组织组件

在上一小节的例子中,我们已经展示了如何利用相应路径关系来表达组件之间的逻辑关系,从而有效组织组件。在这一小节我们将详细描述如何组织组件。

组件树

在oak框架中,每一个组件都被映射到一颗“组件树”上的结点。当然这里用“树”并不准确,其实这些结点之间是一片“森林”,每个树代表一个页面。树的根结点就是Page组件。

每个组件在初始化时,建立其组件树上的结点,其结点的位置由oakPath这个属性所决定。因此,所有的关联到Entity上的组件都需要设置其oakPath。唯一的例外是根结点也就是Page页面,它们的oakPath是在初始化时由框架自动编译形成的,其值就等于它们在src/pages目录下的相对路径。

当组件在组件树上构建完成后,框架会给其标定一个它的路径,这个属性就是前面经常用到的oakFullpath。因此,当在一个组件中包含其它子组件时,通过像这样设置其oakPath即可告诉组件树,将它关联到自己的子结点中,相对路径就是relative

oakPath={`${oakFullpath}.${relative}`}

当然,这里的relative并不能随便编写。如果父组件和子组件都有相关的Entity,它需要正确表达父子Entity之间的关系,您可以阅读并参见相关章节。如果父组件是一个Virtual组件(不关联在任何Entity上),而子组件是一个Entity组件,则相对路径应该是:

  • 如果子组件是一个List组件,则relative为 ${Entity}s,Entity是子组件所属对象;
  • 如果子组件是一个Detail/Upsert组件,则relative为${Entity},Entity是子组件所属对象;

父子组件的关系

如果组件和其子组件都是Entity组件,则它们之间有三种典型的关系:

  • 一对一:父亲是一个detail/upsert组件,其子组件也是一个detail/upsert组件,此时父子组件所关联的Entity必然是多对一(子->父)。例如:假设我们设计了一个Application的详情页面,其中也要渲染其关联的父对象System信息,则可以分成两个组件编写,并通过设置<SystemDetail oakPath={`${oakFullpath}.system}` />来指定其相对关系。
  • 一对多:父亲是一个detail/upsert组件,其子组件是一个list组件,此时父子组件所关联的Entity是一对多(父->子)。在上一小节的例子里就是这种关系,一个System的详情组件包含了一个子对象Application的列表组件(一对多关系)。
  • 多对一:父亲是一个list组件,其子组件是一个detail/upsert组件,此时,父子组件所属的Entity一定是相等的,它们之间的相对路径应是子组件对象的主键:<SystemItem oakPath={`${oakFullpath}.${system.id}`} />

在三种关系中,前两种关系都出现在父页面为detail/upsert页面时,此时,父子组件可以各自负责自身的取数和渲染,代码分离较为干净,而在第三种多对一关系中,如果子组件是无条件渲染(例如子组件渲染的是列表组件中的一行详情展示),则父组件的projection应当能覆盖子组件(甚至子孙组件)需要的projection,也就是说父组件应当帮助子组件取数。如果您未能保证这一点,当子组件渲染时发现数据不完整,则会自己发起请求去获取完整的数据,这可能会导致大量并发的网络请求。

Virtual组件

有些时候,我们需要定义一个不关联在任何Entity上的组件,这种需求往往发生在像首页/看板这种需要取不同Entity的数据,放在一起给用户加以展现的Page层。这时,我们可以将Page定义成一个Virtual组件,再将其下的不同Entity分割成不同组件加以处理。

定义Virtual组件,只需要在OakComponent定义时,不定义entity属性即可。

因为Virtual组件并不关联在任何对象上,所以它的formData方法中并没有可用的data数据,同时也无法使用update/create/updateItem这些框架方法去更新数据。但是它的refresh方法(刷新)和execute方法(提交更新)仍然是有效的,它会像下面所述的级联取数/更新行为,对其包含的所有子组件生效。

级联取数/更新

通过有效组织组件,不仅可以使代码更加的优雅和可重用,同时还获得了Oak框架的一个强大的能力——级联取数/更新。回忆一下,我们的组件之间的相对路径,就是对象之间的级联属性,因此,对象之间的级联关系,也被完美的映射到了组件上。

详细来说,当在一个父组件上执行刷新(调用this.refresh方法)时,其所有的相对路径上的子组件都会刷新,注意,这里的刷新是会将子组件的projection合并到父组件的对象之上统一执行,以获得性能的提升。

而当在一个父组件上执行提交(调用this.execute方法)时,其所有的相对路径上的子组件所产生的更新,都会提交到服务器。同样的,这里所有的更新数据也会被提升到父组件的对象之上进行统一执行。

共享路径

父组件和子组件在某些特殊的情况下,也可以共享路径,从而达到共享组件树结点的目的。

只要设置子组件的oakPath为当前组件的oakFullpath,就可以实现共享路径和组件树上结点的目标:

oakPath={oakFullpath}
父组件没有实际逻辑

一种常见的情况是父组件为Page,引用子组件进行渲染。由于取数和渲染逻辑都写在子组件中,父组件只是一层wrapper。此时如果将父组件设计为Virtual组件过于臃肿,因此可以在声明父组件时不声明projection,并使父子组件共享同一路径,此时框架会在子组件渲染时才进行组件树的取数逻辑。

子组件是抽象组件

有些子组件是抽象组件,并不限定在某一具体Entity上。这类组件在使用时,和其父组件可以通过共享路径来实现这种抽象逻辑。例如,在oak-frontend-base库中,提供了诸如list/detail/filter等抽象类组件,像list组件可以渲染出一个对象列表,在使用时就要声明其oakPath和当前(List)结点的oakFullpath一致。

多个子组件是对同一行对象的更新

在有些设计中,由于对某一对象的某类操作需要涉及更新大量的属性,在设计时会采用共享路径的方式,使他们的更新被缓存在同一个组件树结点之上。像下面这样的步骤条设计中,多个步骤条下的子组件如果是更新同行数据,它们的oakPath可以设置成一致。

步骤条 这种情况要注意,第一个渲染的子组件应负责取数,而后续渲染的子组件的projection会被忽略。

子组件的相对路径重复

有的时候,需要在同一个相对路径下渲染多个不同的子组件,这种情况在一对多的关系中最常见。例如,假设在一个System的detail组件中,我们希望用两个子组件来分别渲染:

  • 第一个子组件渲染所有类型为Web的Application(list)
  • 第二个子组件渲染所有类型为小程序的Application(list) 因为两个组件都是Application对象的list,且它们相对System父结点的相对路径都是application$system,但它们并不是上面所描述的共享结点的情况,因为两个组件渲染的数据并不一样(filter会有区别)

这种情况,可以分别定义两个组件的oakPath为${oakFullpath}.application$system:1${oakFullpath}.application$system:2 ,通过冒号和后面的字符来进行区分。

绝对路径

在有的情况下,某个组件的渲染需要引用另一个对象的组件,而它们(所属的对象)之间并没有直接的关联关系(请注意,这种情况你应该首先检查一下,你的设计是否合理),此时无法通过相对路径来标定两者之间的绝对关系,此时我们可以为该子组件设置一条绝对路径:

oakPath="ogb-system-upsert-application-upsert"

绝对路径的命名应当稍微复杂一点,以保持其在全局唯一(可以用*包名+当前组件路径名+子组件路径名来进行命名)。

一个子组件如果使用了绝对路径,它和父组件之间就没有任何关联关系。父组件的refresh/execute都不会与之发生任何联系。

定义组件

通过OakComponent接口可以定义一个组件对象。在前几章,我们已经看到部分重要的定义配置项,完整的配置项及格式可以参见oak-frontend-base/src/types/Page.ts文件。在编写代码时,typescript也会给出有效的提示。在本章节我们将归纳并进一步介绍部分配置项,以及组件本身可引用的数据项和方法。

配置项

配置项类型作用
entitystring | () => string组件关联的Entity
isListboolean组件是否为列表页
projectionobject | () => object组件所取的Entity属性
filtersarray组件取Entity的过滤条件(仅对list组件有效)
sortersarray组件取Entity的排序(仅对list组件有效)
paginationobject组件取Entity的分页设置(仅对list组件有效)
getTotalnumber | object组件取Entity时,取满足条件的行总数设置(仅对list组件有效)
propertiesRecord<string, any>组件接受的props
dataRecord<string, any>组件自身的data(React中的state)
featuresarray组件需要监听哪些features的变化
actionsarray组件需要检查哪些actions的许可
zombieboolean组件是否保留数据
formData(params) => Record<string, any>组件处理取到的Entity行数据逻辑
lifetimesRecord<string, function>组件的生命周期方法
listenersRecord<string, function>组件监听属性事件
methodsRecord<string, function>组件自定义方法

entity

本组件关联的对象,如果不设置,则这个组件就是Virtual组件。

projection

组件需要取的对象的属性,结构就是此Entityprojection。可以通过级联取其相关的对象数据。

filters

list组件需要取的对象的过滤条件,结构就是此Entityfilter。由于一个列表页可能会更新不同的过滤条件,因此这里用的是数组结构。您可以在组件的方法中调用addNamedFilterremoveNamedFilter等接口对过滤条件进行增删。

sorters

list组件需要取对象的排序条件,结构就是此Entitysorter。和filters类似,这里用的也是数组结构。

pagination

list组件取数据的分页设置。

  1. currentPage:起始页号,默认0,
  2. pageSize:页面数据条数,默认20,
  3. randomRange:随机取数据范围,用于一些特殊场合。在randomRange标识的范围内随机取pageSize条数据。 pagination还支持数组形式,用一个deviceWidth来标定宽窄屏下不同的配置。

getTotal

list组件是否取当前条件下的行总数量 有时列表页想取得满足当前条件的行数量,此时可以在getTotal上配置一个最大值,框架就会去取得不大于这个数值的总数量。如果满足条件的行的数量大于这个数值,则系统返回此数值(比方说有1亿行数据,此时系统不会去查询这么大量的数据返回1亿,而是只返回getTotal的值),这种设计的用意是当行数量极大时节省系统性能。

和pagination一样,getTotal也支持分设备配置。

properties

组件接受的外部属性值(相当于React中的props)。注意,在小程序环境下,会根据properties的类型向小程序环境注册其属性类型。因此即使属性默认值是null或者undefined,在这里也要用该属性类型来声明初始值,并在组件方法中处理。建议的各种类型的初始值可以是:

  1. number: 0;
  2. string: ''(空字符串);
  3. object: null;
  4. array: [];
  5. function: () => undefined as void;

data

组件的自身内部属性值(相当于React中的state)。

features

组件要监听所配置feature的变化。例如,如果一个页面与用户是否登录状态有关,就应当关联在oak-general-businesstoken上,这样当用户登录或退出时,本页面就会被通知。关于features的介绍请参见todo。

对于每个feature发生变化可以定义监听后的行为:

  1. refresh:组件刷新数据
  2. reRender:组件重新渲染
  3. 自定义回调函数

有些系统级别的feature会被组件默认jtkf,如所有非Virtaul的组件都会默认监听cache的变化,而所有的组件都会监听locales的变化。

actions

组件需要对取得的数据行进行某些action的判定,其值为EntityAction

在Oak框架中,所有的action的行为判定都应该写成checker,加上相关权限判定。组件在取得行数据后,会自动判定当前用户是否可以在这些行上执行这些actions,判定的结果在FormData中作为参数的一部分返回。

zombie

声明组件在析构后是否保留其数据。注意,这个声明只能在顶层组件也就是Page上声明

zombie意味着这个页面在析构时,其在框架中执行的结果——如增加的filters/sorters,更新但尚未提交的数据——是不会丢失的,当用户回到这个页面时,可以恢复到上次的状态。

比方说您在创建某个特别复杂的对象中途,因为某些原因切换到了其它页面上,等回到这个页面时,如果不希望填写了一半的数据消失,就可以使用这个声明。但是要注意,如果在应用中可能某个页面有多个不同的场景,需要仔细设计避免不正确的重用。

formData

当框架被某个feature通知数据有更新时,组件就可能进入重新渲染(reRender) 逻辑。重新渲染会先调用formData函数,并将返回的结果也置在组件的data域中,再调用相应平台下的渲染代码。

formData函数接受一个对象输入参数,其形式声明如下:

options: {
    data: IsList extends true ? RowWithActions<ED, T>[] : RowWithActions<ED, T>;
    origin?: IsList extends true ? RowWithActions<ED, T>[] : RowWithActions<ED, T>;
    features: BasicFeatures<ED> & FD;
    props: TProperty;
    legalActions?: ActionDef<ED, T>[];
    originLegalActions?: ActionDef<ED, T>[];
    dirty: boolean;
    modified: boolean;
}
  • data: 取到的行数据,组件为list时是数组,非list时是对象。在行的oakLegalActions当中返回了声明的Actions中通过的(该行上的)合法操作。
  • origin: 行数据的前项。如果在组件上对行数据有更新但未提交,这里返回的是更新前的原数据。
  • features:所有的features。
  • props:组件的props。
  • legalActions:当组件不是list时,返回该行通过的Actions;如果组件是list,只有当Actions中声明了create时,这里返回的是该用户是否有创建新数据的权限(只有create动作和具体行无关,每行数据相关的action动作检查结果都在该行的oakLegalActions上)。
  • originLegalActions:和legalActions一样,只不过检查的是origin数据上的动作权限。
  • dirty:行上是否有更新,调用组件的update/updateItem方法可以更新行上的属性。
  • modified:行是否真的被更新。和dirty的区别在于:如果将行上的某原值为1的属性改为2,再改为1,此时dirty为true,而modified为false。

formData需要返回一个对象,此对象会被合并到组件的内部数据项(this.state)上,供渲染层和方法层使用。

lifetimes

组件的生命周期函数。组件有以下几个生命周期:

  1. created
  2. attached
  3. ready
  4. detached
  5. moved
  6. error
  7. show
  8. hide
  9. resize

其中5~9的生命周期函数和小程序同构,在其它端暂不支持。重要的是前面4个生命周期:

  • created: 当组件构建时调用(此时组件上的方法和数据都可能未完成准备)
  • attached: 当组件mount时调用(此时组件上的数据已经可用,但部分方法还没有准备完成)
  • ready:组件完全初始化完成,且声明的对象数据已经取得之后调用。此时所有的数据和方法都可用
  • detached:组件准备析构时调用

listeners

组件监听的自身数据(包括props和data)的变化,和小程序的方法声明类似,函数名就是监听的数据项名,其调用参数如下:

(prev: Record<string, any>, next: Record<string, any>) => void

第一个参数传入前项的自身数据字典,第二个参数传入当前的自身数据字典。不过要注意的是,在小程序环境下,props上的变化是无法监听到前项的

methods

组件自定义方法

组件的数据项

组件中有两种数据项: state和props

state

继承了React的语义,表达组件内部的自身数据项,并可以通过setState方法来设置这些项(和类语法下的React完全一样)。

this.state中初始会包括在data配置项中声明的数据,以及formData方法返回的数据。当然,在调用setState方法时,可以不限于只更新data配置项中声明的数据项,但是我们不推荐这样做。

要注意的是,调用setState来更新数据项,会触发渲染函数调用进行重渲染,但并不会触发调用formData。如果您希望执行框架层面上的重渲染,可以在setState的回调方法参数里调用reRender方法。

props

继承了React的语义,表达组件外部传入的属性。为了在小程序环境下能正确处理传入的属性,需要在properties配置项中进行准确声明(上面已经叙述)。

如何引用数据

在组件的各种方法(formData/lifetimes/methods)中,可以通过this关键字访问两种数据项:

const { oakId } = this.props;
const { name, oakFullpath } = this.state;

在props和state上都包含了一些框架的内置数据项,见下节介绍。state上还包含了从formData返回的数据项。

在小程序的渲染xml代码中,直接引用数据项名称就可以访问数据项(props和state上的所有数据项)。

在web和native环境下的渲染函数中,可以通过参数props中的data来引用所有的数据项。在使用前要通过typescript来声明这些数据项:

export default function Render(
    props: WebComponentProps<
        EntityDict,
        'system',               // 对象
        false,                  // 是否list
        {
            name: string;
            description: string;
        },
        {
            setName: (name: string) => void;
        }
    >
) {
    const { name, description, oakId } = props.data;        // oakId是内置数据项
    const { setName, execute } = props.methods;             // execute是内置方法
}

框架内置数据项

框架有一些内置的数据项供组件使用。这些数据项都是以oak开头来命名的。用户应该避免使用相同的关键字来命名数据项。

在props上,有以下内置数据项:

数据项类型意义
oakIdstring非list页面的对象主键
oakDisablePulldownRefreshboolean页面是否禁用下拉刷新(与index.json中所配置的enablePulldownRefresh保持相反)
oakZombieboolean组件在析构时数据是否保留,和配置项中的zombie类似,可用来控制单个组件中的数据保留(对于非页面组件必须在props上配置才能生效)
oakActionsstring[]以参数的形式传入Actions,覆盖组件配置项中的Actions
oakFiltersNamedFilters[]以参数的形式传入Filters,覆盖组件配置项中的Filters

在state上,有以下内置数据项:

数据项类型意义
oakExecutableboolean | Exception组件上的更新是否可以执行。如果更新不能执行,可能会以Exception的方式告知异常
oakExecutingboolean组件是否正在提交更新
oakDirtyboolean组件上是否有更新数据(等同于formData参数上的isDirty
oakModifiedboolean组件上的数据是否真被更新(等同于formData参数上的isModified
oakLoadingboolean是否正在刷新数据
oakLoadingMoreboolean是否正在加载更多数据
oakPullDownRefreshLoadingboolean是否正在下拉刷新数据(只对窄屏有效)
oakEntityboolean组件关联的entity
oakFullpathstring组件在页面“组件树”中的路径
oakLegalActionsstring[]组件上当前合法的Actions(等同于formData参数上的oakLegalActions

善用这些数据项会有效节省您的开发工作量,提升组件的交互性。例如,您可以用oakLoading来判断是否在刷新页面并在渲染中处理这种情况,或者用oakDirty加上oakExecutable来判断页面上是否有可以提交的更新。

组件上的方法

OakComponent配置项的各个函数中,均可以通过this来引用组件上的方法。这些方法可以分为以下三类:

  • methods项中用户自定义的组件上的私有方法
  • 组件的公共方法
  • 从features暴露到组件上的方法

组件的公共方法

组件上的公共方法目前只有以下两个:

方法名参数作用
setStateRecord<string, any>, callback设置组件上的data(同react)
reRender-重新调用formData

未来会考虑加入动画等相关的api

从features暴露到组件上的方法

features提供了前端公用的能力,比较重要的底层feature包括:

  • cache: 前端数据缓存
  • locale: 语言国际化(i18n)
  • runningTree:组件树结构
  • navigator: 页面路由访问
  • localStorage: 前端数据持久存储 等。这些底层的feature都存放在oak-frontend-base/src/features目录下,如果组件需要调用某个feature上的指定接口,可以像这样写:
this.features.cache.refresh(...);

同时为了方便起见,框架也将最常用的一批接口暴露到了组件上,这样组件就可以很方便的通过this关键字来直接调用。这批接口包括:

feature 方法名 作用
localStorage save 存储持久化数据
load 读取持久化数据
clear 清除持久化数据
message setMessage 发消息通知
consumeMessage 消费消息通知
navigator navigateTo 跳转页面
redirectTo 重定向页面
navigateBack 向回跳转
switchTab 切换Tab(小程序专用)
locale t 翻译
cache checkOperation 检查操作可行性
select 查询数据
aggregate 聚合查询
operate 操作数据
subscriber subDataEvents 订阅数据变化
runningTree refresh 刷新数据
execute 提交更新
create 创建(非list页面)
update 更新(非list页面)
remove 删除(非list页面)
setId 设置id(非list页面)
getId 获取id(非list页面)
isCreation 是否创建(非list页面)
loadMore 取下一页(list页面)
getFilters 获取当前过滤条件(list页面)
setFilters 设置当前过滤条件(list页面)
getFilterByName 获取指定命名的过滤条件(list页面)
addNamedFilter 增加命名过滤条件(list页面)
setNamedFilters 设置命名过滤条件(list页面)
removeNamedFilter 删除命名过滤条件(list页面)
removeNamedFilterByName 根据命名删除命名过滤条件(list页面)
setNamedSorters 设置命名排序条件(list页面)
getSorters 获取过滤条件(list页面)
getSorterByName 获取命名排序条件(list页面)
addNamedSorter 增加命名过滤条件(list页面)
removeNamedSorter 移除命名排序条件(list页面)
removeNamedSorterByName 根据命名移除过滤条件(list页面)
getPagination 获取分页设置(list页面)
setPageSize 设置分页长度(list页面)
setCurrentPage 设置当前分页(list页面)

如何使用方法

如上所述,在formData/生命周期函数/自定义方法中,都可以通过this关键字来调用这些方法,或者通过this.features调用更多features上的方法。

  • 要注意,在生命周期的create/attach函数中,runningTree上的许多方法是不能调用的。因为runningTree是在组件开始构建后才进行初始化的,更多细节可以参见todo。

在小程序环境下,可以直接在xml文件中调用自定义方法。

在react/react-native环境下,可以在props.methods中直接调用所有的自定义方法,同时也包括一部分公共方法。所提供的公共方法通过typescript声明可以获得,而自定义方法需要在props的声明中显式加以定义(参看上面「如何使用数据」小节的例子)。