实现简易的可视化拖动生成页面

本文最后更新于:2022/01/13 , 星期四 , 22:35

20210103 更新

之前写的比较草率,只是为了实现而实现,没有进行更深层次的思考。所以打算对文章进行一个整体的重写,代码完善一下。

背景

最近因为临近年关,公司内好多活动页、落地页这种简单的页面要写。每次写都是在做重复的工作。

作为一个一直以优化掉自己为目标的前端切图仔,所以打算搞个可以让产品、UI 都能用明白的工具,来实现一个可视化拖拽平台,来优化掉自己。

最终虽然在公司落地失败了,但是自己也积累了一些小经验,所以打算记录一下自己实现的过程。

什么是低代码平台?

本来我理解的低代码平台就是编辑器和渲染引擎的一个组合,后来随着我在查看其他平台的时候,发现我自己想的太简单了,编辑器和渲染引擎只能说是低代码平台的一部分内容,那低代码平台应该具备哪些能力呢?

核心能力

  • 可视化配置面板:也就是我们所说的 editor,可以拖拽一些组件,然后对组件进行属性的变更

  • 扩展能力:组件、模板、逻辑复用

  • 生命周期管理:开发管理、页面管理、部署管理。

开发模式的转变

2022/01/03/传统开发模式与低代码开发模式的对比

低代码的「家人们」

  • low code

    • 预置组件实现全集功能

    • 可视化配置降低开发门槛

    • 支持定制&扩展

  • no code

    • 完全可视化配置

    • 不支持扩展,一切需求规范化

  • pro code

    • 组件化抽象

    • 框架层规范

原理

2022/01/03/低代码平台简易工作过程

页面管理

配置页面

物料堆(组件库)

由元组件和布局组件组成,元组件具有以下特点:

  • 功能结构足够单一

  • 不可继续拆分

  • 开放定制

布局组件特点:虚拟概念,不渲染自己,只渲染传进来的元组件。

因为元组件具有功能结构单一的特点,所以在处理复合布局/逻辑时需要在外加一层容器(布局组件)形成复合组件,然后进行统一处理(遇事不决加一层)。

多个复合组件构成就可以组一个页面。

画布

目前主流画布实现方案有两种:画布渲染一体化 和 多态画布(配置画布+渲染引擎)

多态画布特点:状态分离,效率高,可以两个组来维护

一体化特点:复杂度高(先转中间语言,再转页面),逻辑集成度高

配置面板(配置项)

配置面板和组件类型关系是:1 对多的(比如 按钮 和 图标的配置是不同的),组件类型对组件配置的关系也是:1 对多的(可以看一下宜搭)。组件类型是 class,组件配置就是实例。

顶栏(全局、页面配置)

2022/01/03/IDE构成

输出页面

根据配置页面中输出的页面原数据,进行渲染还原页面。

页面元数据(JSON)->渲染引擎->组装协议。

渲染引擎工作内容

  • 遍历解析:使用深度优先遍历。因为有可能存在 A 和 B 同级,但是 B 的展示依赖于 A 的情况,这样 A 渲染完返回给他的父级一个消息,再去渲染 B,这样就可以处理这种复合组件了。主要处理的是前后加载依赖。

  • 处理组件依赖

  • 全局配置调度

开发目标

  1. 物料堆抽象 - 需要后续组件完成时,直接丢入物料堆可以被加载,无需后续调整物料堆代码

  2. 解析 JSON - 响应加载物料堆中的组件进行渲染

  3. 点击渲染好的页面某一块,点击组件树可以拿到对应的组件,并且能拿到挂载在上面的 config

物料堆

为了实现方便,所以我们引入个组件库,我这里用的arco-design

因为很多时候,我们在搞组件库的时候,并不都是自己开发的,大部分都是有基础建设组来搞的组件,所以我们要尽可能不去修改最内层的东西,那我们就需要在组件外面加一层

