今天我们来一起探讨 formily 表单设计器配置端的实现原理。其实也就是比较复杂的低代码平台的实现原理。首先声明一下 formily 配置端的源码是通过 react 编写的,而本人对于vue技术栈更加的得心应手,所以本人会采用 vue 技术栈来进行实现。而且 formily 配置段本身代码阅读起来难度比较大,而且内容非常庞杂,我在这里主要会探讨和实现它的主干逻辑的实现原理。当然 formily 的设计虽然有很多值得学习的地方,但也有一些其他的思考和解法,所以在某些细节的实现上,我不一定会完全按照它的思路来进行实现,而是会融入一些自己的思考。
架构设计
schema 设计
对于低代码或者无代码平台来说,最核心的功能实际上就是出码功能。也就是产出 schema 配置。而 schema 本质上来说,就是一种 DSL,就是一种对于界面结构和核心信息的描述语言。而formily 更多的针对的是表单类型的场景。所以下面我们就给出一些表单的场景,尝试用 schema 来对表单界面进行描述和抽象:
这是一个最基础的表单,按照 formily 的表单模型,可以这样进行抽象:首先整个表单的数据类型是一个 Object 类型,所以我们首先可以这样进行定义:
js
const schema = {
type: "object"
}
通过 type 为 object 定义出来的整个表单的数据类型。 紧接着这个表单中有三个字段分别是 name, activityZone、activityForm,它们数据类型都是 string 类型的,所以我们可以继续这样进行定义:
js
const schema = {
type: "object",
properties: {
// 定义 Object 数据中的具体字段
// 定义 name 字段
name: {
},
// 定义 activityZone 字段
activityZone: {
},
// 定义 activityForm 字段
activityForm: {
}
}
}
至此,我们就定义除了表单的所有的字段。每一个字段都有自己的属性,比如字段的数据类型,字段的 label,字段需要渲染的 ui 组件,字段的一些样式,字段关联的事件 .... 基于上面的分析,我们可以继续完善我们的 schema 定义: 以 name 字段为例:
js
name: {
// 定义字段的标题
title: 'xxx',
// 定义字段的数据类型
type: 'string',
// 定义字关联的ui组件
component: 'input',
// 定义字段的内联样式
style: {
// ....
},
// 定义字段的事件
on: {
// xxxx
}
},
如果当前的字段是 Array 类型,那么也会有一些特殊,因为 Array 一般表现为自增组件的形式。点击新增或者删除按钮可以增删指定的某一行子表单。所以还需要有额外的字段来描述这一行的表单信息:
js
{
type: Array,
// 其他的描述信息
// ...
// 描述每一行的表单信息
items: {
type: 'object',
// xxxx
}
}
至此我们就初步分析完毕 formily schema 的核心设计原则。总的来说其实就是两个核心要点:
- 有严格的数据结构的区分,通过 type 来区分不同的数据结构,不同的数据结构来描述不同的表单类型。
- 界面结构和表现耦合在一起。
第一点很好理解,第二点是什么意思呢?要理解这个我们就得理解几个很重要的概念:
什么是界面的结构?比如:上面哪个ui表单,它的界面是由 三个字段构成的,每一个字段,它的字段 key 是什么,字段的 label 是什么,我们通过一个很精简的结构来将其描述出来就可以了:
js
[
{
fieldKey: 'name',
fieldLabel: '名称'
},
{
fieldKey: "activityZone",
fieldLabel: 'activity zone'
}
]
以上就是对于界面结构i的描述。而且这段 schema 只聚焦于界面的结构以及字段的核心内容,不关注ui,也不关注交互。
什么是界面的表现?理解了对于界面结构的抽象,理解界面的表现就特别简单了:
js
{
name: {
// 描述 name 字段所有的 ui 以及交互信息
component: 'input',
style: {
},
on: {
},
// 组件的属性
props: {
}
},
activityZone: {
// xxx
}
}
它表现为一个 map 结构,将详细的描述出每一个字段的详细的ui交互信息。
通过这套定义,如何渲染界面?
很简单了:我们直接循环界面的结构定义,在具体每一次循环的时候都从表现map中取出字段关联的ui表现定义就可以了。
这种 schema 设计方式的好处是显而易见的,首先它在认知上就将整个表单或者界面的结构和ui是分离定义的。这样就可以达到这样的一个效果:我们界面操作的时候,如果只是改变了结构,那么我只需要操作界面的结构定义的 schema 就可以了,界面表现的 schema,我不关注。我在界面操作的时候,如果只是改变了ui,那么我只需要操作ui schema 就可以了,整体的 schema 结构是不会受到任何的影响的。这其实也是分离关注点这种设计方式的落地与实现。
举一个很常见的例子,比如我要实现
飞书云表格的推拽效果。那么我们在抽象的时候,就可以这样进行抽象:
首先定义表格的结构:
js
[
{
fieldKey: 'redio',
fieldLabel: '单选'
},
{
fieldKey: 'text1',
fieldLabel: '文本1'
}
]
然后定义出表格的内容:
js
{
redio: {
component: 'redio',
style: {},
// xxxx
},
text1: {
component: 'input'
}
}
当我要实现拖拽改变表格列的顺序的时候,实际上可以只动表格的结构定义,而结构定义又被我们定义为了一个数组的格式,所以操作起来非常方便。而内容不受任何的影响,因为结构定义和内容定义之间的关联关系并不会受到影响。
当我操作表格的某一列,改变它的ui的时候,完全只需要精准操作对应的内容定义就可以了。表格的结构以及其他的列的内容完全不会受到影响。可以更好的实现精准的更新。
基于上面的分析,我们本次低代码平台将采用结构与ui分离的 schema 设计方案。
数据协议设计
低代码 schema 数据
低代码平台的数据状态很多,我们逐步来进行设计。首先我们可以想到的就是已经渲染到画布上的 schema 数据。按照上面的设计,我们又会将整个 schema 划分为描述结构以及描述内容两个 schema 对象。所以很自然的在全局状态中,就会存放两个 schema 对象:
js
// 描述界面结构的 schema
const layoutSchema = [
]
const blockInfoSchema = {
}
这两个数据需要自顶向下从低代码平台的根组件流向最底层的每一个被渲染出来的物料组件。所以它们两个肯定被抽象为两个全局状态。而使用 vue3 两进行表达的话,我们可以在低代码的根组件中,将这两个状态通过 provide 共享给后代组件。
明确了 schema 作为全局状态之后,我们接下来就明确的定义出 schema 的数据结构,首先是布局 schema 的结构定义:
js
interface LayoutSchemaData {
// 每一个渲染到画布中的物料的唯一的id
id: string;
fieldLabel: string;
children?: LayoutSchemaData[]
}
type LayoutSchema = LayoutSchemaData[]
其中最核心的属性就是物料的id,这个属性将是布局 schema 配置和内容 schema 配置能够正常关联起来的关键。同时,每一个物料都拥有一个可选的参数: children,表示当前的物料是否包含子结构。
然后我们给出内容物料的的基本类型定义, 因为物料的类型有很多,比如 Input 物料,Image 物料 .... 所以我们在类型定义上将其抽象为两层结构:
首先是基础结构的定义:
js
interface BaseBlock {
// 定义ui组件的类型
component: string;
// 定义样式属性
style?: CssStyle;
// 唯一的 id
id: string;
// 组件的属性
props?: Record<string, any>;
// 类名
class?: string;
// 生成周期
lifeCycle?: {
// 组件挂载前
beforeMount: () => void;
// 组件挂载后
afterMount: () => void;
// 组件卸载前
beforeUnmount: () => void;
// 组件卸载后
afterUnmount: () => void;
// ...
};
}
基于 BaseBlock,我们可以派生出具体的物料结构定义:
js
interface InputBlock extends BaseBlock {
// 定义 input 组件类型
component: 'input';
props?: {
// 定义出 Input 组件特有的 props
type: InputType;
maxLen: number;
// ...
}
}
interface ImageBlock extends BaseBlock {
// 定义 input 组件类型
component: 'image';
props?: {
// 定义出 Image 组件特有的 props
src: string;
// ...
}
}
最终我们将所有 Block 类型组成一个联合类型进行导出:
js
type Blocks = InputBlock | ImageBlock
其实 Block 中还有一个非常重要的内容要进行设计,那就是事件属性,这个我们后面来进行补充。
至此我们就初步设计好了 schema 数据协议。
编辑器状态的设计
我们抽象出一个 Engine 的类型来定义编辑器相关的状态。
js
class Engine {
// 编辑器的模式
model: Ref<"readonly" | "edit" | 'disabled'>;
// 当前选中的物料id,允许一次性选中多个
selectIds: Ref<string[]>;
}
我们首先可以想到有两个很核心的属性,编辑器的状态以及选中的物料id,由于用户可能一次性选中多个物料,所以这个类型是一个数组。并且两个状态我们都将其声明为响应式数据。
除了这两个核心属性之外,编辑器对象上我们还需要维护事件相关的处理。我们都知道,低代码平台中最频繁的事件就是关于鼠标以及拖拽的各个事件。而且需要注册这些事件的元素会跟多,很灵活,所以我们会在这个类型上面提供一个方便的注册事件的方法
js
interface EventHandler {
(...args: any[]) => any
}
class Engine {
// 编辑器的模式
model: Ref<"readonly" | "edit" | 'disabled'>;
// 当前选中的物料id,允许一次性选中多个
selectIds: Ref<string[]>;
// 注册事件
public on(eventName: string, handler: EventHandler) {
// 在这个函数中进行注册
}
}
我们在这个类中添加了注册编辑器事件方法。有了这个方法之后,后续的所有的编辑器模块需要注册事件的时候就特别简单了:
js
const engine = inject(xxx)
// 注册拖拽开始事件
engine.on(dragStart, () => {
})
因为这些事件全部都是原生的dom事件,所以我们需要能够有一个好的办法可以全局监听这些事件,因此我们继续这样设计:
js
interface EventHandler {
(...args: any[]) => any
}
class Engine extends EngineEvent {
// 编辑器的模式
model: Ref<"readonly" | "edit" | 'disabled'>;
// 当前选中的物料id,允许一次性选中多个
selectIds: Ref<string[]>;
// 注册事件
public on(eventName: string, handler: EventHandler) {
// 调用原型上集成的注册 dom 事件的方法
this.attach(eventName, handler)
}
}
js
class EngineEvent {
public attach(eventName: string, handler: EventHandler) {
// 直接在 document上面注册
document.addEventListener(eventName, handler)
}
}
这样做,确实可以实现将指定的事件注册到document上面去,并且可以通过事件冒泡机制捕获到指定的dom事件。但是这样同类型的事件只能够注册一个,无法注册多个。低代码平台的事件是非常复杂的,很多事件同一个事件需要触发多个处理器去进行执行,所以我们需要实现一个发布订阅的类来进行管理。所以我们这样设计:
js
class EngineEvent {
// 事件总线
private eventBus: {
[eventName: string]: EventHandler[]
}
// 需要监听的事件列表
private events = [
'dragStart',
'mousedown',
// ...
]
public attach(eventName: string, handler: EventHandler) {
// 将指定的事件处理函数收集到指定的总线上
const { eventBus } = this
if (!eventBus[eventName]) {
eventBus[eventName] = []
}
eventBus[eventName].push(handler)
}
// 批量执行指定的事件的方法
public emit(eventName: string, ...args: any[]) {
const eventHandlers = this.eventBus[eventName]
if (!eventHandlers) {
return
}
// 批量执行订阅的方法
for (const event of eventHandlers) {
event(...args)
}
}
// 在 document 上注册各类鼠标或者其他的 dom 事件
private addEventListener() {
this.events.forEach(event => {
document.addEventListener(event, (e: Event) => {
// 取出触发该事件的时候的鼠标位置信息以及其他的重要数据
const pageX = e.pageX
// ....
// 触发订阅了该事件的事件总线上相关方法执行
this.emit(event, {
// 将所有的重要位置信息传入进去
pageX,
...,
// 传入此时的编辑器上下文信息
engineCtx: this
})
})
})
}
constructor() {
// 注册一系列重要的 dom 事件
this.addEventListener()
}
}
核心内容都已经写上了注释,实际上 formily-design 的核心实现思路也是类似的。这个思路其实就很类似于 canvas 画布的处理逻辑了,通过监听事件触发的位置来判断此时应该做怎样的交互行为。
碰撞检测总体实现思路
接着上面继续分析,上面我们已经可以监听画布的各种事件了。当然也就可以流畅的监听画布中的拖拽事件。当我们的画布已经有了物料之后,我们重新拖进新的物料,那么就需要进行物料与物料之间的边界判断以及碰撞检测,类似于下面这样:
它的细节处理其实还是需要不少时间来进行调试和优化的,但是总的实现思路其实并没有那么复杂。我们可以这样进行设计:
- 我们注册一个 dragStart 事件,当这个事件触发之后,我们取出 blockInfoSchema 这个全局状态,遍历这个全局状态,汇总此时已经渲染的所有的物料的位置信息以及边界信息,形成一个类似于这样的配置:
js
{
top: xxx,
left: xxx,
button: xxx,
right: xxx,
width: xxx,
height: xxx,
id: xxx
}[]
汇总之后我们在内存中就已经画出了此时整个物料的区划信息。
- 监听 mouseMove 事件,当鼠标移动的时候,通过事件总线,实时读取鼠标位置信息。然后和上一步收集到的区划信息进行比较,如果此时鼠标的位置位于某一个物料的区划中,那么表示这个位置是不能放置新的物料的。(当然,如果这个物料是布局或者容器组件,那么就需要特殊处理)。鼠标移动过程中,不断的比较区划信息,不断的绘制参考线。
- 监听 鼠标松开事件 mouseUp。如果此时鼠标的位置位于一个合法的区块中,那么就将物料的定位设置在这个区块中。
大家先大概理解一下思路,我们下一篇文章机会详细的来用代码实现这个过程。
物料之间的通信方案:
首先我们先定义出物料事件的数据结构:
js
// 这里我们来定义基础的 dom 事件
interface BaseEvent {
click?: (e: Event) => void;
// ...
}
// 基于基础的事件定义出特定的物料对应的事件模型
interface InputEvent {
input?: (value: any, e: Event) => void;
change?: (value) => void
// 其他自定义事件
}
将事件加入到物料 block 的定义中:
js
interface BaseBlock {
// 定义ui组件的类型
component: string;
// 定义样式属性
style?: CssStyle;
// 唯一的 id
id: string;
// 组件的属性
props?: Record<string, any>;
// 类名
class?: string;
// 生成周期
lifeCycle?: {
// 组件挂载前
beforeMount: () => void;
// 组件挂载后
afterMount: () => void;
// 组件卸载前
beforeUnmount: () => void;
// 组件卸载后
afterUnmount: () => void;
// ...
};
// 事件
event: BaseEvent;
}
interface InputBlock extends BaseBlock {
// 定义 input 组件类型
component: 'input';
props?: {
// 定义出 Input 组件特有的 props
type: InputType;
maxLen: number;
// ...
},
event: InputEvent;
}
此后我们的所有的物料详情上实际上就已经包含了事件相关的属性了。
我们再来定义物料中需要对外暴露的方法的结构:
js
// 基于基础的事件定义出特定的物料对应的事件模型
interface InputMethods {
getValue: () => string;
setValue: (newValue: string) => void
}
将该属性同样设置到对应的物料类型上去:
js
interface InputBlock extends BaseBlock {
// 定义 input 组件类型
component: 'input';
props?: {
// 定义出 Input 组件特有的 props
type: InputType;
maxLen: number;
// ...
},
event: InputEvent;
methods: InputMethods;
}
此后物料之间通信就很方便了,比如当 a 物料 change 之后需要将它的新值设置给b物料的值,那么就可以这样做:
在 b 物料的 change 事件触发之后通过 inject 获取到对应的 b 物料的 block 上下文对象,然后调用挂载的 methods 中的 setValue 方法就可以了。这样就可以很方便的实现物料通信了。
今天我们初步的对整个低代码平台编辑器总体状态进行了梳理和实现方案设计。下一篇文章我们将进一步梳理布局引擎的方案设计以及整个低代码平台的编码实战。