前端发展至今,各大公司推出了不同的可视化低代码平台,腾讯阿里等都有关于低代码的建设,对比传统的开发,可视化低代码能快速产出页面,适用于大量逻辑简单的推广展示页面、活动页面等,成熟的系统还可以让运营人员直接配置,解放开发人力。本文就主要对低代码技术进行研究,了解其架构和关键的技术。
在正式探究前,先想一想这些问题:
- 在页面编辑器中,拖拽出来的容器、图片等元素,怎么转换成页面数据
- 编辑出来的页面数据,如何生成一个页面
- 如何组装js与css逻辑到页面
- 如何在dom节点上绑定配置的事件,点击事件、双击事件等等
- 编辑器面板设计, 如何实现拖拽
- 撤销与回滚
数据结构与渲染逻辑
页面本身是一种描述性的语言,通过html描述页面的各个节点,css描述节点的样式,js描述逻辑与人机交互等。
在日常的组件化模块化开发中,一个页面由组件A+组件B+组件C+模块A+模块B等等组成,如果抽象成纯数据结构来表示这个页面,就可以用json来描述这个页面由组件a组件b组件c等组成。
"content": {
"root": {
"id": "root",
"type": "root",
"parentId": null,
"childIds": ["a", "b"],
"style": {},
},
"a": {
"id": "a",
"type": "Section",
"parentId": "root",
"childIds": ["c"],
"style": {
"position": "relative",
"minHeight": "100px"
},
},
"c": {
"id": "c",
"type": "Image",
"parentId": "a",
"childIds": [],
"props": {
"key": "WwvxTt",
"src": "var(--猪猪.png)"
},
"style": {
"position": "relative",
"display": "flex",
"width": "150px",
"height": "150px",
"backgroundColor": "red"
}
},
"b": {
"id": "b",
"type": "Button",
"parentId": 0,
"childIds": [],
"props": {
"key": "b",
"content": "点击显示列表",
"type": "primary",
"events": [{
"id": "9a28f1766bd7",
"triggerType": "CLICK",
"action": {
"name": "showList",
"params": {}
}
}]
},
"style": {
"marginTop": "10px",
"width": "110px",
"height": "43px",
"fontSize": "14px"
}
}
},
用一段json表示dom结构,每个是一个node,记录一个节点的父节点子节点、样式、dom类型、响应事件等信息。 每个节点都有一些通用的行为,例如获取当前节点信息、更新节点样式、子节点的增删改查、修改父节点指向等。 那对于整段节点的结构来说,就应该有新增节点、移除节点、移动节点的操作。
了解到存储的数据结构之后,那么用户对于编辑器中通过拖拽方式的添加组件、挪动组件、删除组件、设置组件样式等等的操作,其实都是在往这个json数据上做调整,数据变更驱动视图的更新。
那么json数据如何映射到我们的画布上呢?
渲染器的渲染逻辑是从根节点开始,通过不断递归解析这份树状结构的视图数据,通过动态组件的方式显示到页面中。 拿react举例, 渲染的关键代码:
function renderContent() {
const rootNodes = Object.values(data).filter(item => item.parentId === null);
function getChildren(node) {
const { type: Type, style, props } = node;
return <Type style={style} props={props}>
{ node?.childIds?.map((item, index) => getChildren(data[item])) }
</Type>
}
return rootNodes.map(item => getChildren(item))
}
实际情况上述代码会更加复杂,比方修改的属性如何传递到组件中,类名拼接等等,每个组件都要经历相同的数据加工、注入一些必要功能等的过程,此时可以运用高阶组件来抽离逻辑。
上述json里的type都是dom支持的元素,拿a来说,a的type是Section, 渲染逻辑返回的就是<Section />, 那列表怎么办,并没有一个<List />,要如何渲染?
列表渲染:
列表: 组件选择列表,拖动到编辑中,在编辑器中编辑的是每一项数据item的样式,非整个列表。编辑完的列表的json数据结构like:
{
"a": {
"type": "List",
"childIds": ["b"],
"props": {
"dataKey": "list",
"data": []
},
},
"b": {
"type": "Section",
"childIds": ["c,d"]
},
"c": {
"type": "Image",
"childIds": [],
"props": {
"key": "WwvxTt",
"src": "",
"dataKey": "listImage"
},
},
"d": {
"type": "p",
"childIds": [],
"props": {
"key": "WwvxTta",
"contentText": "",
"dataKey": "listRank"
},
}
}
上面的json结构表示a是一个列表,绑定的数据是list。列表的children只有一个是b, b表示列表中每个item的节点,由图片节点c和文本节点d组成。配置节点d的文本对应列表数据字段是listRank, 图片c的资源路径对应列表数据字段的listImage。
假设list数据是这样的:[{listRank: 1, listImage: ‘image1’ }, {listRank: 2, listImage: ‘image2’}],那我们需要把数据转变为json上节点数据。
即从[{listRank: 1, listImage: ‘image1’ }, {listRank: 2, listImage: ‘image2’}] 转变成
[
{
id: 'b',
type: 'Section',
childrens: [{
id: 'c',
type: 'image',
props: {
src: 'image1'
}
}, {
id: 'd',
type: 'p',
props: {
contentText: 1
}
}]
},
{
id: 'b',
type: 'Section',
childrens: [{
id: 'c',
type: 'image',
props: {
src: 'image2'
}
}, {
id: 'd',
type: 'p',
props: {
contentText: 2
}
}]
}
]
再改造一下渲染逻辑生成列表子节点。 List组件可以认为是业务组件的一种,其他自定义组件也可以封装,在内部处理数据与节点属性的映射,再对于基础组件进行动态渲染。
清楚了数据结构和渲染逻辑之后,那么存到服务器中的数据,也同样是这份json数据,页面链接根据id查询json结构, 客户端再通过渲染逻辑解析成真实的dom节点。
基本的网页由html+css+js组成,除了元素的布局之外,我们还需要有额外的逻辑处理和css样式注入,那这一部分如何注入到我们生成的页面中呢?
js与css注入
css和js在编辑器中编辑,保存在接口中。在打开页面时查询,js动态插入style
// 获取到接口css内容后执行
insertCss(css) {
if (css) {
const styleNode = document.createElement('style');
styleNode.type = 'text/css';
styleNode.appendChild(document.createTextNode(css));
document.head.appendChild(styleNode);
}
}
js的数据同理,接口保存打包编译好的js文件名,通过loadScript的方式加载脚本并执行。为了减少请求脚本的时长,也可以在生成页面时注入script数据。在打开页面时执行。
事件系统
页面通常需要交互,点击、双击等事件的绑定和监听必不可少,这就需要给我们的组件添加事件系统。
事件系统的核心就是运用观察者模式,分发注册的事件名,在代码编辑中做监听
编辑器中编辑一个button元素点击事件为onBtnClick,生成的数据结构如:
"a": {
"type": "button",
"childIds": [],
"props": {
"key": "WwvxTta",
"contentText": "点击",
"trigger": [{
"type": "CLICK",
"name": "onBtnClick"
}]
},
}
dom元素事件可以通过高阶组件包裹,赋予不同的事件的监听,onClick、onMouseEnter等等,当点击时trigger onBtnClick, 在代码编辑中
Event.on('onBtnClick', () => {
// do something
})
以此实现一个对click事件的监听与响应。
编辑器设计
之前我们探究的是一份低代码数据如何生成一个页面,那编辑器的作用就是设置这份数据。
通常编辑器的布局是左边组件区,中间画布,右边为节点属性设置区。生成的页面布局数据结构同保存在服务端的一致,这样在画布中的渲染逻辑就可以复用,不同的是在画布中的元素渲染不需要事件的绑定,js与css的注入等等,但是需要可选中,移动等等。
在编辑器中最重要的功能就是拖拽,从组件区拖拽组建到画布来添加组件,或者是画布里组件之间的拖拽以改变布局。那拖拽功能的实现可以利用react-dnd库,监听drop事件来决定是添加组件还是改变父节点指向。
一个react-dnd示例
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
const DraggableBodyRow = ({ index, moveRow, className, style, ...restProps }) => {
const type = 'DraggableBodyRow';
const ref = useRef();
const [{ isOver, dropClassName }, drop] = useDrop({
accept: type,
collect: monitor => {
const { index: dragIndex } = monitor.getItem() || {};
if (dragIndex === index) {
return {};
}
return {
isOver: monitor.isOver(),
dropClassName: dragIndex < index ? ' drop-over-downward' : ' drop-over-upward',
};
},
drop: item => {
moveRow(item.index, index);
},
});
const [, drag] = useDrag({
type,
item: { index },
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
});
drop(drag(ref));
return (
<tr
ref={ref}
className={`${className}${isOver ? dropClassName : ''}`}
style={{ cursor: 'move', ...style }}
{...restProps}
/>
);
};
<DndProvider backend={HTML5Backend}> // DndProvider包裹需要拖动和监听的元素
<Table
columns={cols}
data={stars}
components={components}
onRow={(record, index) => ({
index,
moveRow,
})}
pagination={false}
scroll={{ y: 350 }}
bordered={true}
rowKey={item => item.kugouId}
emptyText="暂无数据"
/>
</DndProvider>
如果固定死画布层的宽度,会影响实际页面的效果尤其是移动端的网页,需要做页面适配。因此画布层可以通过iframe来展示,结合根结点font-size+rem,来模拟实际效果。
同时为了渲染逻辑清晰,避免与修改节点逻辑杂糅,画布可分为辅助工具层、渲染层、逻辑层。
撤销与回滚
在编辑器中撤销的功能就是数据状态回退,适用redux-undo这个库可以轻松实现Redux 状态树中的撤销和重做。
……to be continued