按照这个再搞个Container和Input,Container的话就直接自己写一个吧,因为需要在这层处理好多东西,比如width,height等等等,选中外边框之类的。

1
2
3
4
5
6
7
8
9
10
11
// parser-button.tsx
import React from 'react';
import CButton from './cButton';

// 假装CButton是自己写的,
// 这里进行一些自己的逻辑封装
const ParserButton: React.FC = (props) => {
return <CButton>{props.children}</CButton>;
};

export default ParserButton;

需要处理的逻辑就都放在 parserButton 里,这里当然只是最简单的一种。

物料堆其实就是一堆组件的数组,然后遍历渲染,所以我们把有的组件都放到一个数组里去

目前每次写个组件都得在这个文件里引入,比较蛋疼,也不知道有什么好的方法自动生成这种东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// components/index.ts
import CButton from './cButton';
import Container from './Container';
import CInput from './cInput';
import ParserButton from './parser-button';
import ParserInput from './parser-input';
import ParserContainer from './parser-container';

// 物料堆里用到的基础组件
export const componentList = [
{ name: 'CButton', Component: CButton },
{ name: 'CInput', Component: CInput },
{ name: 'Container', Component: Container },
];
// 渲染引擎里用到的解析器
export const parserList = {
CButton: ParserButton,
CInput: ParserInput,
Container: ParserContainer,
};

然后对componentList进行遍历渲染就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// fragments/componentStack.tsx
import React, { useContext } from 'react';
import { componentList } from '../components';

const ComponentStack: React.FC = () => {
return (
<>
<div className="component-stack">物料堆</div>
<ul>
{componentList.map((info) => (
<li
className="border-1 border-gray-900 my-[2px] mx-[5px] px-0 py-[10px]"
key={info.name}
>
<info.Component />
</li>
))}
</ul>
</>
);
};

export default ComponentStack;

我们在使用的时候应该是能拖拽的,所以再给每个li加上draggableonDrag事件,并用 context 记录一下自己 drag 的东西是什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// fragments/componentStack.tsx
import React, { useContext } from 'react';
import { componentList } from '../components';
import { EditorContext } from '../context/editorContext';

const { updateSelectType } = useContext(EditorContext);

const onHandleDrag = (item: Record<string, any>) => {
updateSelectType(item.name);
};

<li
draggable
onDrag={() => {
onHandleDrag(info);
}}
className="border-1 border-gray-900 my-[2px] mx-[5px] px-0 py-[10px]"
key={info.name}
>
<info.Component />
</li>;

要美化的话,可以给 componentList 加个 type 区分一些组件类别等等等操作。

代码可见componentStack

画布区

因为是自己的练手项目,就直接将画布和渲染引擎放在一起了,拖动东西到画布,解析成 json,传送到渲染引擎里进行渲染。

首先我们要明确画布区要实现什么内容

  1. 接收拖拽来的组件

  2. 组装 json

  3. 渲染页面

接收拖拽来的组件

这个就直接在渲染引擎套一个drop事件就可以。然后读取一下context保存的组件类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// page/mainPage.tsx
const onHandleDropContainer = (e: React.DragEvent<HTMLDivElement>) => {
// 将拖拽的组件加到树中
dispatch(addNodeIntoRoot(selectType));
};

// 这个一定要有,dragOver的时候阻止事件传播不然的话不会触发onDrop事件。
const onHandleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};

<div
className="stage h-[90vh] border-1 border-gray-500 overflow-scroll"
onDrop={onHandleDropContainer}
onDragOver={onHandleDragOver}
>
<RenderEngine />
</div>

渲染页面

渲染之前,我们得先知道我们要组装成什么样。

为什么先写渲染json呢,只是为了方便调试,先界定好输入和输出,然后组装的json按照输入来组装就可以了。

想一下 react 的 fiber 节点。搞成类型 fiber 节点那种形式

