低代码平台精髓:常见功能及实现思路

831 阅读6分钟

本文用于介绍使用React搭建的低代码平台中设计器部分的常见功能以及技术实现,包括从组件列表拖拽组件到画布、鼠标移入画框、选中等功能。

低代码平台

拖拽组件到画布

通过拖拽生成页面是常见的低代码平台实现方案,以 lowcode engine 为例,用户从左侧组件面板拖拽组件到画布:

image.png

拖拽有两种方式,第一种是基于 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:

参考:juejin.cn/post/714544…

确定组件放置的位置

目前前端框架都是基于数据驱动,我们可以定义页面信息(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 查找,所以要区分,如何区分?我强制要求用户开发的组件必须是继承自 BaseComponentclass 组件,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 用于按条件查询 fiberfiber 最外层 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],
    };
  };

移入、选择、放置位置线框的绘制

设计器中鼠标移入到组件上会显示虚线框,点击会显示实线框,拖拽还会显示放置位置:

截屏2024-08-28 15.37.08.png

image.png

实现原理还是基于前面提到的 fiber 遍历,具体就是监听 onDragOveronPointerMoveonPointerDown 使用前面提到的 findDesignInfoByDOM 获取设计信息。

绘制选中框、鼠标停留框:

在设计器中绘制一个div,设置为 position: "absolute",display:none,点击组件,使用 findDesignInfoByDOM 获取组件最外层 dom,使用 getBoundingClientRect 获取需要绘制的位置。

绘制鼠标移入位置:

和绘制选中框、鼠标停留框类似,不过要基于 getBoundingClientRect 获取的 element 查找 previousElementSibling,然后基于 previousElementSiblinggetBoundingClientRect 绘制,如果没有找到 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 接受 schemacomponentMap 两个参数 :

 <WebRender schema={this.pageSchema} componentMap={componentMap} />

schema 就是前面提到的 pageSchemacomponentMap 是一个以组件包名为键,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 遍历 pageSchemacomponentsTree,根据 componentscomponentMap 中获取组件,渲染到页面。

代码如下:

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 参数等)生成表单。