我们先对整个平台的设计做一下简单回顾:
这里是我平时自己维护的一个低代码平台,技术栈是Vue。后续的分享也是基于该平台的一些具体实现细节展开
和市面上大部分可视化搭建系统基本类似。左侧对应组件区域,中间是画布区域,右侧是属性区域。
大致操作流程就是拖动左侧的组件到中间的画布,选中组件,右侧属性面板就会展示与该组件关联的属性。编辑右侧属性,画布中对应的组件样式就会同步更新。页面拼接完成,可通过预览按钮进行页面预览。预览无误,即可通过发布按钮进行活动的发布。
当然其中也有撤销、重做等操作。
今天我们来探讨的是选中画布中指定组件,右侧属性面板展示与该组件关联的表单,修改右侧表单,画布中的组件样式会同步更新。
首先来看一下编辑器全局的数据结构:
const editorModule = {
state: {
components: [],
currentElement: "",
},
mutations: {
addComponentToEditor(state, component) {
component.id = uuidv4();
state.components.push(component);
},
setActive(state, id) {
state.currentElement = id;
},
updateComponent(state, { id, key, value, isProps }) {
const updatedComponent = state.components.find(
(component) => component.id === (id || state.currentElement)
);
if (updatedComponent) {
if (isProps) {
updatedComponent.props[key] = value;
} else {
updatedComponent[key] = value;
}
}
},
},
getters: {
getCurrentElement: (state) => {
return state.components.find(
(component) => component.id === state.currentElement
);
},
}
}
editor中存储了components(所有组件数据)和currentElement(当前选中的组件信息)。
当点击左侧业务组件,会触发业务组件的点击事件,进而触发addComponentToEditor,向editor store的components添加一条组件。我们这里添加一个普通的文本组件,然后看下他的初始props:
{
actionType: "",
backgroundColor: "",
borderColor: "#000",
borderRadius: "0",
borderStyle: "none",
borderWidth: "0",
boxShadow: "0 0 0 #000000",
color: "#000000",
fontFamily: "",
fontSize: "14px",
fontStyle: "normal",
fontWeight: "normal",
height: "36px",
left: "97.5px",
lineHeight: "1",
opacity: 1,
paddingBottom: "0px",
paddingLeft: "0px",
paddingRight: "0px",
paddingTop: "0px",
position: "absolute",
right: "0",
tag: "p",
text: "正文内容",
textAlign: "center",
textDecoration: "none",
top: "232px",
url: "",
width: "125px"
}
当在画布中选中该文本组件时,就会触发setActive,更新currentElement。(通过getCurrentElement可以获取到当前正在被操作的组件)。
这个时候,应该如何添加属性和表单的基础对应关系呢?
这个也是本篇文章的主题:低代码平台的属性面板该如何设计?
1.属性面板应该包含哪些内容?
我们的Choba Lego平台中有很多业务组件,而每个富交互的页面都是由这些业务组件堆积拼装而成,而每个组件都包含了一些通用属性和组件特有属性,这些属性反映了当前组件的各种状态,非常复杂。
对于单独的组件来说,属性面板应该是语义化的,无论是开发还是非开发同学,通过属性面板的操作区,就可以直观的知道一个组件的属性是什么,应该如何使用和编辑。
那么属性面板应该包含哪些内容呢?
- label:属性名称。这个可以显式的告诉具体的属性的作用,比如元素的宽高、边框、背景颜色等。
- description:属性的描述信息。对于一些特殊属性,可能第一下通过label并不能直观的识别属性的含义,添加描述信息可以进行详细的阐述。
- content:属性渲染器。用户可以基于此实现对属性的修改。最常见的有 textarea、input、select 等。
- error:属性校验信息。当用户输入了不合法的或者类型不匹配时,可给予适当的错误提示信息。
通过以上描述,我们会发现,这其实就是我们常用的表单。
2.属性和组件的映射关系
其实上面的四块内容,内容渲染器应该是最复杂的。采用合适的渲染器来渲染对应的属性才是最重要的。
但存在一些场景,一些属性可以被多种渲染器来渲染,像字体大小-fontSize,既可以用input-number,又可以用slider。那么这种场景应该如何选用最合适的渲染器呢?其实这种我觉得完全可以看开发者和使用者的综合意愿,没有绝对的对错之分。
对应上面组件的props信息,我们可以对这些属性做一些归类,那归类的标准又是什么呢?我认为应该把属性与js中的数据类型做一下映射,然后在具体的分类下选用合适的渲染器。
我们知道在JavaScript中,一共有七种数据类型,字符串(String)、数字(Number)、布尔(Boolean)、空(Null)、未定义(Undefined)、Symbol和对象(Object)。其中对象类型包括:数组(Array)、函数(Function)、还有两个特殊的对象:正则(RegExp)和日期(Date)。
这里面的空(Null)、未定义(Undefined)、Symbol和正则(RegExp)在渲染器中基本用不到。
我们先来看一下字符串(String)、数字(Number)、布尔(Boolean)和日期(Date)可能渲染的方式:
字符串(String)
渲染器类型 | 组件 |
input | |
textarea |
数字(Number)
渲染器类型 | 组件 |
input-number | |
slider |
布尔(Boolean)
渲染器类型 | 组件 |
switch |
日期(Date)
渲染器类型 | 组件 |
date |
除了这几种,还有对象(Object)、数组(Array)、函数(Function)。
对象和数组属于较复杂的类型,不过我们可以把它抽象为多层级(可以理解为嵌套)的基础数据类型:
渲染器类型 | 组件 |
array |
像数组一般是用下拉框的形式来展现。
至于函数(Function),可以采用预定义的形式:
渲染器类型 | 组件 |
function |
到这里,不难想到,我们要维护一个属性和表单组件的对应关系。属性对应上面的key,像borderColor、text、width、fontFamily这些,那组件呢?组件其实就是对属性的具体呈现,像width可以用数字输入框、text可以用普通输入框,但是对于一些比较复杂的特性,我们自己去实现这些组件,就显得捉襟见肘了,这个时候我们就可以考虑和现有的组件库做一下结合了(这里我采用的是Ant Design Vue)。
那么这样,属性prop和component基础的对应关系就有了:
const mapPropsToComponents = {
text: {
component: "a-input",
},
width: {
component: "a-input-number",
},
borderWidth: {
component: "a-slider",
},
// ...
}
但这只是满足了常规的基础组件设计,像一些独有的属性或者基础组件不能满足的情况,我们需要对其做一定扩展:
渲染器类型 | 组件 |
upload | |
color-picker |
上面提到的上传组件和颜色选择组件是需要我们单独去实现的。
3.属性分类
仅仅有属性和组件的对应关系还不够,每个组件都会对应大量的表单属性,对他们按功能做一下归类还是很有必要的。
基本属性也就是每个组件独有的一些属性,除基础属性以外,剩余的就是所有组件的通用属性了。
属性分类虽然是一个比较简单的实现,但是能对使用者带来很大的收益,可以清晰的知道每种属性更改对组件带来的不同影响。
4.更新表单将数据更新到属性
有了上面的准备,最重要的一步来了,那就是选中组件,属性面板展示该组件关联的表单属性,修改属性,组件数据会同步更新。
以我以往的经验来看:表单组件在设计时,有两点是必须的:
- 表单初始值(默认value),供初始展示使用
- 表单属性更改的事件(默认为 change)
对于不同的表单,初始值和属性更改后,参数的处理是不一样的:
- 像高度、宽度这种数字类型的,传入表单时应保证是number(24)类型,属性更改后,事件参数应该是string(24px)类型的
- 字体加粗与否、倾斜与否、加下划线与否,传入表单时应保证是boolean(true/false)类型,属性更改后,事件参数应该是string(bold/normal)类型的
所以给每一个属性在传入表单和事件更改后都要加一个额外的转化函数去处理值:
- initialValueConvert
- eventChangeValueConvert
还有对属性进行赋值时,不是所有的表单控件接收的都是value,像checkbox就是checked,这种单独抽一个属性valueProp去控制即可。
其次,像上面提到的父子层级的渲染,除了component还要多加一个subComponent。
上面配置完成后,属性和组件的对应关系就有了:
const mapPropsToComponents = {
width: {
component: "a-input-number",
eventName: "change",
valueProp: "value",
initialValueConvert: (v) => (v ? parseInt(v) : ""),
eventChangeValueConvert: (e) => (e ? `${e}px` : ""),
text: "宽度",
},
textAlign: {
component: "a-radio-group",
subComponent: "a-radio-button",
eventName: "change",
valueProp: "value",
eventChangeValueConvert: (e) => e.target.value,
text: "对齐",
options: [
{ value: "left", text: "左" },
{ value: "center", text: "中" },
{ value: "right", text: "右" },
],
},
// ...
}
我们的数据始终保持自上而下的顺序,也就是说表单更新最终要反射回到总体的 store 当中去。这个时候我们在对应的组件当中发射出一个事件(change),当 change 发生的时候,我们能够知道是哪个元素的哪个属性,以及新的值是什么,我们就用这些信息更新这个值,这样 store完成更新,元素的 props 发生更新,那么整个数据流动就完成了。
5.参考链接
https://mp.weixin.qq.com/s/u2AkeXiL0pi4799ccjR_Tg