1
2
3
4
5
{
"fiberRoot": {
"children": [{ "type": "div", "children": ["你好"], "props": {} }]
}
}

或者直接借鉴一下 宜搭的 json 格式,只要能描述出页面就可以,以下是我的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"page": {
"type": "Container",
"children": [
{
"type": "Container",
"children": [
{
"type": "CButton"
},
{
"type": "CInput"
}
]
}
]
}
}

然后只要把这个json树能在画布区域渲染成实际的组件就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// fragments/renderEngine.tsx

// 从顶向下渲染,先渲染根节点,并把根节点下的内容传给组件渲染器
const renderRoot = (scheme: Record<string, any>) => {
// 全局配置可以在这操作
const page = scheme.page;
return <div className="root">{renderComponents(page)}</div>;
};


// 渲染组件
const renderComponents = (section: Record<string, any>) => {
// 取出children
let children = null;
// 判断当前组件是否有子节点,如果有子节点,先渲染子节点。
if (section.children) {
// 将子节点渲染出来
children = renderChildren(section.children);
}

// 渲染当前层级。
return startRender(section, children);
};

const renderChildren = (section: Record<string, any>) => {
// 将子节点们做成数组
let nodeArray = section.children || ([] as any).concat(section);

// 遍历再渲染,相当于递归遍历了,最终做出来子节点及其子节点。
return nodeArray.map((node: any, idx: number) => {
return renderComponents(node);
});
};

// 解析当前组件的json
const startRender = (
section: Record<string, any>,
children?: ReactNode | null,
) => {
// TODOS: 类型需要完善一下暂时先都用any 这么处理一下吧。
// 取出要用的解析器类型
const type = section.type as 'CButton' | 'CInput' | 'Container';
const { componentId } = section;
const RenderMod = parserList[type];

// 直接渲染
if (RenderMod) {
return (
// 这里面就是上面咱们写的parserButton 这种东西,传jsonScheme下去主要是因为里面可以放一些props。
<RenderMod jsonScheme={section} key={componentId}>
{children}
</RenderMod>
);
}
return null;
};

将我搞的这个树的结构传到renderRoot里,就可以在页面上显示内容了。
代码可见renderEngine

组装json

组装json要分情况讨论,一种是直接拖拽到画布上,一种是拖拽到container上去

直接拖拽到画布上的话,其实就是给page.children不断的push东西。

为了偷懒不配置immer,直接安装了个redux/toolkit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// redux/reducers/editSlice.ts 的addNodeIntoRoot方法。

addNodeIntoRoot: (state, action) => {
const { JSONScheme } = state;
const _page = JSONScheme.page;
// 如果page上没有type,初始化page
if (!_page.type) {
// 生成个唯一id
const rootContainerId = genCompId();
_page.type = 'Container';
_page.componentId = rootContainerId;
_page.children = [];
}

// 如果page的type不是container,给page下加个container,并把之前的放到children里。
if (_page.type !== 'Container') {
const oldPage = JSON.parse(JSON.stringify(_page));
const rootContainerId = genCompId();
_page.type = 'Container';
_page.componentId = rootContainerId;
_page.children = [oldPage];
}
// 获取要添加的type
const addType = action.payload;
// 生成唯一ID
const componentId = genCompId();
// 放到page的children下
_page.children.push({ type: addType, componentId });
state.JSONScheme.page = _page;
},

如果拖拽到container上的话。咱们需要先对container进行处理一下。

让container能接收到drop的东西,把这两个放到container上去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
// 不能让他冒泡。
e.stopPropagation();
// 当前的componentId
const { componentId } = jsonScheme;
dispatch(
addNodeIntoContainer({
addType: addNode,
targetComponentId: componentId,
}),
);
};

const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};

然后再来写addNodeIntoContainer:主要思路就是判断targetComponentId是不是container,只有是container才可以加。

