本文用于介绍使用React搭建的低代码平台中设计器部分的常见功能以及技术实现,包括从组件列表拖拽组件到画布、鼠标移入画框、选中等功能。
低代码平台
拖拽组件到画布
通过拖拽生成页面是常见的低代码平台实现方案,以 lowcode engine 为例,用户从左侧组件面板拖拽组件到画布:
拖拽有两种方式,第一种是基于 drag and drop api,第二种是基于鼠标事件手动属性拖拽 API。
drag and drop api:
首选渲染组件面板时,设置需要拖拽元素的 draggable
属性和 onDragStart
事件
<div>
{components.map((component) => {
const { displayName, icon, key} = component;
return (
<div className="component-item" key={key} draggable onDragStart={onDragStart(component)}>
<img src={icon} alt="" />
<div className="component-name">{displayName}</div>
</div>
);
})}
</div>
onDragStart
主要用于设置放置时的数据,我是这么写的:
onDragStart = ({ displayName, packageName, packageVersion, componentName, isContainer }: ComponentMeta) => {
return (ev) => {
return ev.dataTransfer.setData(
"componentMeta",
JSON.stringify({
displayName,
packageName,
packageVersion,
componentName
})
);
};
};
在画布区域,监听 onDrop
渲染组件:
<div onDragOver={(e) => e.preventDefault()} onDrop={this.addComponent}></div>
addComponent = (ev: DragEvent) => {
const componentMeta: ComponentMeta = JSON.parse(ev.dataTransfer.getData("componentMeta"));
};
手动实现 drag:
确定组件放置的位置
目前前端框架都是基于数据驱动,我们可以定义页面信息(pageSchema)如下:
{
components: [
{ package: "@soda/base", version: "1.0.0", componentName: "A" },
{ package: "@soda/base", version: "1.0.0", componentName: "Span" }
],
......
componentsTree: [
{
componentName: "Page",
id: "aqasiz7lkk7a3dy222z1",
children: [
{ componentName: "A", id: "aqasiz7lkk7a3dyz1" },
{ componentName: "Span", id: "kje68elzgza63nve" },
],
},
],
}
渲染器根据这部分代码渲染页面,拖拽组件到画布就是修改这部分数据,拖拽组件到画布后,我们需要确定在哪个容器中的哪个位置插入数据,如何获取父容器?onDrop
可以得到放置位置的 dom
,在 react
中, dom
有一个 __reactFiberxxxxxxxxx
属性,它记录了 fiber
节点信息,可以通过 fiber.return
获取父fiber
,判断父fiber
是不是容器,如果不是继续查找 fiber.return
。容器可以自己定义,比如多一个 __isContainer
属性。如何得到容器中的位置?在查询容器的时候,我们遍历了fiber
树,只要我们在渲染时,使用 id
作为组件的 key
,遍历时记录最后一个 “可设计组件”就行了。
什么是可设计组件?这个是相对于 react
组件说的,简单来说就是可以在画布拖拽、选中、删除的组件,它可能由很多 react
组件或者其他“可设计的 react
组件”组成,我使用的方法是从 fiber
查找,所以要区分,如何区分?我强制要求用户开发的组件必须是继承自 BaseComponent
的 class
组件,BaseComponent
如下:
export abstract class BaseComponent<P = any> extends Component<P> {
// fiber 树会多一个 __sodaComponent 属性
static __sodaComponent = true;
}
这个查询流程代码如下:
/**
* 获取设计信息
* @param dom DOM节点
* @returns node 鼠标所在节点
* @returns parents 所有父容器
*/
function findDesignInfoByDOM = (dom: HTMLElement): { node: DesignNode; parents: DesignNode[] } => {
const __reactFiberPropty = Object.keys(dom).find((i) => i.startsWith("__reactFiber"));
const isDesignComponent = (fiber) => fiber?.tag == 1 && fiber?.type.__sodaComponent;
let component = findFiberAndElement(dom[__reactFiberPropty], isDesignComponent);
let parent = component;
let container = null;
const parents = [];
while (parent.fiber) {
if (!container && parent.fiber?.type.__isContainer__) {
container = parent;
}
if (!container) {
component = parent;
}
if (parent.fiber?.type.__isContainer__ && parent.fiber?.tag == 1 && parent.element) {
parents.push({ element: parent.element, type: parent.fiber?.type, id: parent.fiber?.key });
}
parent = findFiberAndElement(parent.fiber, isDesignComponent);
}
return {
node: { element: component.element, type: component.fiber?.type, id: component.fiber?.key },
parents,
};
};
}
findFiberAndElement
用于按条件查询 fiber
和 fiber
最外层 dom
,代码如下:
/**
* 查找满足条件的 fiber 和它的最外层 DOM
* @param fiber
* @returns
*/
export const findFiberAndElement = (fiber, condition: (fiber) => boolean) => {
let element = null;
while (fiber) {
if (fiber.tag === 5) {
element = fiber.stateNode;
}
fiber = fiber.return;
if (condition(fiber)) {
return { fiber, element };
}
}
return { fiber: null, element: null };
};
onDrop
后的添加组件逻辑:
addComponent = (ev: DragEvent<HTMLElement>) => {
const componentMeta: ComponentMeta = JSON.parse(ev.dataTransfer.getData("componentMeta"));
const { node: slibing, parents } = this.findDesignInfoByDOM(ev.target as HTMLElement);
let components = this.pageSchema.componentsTree;
let container = null;
const findParentSchema = () => {
const arr = parents;
outer: for (let index = 0; index < arr.length; index++) {
const { id } = arr[index];
for (let j = 0; j < components.length; j++) {
const current = components[j];
if (current.id === id) {
if (!Array.isArray(current.children)) {
break outer;
}
container = current;
components = current.children;
}
}
}
};
findParentSchema();
const index = container.children.findIndex((i) => i.id === slibing.id);
if (index !== -1) {
container.children.splice(index, 0, { componentName: componentMeta.componentName, id: uuid() });
} else {
container.children.push({ componentName: componentMeta.componentName, id: uuid() });
}
this.pageSchema = {
...this.pageSchema,
componentsTree: [...this.pageSchema.componentsTree],
};
};
移入、选择、放置位置线框的绘制
设计器中鼠标移入到组件上会显示虚线框,点击会显示实线框,拖拽还会显示放置位置:
实现原理还是基于前面提到的 fiber
遍历,具体就是监听 onDragOver
、onPointerMove
、onPointerDown
使用前面提到的 findDesignInfoByDOM
获取设计信息。
绘制选中框、鼠标停留框:
在设计器中绘制一个div
,设置为 position: "absolute",display:none
,点击组件,使用 findDesignInfoByDOM
获取组件最外层 dom
,使用 getBoundingClientRect
获取需要绘制的位置。
绘制鼠标移入位置:
和绘制选中框、鼠标停留框类似,不过要基于 getBoundingClientRect
获取的 element
查找 previousElementSibling
,然后基于 previousElementSibling
的 getBoundingClientRect
绘制,如果没有找到 previousElementSibling
就直接绘制在父容器中。
<span ref={this.insertPositionRef} style={{ borderLeft: "4px solid #1772f6", position: "absolute" }}></span>
/**
* 显示插入位置
* @param ev
*/
onDragOver = (ev) => {
ev.preventDefault();
const { node: slibing } = this.findDesignInfoByDOM(ev.target as HTMLElement);
const lastChildNode = slibing.element.previousElementSibling;
const { x, y, height, width } = lastChildNode ? lastChildNode.getBoundingClientRect() : { x: 0, y: 0, width: 0, height: 0 };
this.insertPositionRef.current.style.top = `${y}px`;
this.insertPositionRef.current.style.left = `${x + width - 4}px`;
this.insertPositionRef.current.style.height = `${height}px`;
};
基于 JSON 渲染页面
定义一个 WebRender
接受 schema
和 componentMap
两个参数 :
<WebRender schema={this.pageSchema} componentMap={componentMap} />
schema
就是前面提到的 pageSchema
,componentMap
是一个以组件包名为键,react
组件对象为值的对象,类似这样:
// pageSchema
{
components: [
{ package: "base", version: "1.0.0", componentName: "A" }
],
......
componentsTree: [
{
componentName: "Page",
id: "aqasiz7lkk7a3dy222z1",
children: [
{ componentName: "A", id: "aqasiz7lkk7a3dyz1" }
],
},
],
}
// componentMap
{ base: { Span, A, Button, EventTest, version: "1.0.0" } }
componentMap
从哪里来?我们可以在组件库打包时,设置为 umd
格式,在设计器中使用 script
引入对于的 js
,挂载到 window
上,在设计器中,通过引入的 js
注册 componentMap
。
WebRender
遍历 pageSchema
的 componentsTree
,根据 components
从 componentMap
中获取组件,渲染到页面。
代码如下:
export class WebRender extends Component {
render(): ReactNode {
const { componentsTree } = this.props.schema;
return <>{this.schemasToComponent(componentsTree)}</>;
}
/**
* 查询组件
* @param componentName
* @param componentMap
* @returns
*/
getComponent(componentName: string, componentMap: RenderType["componentMap"]): ElementType {
const Comp = componentMap[packageName][componentName];
if (!Comp) {
if (componentName === "Page") {
Comp = Page;
} else {
Comp = forwardRef(() => <div>{componentName}组件不存在</div>);
}
}
return Comp;
}
/**
* 通过组件 schema 渲染组件
* @param schemas
* @param componentMap
* @returns
*/
schemasToComponent(schemas: PageSchema["componentsTree"], componentMap = this.props.componentMap) {
return schemas.map((schema) => {
const { componentName, id, children: childrenMeta, props = {} } = schema;
const Comp = this.getComponent(componentName, componentMap);
const children = !Array.isArray(childrenMeta) ? null : this.schemasToComponent(childrenMeta, componentMap);
return (
<Comp key={id} id={id} {...props} ref={(ref) => (this.refsMap[id] = ref)}>
{children}
</Comp>
);
});
}
}
右侧属性修改,关联组件如何创新渲染
更改 schema
,让 WebRender
自动更新,属性有很多种,比如:
{
"type": "JSExpression",
"value": "this.a"
}
{
"type": "JSExpression",
"value": "(function(){\n return \"aaa\"\n})()",
"mock": "button"
}
{
"type": "JSSlot",
"value": []
}
WebRender
增加一个方法,把这些类型变成真实的属性,这样就能完成属性修改,组件渲染了。
组件部分(待补充)
列可拖拽、删除的表格
有点低代码平台的表格可以通过拓展新增列,也可以选中列,然后删除,如何实现?表格需要分为设计态和运行态两种状态。
-
在设计态,使用CSS和容器组件模拟出表格外观。实际上,它是一个容器组件加上多个独立的组件组成,它看起来像是一个表格,这样的话就可以对表格的列进行拖拽、新增、删除。
-
在运行态,这些表单组件会被渲染成真正的表格列,同时在最外面渲染一个 Form 组件。
需要注意:
-
组件需要提供一个配置,用于确定显示数据的什么字段;
-
拖入表单组件(拖入表单组件,需要判定父组件是不是表格,如果是,需要修改 name);
表单组件快速生成思路
我们需要开发一个 API 管理中心,它支持常见 API 格式(比如 OpenAPI 3.0 标准)的注册、也支持手动录入,这样的话就可以把用户的 API 处理成系统 API格式。
表单组件往往会绑定一个接口,我们可以对接这个 API 中心,根据 API 元数据(method、body、params、path 参数、headers 参数等)生成表单。