1
2
3
4
5
6
7
8
9
10
11
12
13
addNodeIntoContainer: (state, action) => {
const { targetComponentId, addType } = action.payload;
const { JSONScheme } = state;
const _page = JSONScheme.page;
// 树的层级遍历来找节点,找到就直接return出来。
const targetNode = findCompById(_page, targetComponentId);
if (targetNode && targetNode.type === 'Container') {
if (!targetNode.children) {
targetNode.children = [];
}
targetNode.children.push({ type: addType });
}
},

完整代码可见reducers/editSlice

配置面板区

要实现的内容:

  1. 选中不同类型的组件,显示不同的面板

  2. 配置面板更改组件属性,画布区属性跟着变。

配置面板可以按照在做组件parser层那样搞出来一个,然后多个面板凑在一起组成一个对象数组,根据选中的组件来渲染不同的面板就可以了。

那如何取到选中的组件呢?

我们可以在parser加一个onClick事件,选中的时候就在context里更新selectedType就可以了。

那如何更新属性呢?

在jsonScheme里加入props字段,往里透传就可以了,最后结构一下。在选中组件的时候,根据选中的组件id来更新type即可

1
2
3
4
5
6
7
8
9
10
11
12
changeNodeProps:(state,action)=>{
const { targetComponentId, propKey,propValue } = action.payload;
const { JSONScheme } = state;
const _page = JSONScheme.page;
const targetNode = findCompById(_page, targetComponentId);
if(targetNode){
if(!targetNode.props){
targetNode.props= {};
}
targetNode.props[propKey] = propValue;
}
}

总结

目前来说,一个简易的可视化拖动页面其实是已经实现了。目前可以做的优化的点:

  1. 渲染面板的再抽象,抽象成类似于渲染引擎那种,只需要根据不同的组件传进去不同的json,然后生成不同的面板就可以了。

  2. 拖出来的组件如何实现通信?(还没想出来要怎么解决,感觉上是可以通过eventBus来实现)。

    老内容

editor

一般分为四块内容

  1. 操作栏:放一些 撤销,保存等操作的地方
  2. 组件栏:放一些自己封装好的组件,渲染出这个列表,需要编辑页面时从这往外拖
  3. 画布:存放拖拽的东西,要等于最终渲染出来的结果
  4. 属性栏:用来设置选中组件的属性,如文字颜色,大小,背景,宽高等。

render

渲染画布这部分的内容

怎么搞?

DSL,可以简单粗暴的理解为一个大 json。

我们可以用一个 json 来描述一个组件,然后多个 json 拼到一起就是整个页面。

比如一个 button

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
comp: Button,
key: 'my-btn',
label: '按钮',
icon: '',
desc: '这是一个按钮',
category: 'form',
attrs: {
size: 'normal',
value: '确定'
},
style: {
width: 80,
height: 36
}
}

用 key 来作为这个组件的唯一标识,然后其他的作为props传到comp内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// button.jsx
import React from 'react';

interface IProps {
style: React.CSSProperties;
label: string;
// ....
}
const Button: React.FC<IProps> = (props) => {
const { style, label } = props;
return (
<button className="" style={{ ...style }}>
{label}
</button>
);
};

Button.defaultProps = {
style: {},
label: '按钮',
};

export default Button;

// button.config.js
import Button from './index';

// DSL - 数据结构
// 用来描述你的视图信息/行为(业务逻辑)
export const option = {
c: Button,
key: 'my-btn',
label: '按钮',
icon: '',
desc: '这是一个按钮',
category: 'form',
attrs: {
size: 'normal',
value: '确定',
},
style: {
width: 80,
height: 36,
},
};

然后画布和 render 就按照 key 找到组件,然后按传参渲染就可以了。

怎么拖动?

因为是内部使用,也不用考虑什么兼容性,直接 dragEvent 一把梭。

难道要每个组件都写上吗?

作为我这种偷懒前端工程师怎么可能会这么搞呢。有两种方案可以实现

  1. hook,将拖动这类都封装成 hook,然后引入进去就完事了。但是这样还是要重复写好多次。所以看第二种
  2. 利用事件的冒泡机制,在这个列表外面加一层,遇事不决加一层,接收同理,在画布组件外面加一层接收装置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
if (!e) return;
const id = (e.target as HTMLDivElement).dataset.id as string;
console.log('drag start ... ', e);
e.dataTransfer.setData('id', id);
};

<div onDragStart={handleDragStart}>
{componentList.map((comp) => (
<section draggable={true} className="item" data-id={comp.key} key={comp.key}>
<span>{comp.label}</span>
</section>
))}
</div>

怎么画?

全局搞个 store,每次拖动进去时就给这个描述这个组件的 json 扔进去,然后画布遍历这个 store 进行渲染就可以了。

1
2
3
4
5
6
7
8
9
10
const MainCanvas: React.FC = () => {
const { comps } = useContext(CanvasContext);
return (
<>
{comps.map((Comp: any) => {
return <Comp.c />;
})}
</>
);
};

大致逻辑就这样。目前我也刚写到这里哈哈哈哈。到这里的代码简易可视化生成页面

另外在拖动的时候看其他的应该会有个瞄准线,接近的其他元素的时候会出现,还有自动吸附等。

瞄准线的话有个思路:

  1. 给每个元素拖拽到画布上时都标记为同一种 class,然后拖拽时就知道了当前在被拖动的,还有画布中的其他元素。
  2. 遍历其他元素 计算与当前拖动元素的距离,当达到一定阈值时就显示标准线。
  3. 标准线的话就用 div,在画布上预先占位,等到需要显示的时,根据当前拖拽的元素的位置设置这个标准线的位置,并设置显示。

思考

我司主要是虽然是 TODO 类型的软件,但是在输入文本时也是支持 markdown 以及一些特别操作的。比如插入附件,插入标签等等等。因为项目是由 jq 时代直接迁移到 React 项目的,所以编辑栏还是之前的 jq 直接操作 DOM 节点这么搞的,耦合严重且 markdown 部分处理的东西已经非常难以维护了。现在对于内容的保存方式如下

1
2
3
4
5
{
content:"## 111111\n::123456::\n*1122213123123*\n\n[111](www.baidu.com)\n ",
tag:["tag1","tag2"],
attachment:["1","2"]
}

那是不是可以转换成 DSL 这种结构,如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
content: [
{
type: 'h1',
content: '11111',
},
{ type: 'hightLight', content: '123456' },
{
type: 'url',
data: {
name: '111',
target: 'www.baidu.com',
},
},
{
type: 'tag',
tagId: ['tag1', 'tag2'],
},
{
type: 'attachment',
data: [
{
name: '1',
url: '11111',
},
{
name: '1',
url: '11111',
},
],
},
];
}

然后搞一种 dsl2Markdown 的中间插件。按需往里面渲染组件这种思路,扩展的话每次只需要扩展 type 和对应的组件即可。这样后续扩展新的语法也很方便。

以上 dsl 好像不太对。「可视化搭建系统」——从设计到架构,探索前端的领域和意义今天上班摸鱼看到了这篇文章,大概意思就是本地输入 markdown 语法,解析成 AST,然后根据 ast 渲染成 HTML(富文本节点或卡片类型),然后服务器存 HTML 就好了。因为我司的是多端的,所以存入 HTML 会导致其他端有问题,所以存入最原始的 markdown 语法,然后每个端自己去解析 AST->渲染对应组件应该是我司优化的最优解。我司主要是用codemirrior这个东西来做的 markdown 和自定义组件,原理都差不多,也是先解析 ast,然后根据对应类型使用createElement创建 html 标签,然后 append 插进去。部分复杂的就是用react.createElement来创建。看起来优化空间还是有的。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!