基于 AntvX6 的流程编排系统搭建实践

基于 AntvX6 的流程编排系统搭建实践

一、 引言

众所周知,前端的流程化配置与框架升级,一直以来都是所有前端开发所需要经历的一环。恰巧最近有项目要进行整合重构,一想这不就有上手案例了,干脆直接拥抱新技术(当然也不算新了)。

这次的开发中,主要涉及节点、转换插件的流程和图表编排,所以自然是少不了使用流程框架,这里简单列举几个市场常见的框架:Butterfly(阿里)、JointJS、G6、X6。最后综合考虑选型更适用react的开发方式的 X6 作为项目解决方案。另外由于是从零构建新系统,这里我使用的是 Procomponents 来搭建基础页面及表单框架,这个框架先不展开讨论,因为里面也有许多优点和坑,留到下次我再做分享。本文的方向主要是对 X6 在前端业务中的使用实践和问题进行展开。

二、 X6技术分析

2.1 X6 简介与 Bpmn.js 选型对比

在第一次接触X6的时候,我就被他可视化、可编排的能力所吸引,同时他还具备针对各种业务场景的可拓展性,具体的功能树展示如下,感兴趣的话,大家可以根据自己感兴趣的方向去尝试体验一下。

那么说了半天X6具体是什么呢,官方给出的介绍是这样的:

X6 是基于 HTML 和 SVG 的图编辑引擎,提供低成本的定制能力和开箱即用的内置扩展,方便我们快速搭建 DAG 图、ER 图、流程图、血缘图等应用。

这里也简单介绍下 bpmn.js

BPMN(业务流程管理)是由 BPMN组织 发布一种业务流程建模和标记的标准协议。它提供了一种符号系统,用于描述业务流程中的各种活动、事件、网关和流程之间的关系。

作为技术栈的选型之一,也作为选型思考的例子,这里分析了下两者的异同点:

相同点

图形化建模展示: bpmn.js 和 X6都是用于图形化建模的工具,它们都注重通过清晰、直观的方式实现可
视化功能,让用户更好的创建和展示业务流程或图形界面,从而加速用户的理解和相互沟通设计内容的效率。

支持事件交互: 都支持一定程度的交互功能,如节点之间的连接、事件的触发等。

定制化 :两者都允许用户进行定制化操作来实现特定的业务场景。

不同点

概念和业务方向:

less 复制代码
bmpn.js :bpmn.js是一款由 BPMN.io 组织基于 BPMN 2.0 开发的一款前端开发工具集。它更
适用于描、解释和分析业务流程,例如像OA工单系统的流程配置场景,它能够更好的让技术与非技术
人员交流和理解。

X6: X6基于 HTML 和 SVG 的图编辑引擎,它基于HTML5 Canvas和SVG技术,提供了丰富的功能
来创建各种类型的图形、流程图和图表。它适用于创建和定制图形化界面和画布工具,特别是在需要
高度定制和灵活性的应用程序中,拥有相对bpmn.js 更高的定制化开发能力。

相对而言, X6 更友好兼容react写法,在处理复杂流程时相对 bpmn.js 更好理解,同时它在图 表节点的渲染和功能的自定义实现上更为突出。另外 还有一个重要的优点,就是 X6 团队在 2.x版本做了相当多的插件分包处理,每一个依赖包都可以单独引入使用,实现了按需加载, 所以综合对比后,我选择了X6来实现任务流程的可视化工作。

三、 项目实践

3.1 编排设计与踩坑

3.1.1 流程设计思路

下面具体讲一下如何设计和开发的,在说这一环节的时候,我希望大家了解一个概念:DnD ,字面含义就是:Drag and Drop(拖拽与放置)。在三方库中, DnD集成了store 、Backends(负责注册拖拽相关的事件)、teardown(销毁事件)等一系列操作,了解完概念那我们再往下看。

根据对业务需求的分析,流程模块主要是分三块区域,思考的页面设计如下:

节点配置: 这里配置画布需要的节点与转换插件,X6支持最基础的例如:圆形、菱形、方形等图形节点,同时 也可以支持图片、自定义节点;

sql 复制代码
节点 用来定义数据源与数据目标,同时节点需要拥有配置的能力;

转换插件 用来处理上下游不同数据类型的对应数据转换,目前包含:
    Copy 对数据源字段的复制,扩充
    FilterRowKind 数据库增量同步时用于对特定的sql类型进行过滤
    Replace 对数据源字段的替换
    SQL 在传输过程中执行 SQL数据转换操作,如concat

内容编排: 整体的画布区域Graph,节点、连接桩、群组、圈选、拖拽等操作都在这里进行,也可以对画布做定制化开发,同时画布也集成了快捷操作工具,方便用户使用;

节点逻辑管理: 这里指的是节点或插件所需的配置管理,包含对单个节点、连线的基础数据、转化方法和自定义属性的存储转化,是整个流程的核心;

整体的设计方案有了,那么接下来就是开发阶段,具体怎么去实现这一系列的交互呢?我们一步步地来展开去讲。

3.1.2 自定义节点定义与实现

为了拥抱新技术,这里我用的是X6官方最新的 2.x 版本,相对于 1.x 繁重的配置和使用方式,官方做了代码解耦,插件单独拆包安装,做到可插拔,更多的改动可以看 2.x版本升级文档 详细了解,这里不多赘述。

当我刚接触X6的时候我是一头雾水的,因为去看了官方文档中最贴合业务的案例,它长这样:

图中虽然具有基础的节点配置,但是与UI和业务的要求还是有一定差距,所以第一反应就想到了自定义节点,就直接去搜是否有自定义节点的react写法。在这里不得不吐槽一下X6的文档,虽然api跟基础用法很齐全,但是对小白来说阅读起来相当吃力,缺少单个方法的运行案例,也存在很多无法查找到的问题,你只能自己去看底层源码。

官方为react节点单独提供了 @antv/x6-react-shape 用于注册和渲染DOM,这里需要注意x6的 版本必须和 @antv/x6-react-shape 保持在一个大版本,且插件最新版本 仅支持react18 ,低于18的版本需要将插件锁定在 2.0.8

这里业务还需要增加节点的tooltip提示,但是由于是同一节点的配置,侧边栏中会存在提示错位的问题,这里我是通过定位元素父元素来处理浮窗绑定的。位置,如果有更好的方式也可以互相交流,具体实现如下:

ini 复制代码
import { Tooltip } from 'antd';
import { register } from '@antv/x6-react-shape';
...
// 自定义react组件节点
const NodeComponent = ({ ...arg }) => {
  const { node, graph } = arg;
  const { label, icon, tip } = node.getData();
  const { sourceType, sourceInstance, sinkType, sinkInstance, dbUri } = node.attr('nodeData') || {};
  const isOriginCell = ['source', 'sink'].includes(node.attr('action'));
  const selectCells = graph ? graph.getSelectedCells() : [];
  const isSelected = selectCells.some(item => item.id === node.id);

  const renderTip =
    isOriginCell && (sourceType || sinkType) ? (
      <div>
        <div>节点信息</div>
        <div>
          数据库类型:
          <span>{dbTypeEnums.find(v => v.value === (sourceType || sinkType))?.label || '-'}</span>
        </div>
        <div>
          资源名称:<span>{sourceInstance || sinkInstance}</span>
        </div>
      </div>
    ) : (
      tip
    );
  const isInStencil = trigger => {
    const parentContainer = trigger.parentNode.parentNode.parentNode.parentNode;
    const checkInSide = parentContainer.classList.contains('x6-node-immovable'); // 存在该class则在stencil内(比较Low的方法)
    return checkInSide ? document.body : trigger;
  };
  return (
    <Tooltip
      className="x6-node-item"
      placement="top"
      title={renderTip}
      fresh
      getPopupContainer={trigger => isInStencil(trigger)}
    >
      <div
        className="react-node w-170 h-36 flex-left bg-white pointer"
        style={{ border: '1px solid #E8E9ED' }}
      >
        <span className="w-3 h-100 bg-408BFF" />
        <ZaIcon type={icon} className="f-s-16-imp m-l-10 col-8AA08E-imp" />
        <span className="m-l-8">{label}</span>
        {(sourceType || sinkType) && (
          <ZaIcon
            type={iconType[sourceType || sinkType]}
            className="f-s-16-imp m-l-12 col-8AA08E-imp"
          />
        )}
      </div>
    </Tooltip>
  );
};

当我们实现了自定义节点的功能,接下来就是怎么渲染到整个画布的节点配置中,这里就涉及到了一个关键点:节点注册。官方提供了一个包@antv/x6-react-shape ,这个依赖包集成了包括 node、view、registry、portal等等不同工具类的相关方法,这里我们使用registry中的 register 方法,底层ts定义如下:

ini 复制代码
import React from 'react';
import { Graph, Node } from '@antv/x6';
export type ReactShapeConfig = Node.Properties & {
    shape: string;
    component: React.ComponentType<{
        node: Node;
        graph: Graph;
    }>;
    effect?: (keyof Node.Properties)[];
    inherit?: string;
};
export declare const shapeMaps: Record<string, {
    component: React.ComponentType<{
        node: Node;
        graph: Graph;
    }>;
    effect?: (keyof Node.Properties)[];
}>;
export declare function register(config: ReactShapeConfig): void;

这里我们注册一个名为 custom-react-node 的节点名,对属性 component 配置我们定义好的 NodeComponent 即可,下面的ports配置了该节点在 left、right 方向上的连接点组。

php 复制代码
import React from 'react';
import { register } from '@antv/x6-react-shape';
...
// 自定义节点注册
register({
  shape: 'custom-react-node',
  width: 170,
  height: 36,
  component: NodeComponent,
  ports: {
    groups: {
      right: {
        position: 'right',
        attrs: {
          circle: {
            r: 5,
            magnet: true,
            stroke: '#006aff',
            strokeWidth: 1,
            fill: '#fff',
            style: {
              visibility: 'hidden',
            },
          },
        },
      },
      left: {
        position: 'left',
        attrs: {
          circle: {
            r: 5,
            magnet: true,
            stroke: '#006aff',
            strokeWidth: 1,
            fill: '#fff',
            style: {
              visibility: 'hidden',
            },
          },
        },
      },
    },
    items: [
      {
        group: 'right',
      },
      {
        group: 'left',
      },
    ],
  },
});

当我们完成了注册后会发现,此时节点还没有出现在侧边节点区域,这里我们还需要做的一步,就是在X6的Graph初始化时进行初始化赋值,这里又涉及到一个注册方法 Stencil

Stencil 是在 Dnd 基础上的进一步封装,提供了一个类似侧边栏的 UI 组件,并支持分组、折叠、搜索等能力。

上面提到在2.x版本中,X6将很多沉重的方法都做了抽离,在使用时,我们可以根据自己的情 况进行按需引入。这里我引入了 @antv/x6-plugin-stencil 依赖包, 先实例化一个stencil配置, 再通过 this.stencil.load(...) 的注册方法,将定义好的节点配置 r1、r2、t1、t2 加载到 Stencil中:

php 复制代码
import { Stencil } from '@antv/x6-plugin-stencil';
import './shape'; // 这里是上述的注册定义方法,做了代码拆分
...
 // 侧边栏
  static initStencil() {
    this.stencil = new Stencil({
      target: this.graph, // 指定画布
      title: '数据源/数据目标', // 标题
      stencilGraphWidth: 210, // 侧边栏宽度
      // search: { rect: false }, // 搜索
      groups: [
        // 分组
        {
          name: 'basic',
          title: '数据源/数据目标',
          graphHeight: 120,
          layoutOptions: {
            columns: 1,
          },
        },
        {
          name: 'transforms',
          title: '转换插件',
          layoutOptions: {
            columns: 1,
          },
          graphHeight: 400,
        },
      ],
      layoutOptions: {
        // 布局
        columns: 1,
        columnWidth: 180,
        rowHeight: 50,
      },
    });
    const stencilContainer = document.querySelector('#stencil');
    stencilContainer?.appendChild(this.stencil.container);
  }
  ...
   // 初始化节点
  static initShape() {
    const { graph } = this;
    const r1 = graph.createNode({
      shape: 'custom-react-node',
      width: 170,
      height: 36,
      attrs: {
        action: 'source',
        body: {
          rx: 2,
          ry: 2,
        },
        text: {
          textWrap: {
            text: '数据源',
          },
        },
      },
      data: {
        label: '数据源',
        icon: 'icon-Source',
        // tip: 'Source',
      },
    });
    ...
    const t1 = graph.createNode({
      shape: 'custom-react-node',
      width: 170,
      height: 36,
      attrs: {
        action: 'transForm',
        transformType: 'Copy',
        text: {
          textWrap: {
            text: 'Copy',
          },
        },
      },
      data: {
        label: 'Copy',
        icon: 'icon-copy',
        tip: '对数据源字段的复制,扩充',
      },
    });
    const t2 = graph.createNode({
      shape: 'custom-react-node',
      width: 170,
      height: 36,
      attrs: {
        action: 'transForm',
        transformType: 'Replace',
        text: {
          textWrap: {
            text: 'Replace',
          },
        },
      },
      data: {
        label: 'Replace',
        icon: 'icon-Replace',
        tip: '对数据源字段的替换',
      },
    });
    ...
    this.stencil.load([r1, r2], 'basic'); // 指定加载的区域name,这里是节点
    this.stencil.load([t1, t2], 'transforms'); // 指定加载的区域name,这里是插件
  }

经过上述的配置操作,我们就可以在画布初始化后实现自定义的侧边节点配置栏了。

3.1.3 注册事件和自定义事件

当然有人可能会问:如果节点一直要拖拽那不是很麻烦吗,画布就没有快速操作的方法吗?

其实这个不用担心,X6还集成了各种事件的交互、订阅,甚至可以绑定Mac、Windows的快 捷键,做到 一键回退、恢复、圈选复制、粘贴、右键菜单 等快捷操作,另外它也支持 自定义 事件 ,下面举例:

ini 复制代码
 // 初始化事件
  static initEvent() {
    const { graph } = this;
    const container = document.getElementById('container');
    /**
     * 监听节点折叠事件
     */
    graph.on('node:collapse', ({ node, e }) => {
      e.stopPropagation();
      node.toggleCollapse();
      const collapsed = node.isCollapsed();
      const cells = node.getDescendants();
      cells.forEach(n => {
        if (collapsed) {
          n.hide();
        } else {
          n.show();
        }
      });
    });
    // 回退事件
    graph.bindKey(['meta+z', 'ctrl+z'], () => {
      graph.undo();
      return false;
    });
    // 画布居中
    graph.bindKey(['meta+f', 'ctrl+f'], () => {
      graph.fitToContent();
      return false;
    });
    // 重做事件
    graph.bindKey(['meta+shift+z', 'ctrl+y'], () => {
      graph.redo();
      return false;
    });
    /**
     * 自定义事件,绑定键盘delete键,mac为ctrl+delete
     */
    graph.bindKey('delete', () => {
      const cells = graph.getSelectedCells();
      if (cells.length) {
        graph.removeCells(cells);
      }
    });
    /**
     * 绑定windows键盘的删除
     */
    graph.bindKey('backspace', () => {
      const cells = graph.getSelectedCells();
      if (cells.length) {
        graph.removeCells(cells);
      }
    });
  }

完成了上面的节点操作和快捷键的事件绑定后,我们通过new Graph(...) 来配置所有需要的节点注册、工具类方法,最后一步将graph初始化后,我们就实现了拖拽节点、快捷操作的功能。

这里需要注意的一点是,圈选、键盘事件、History一类的方法我们需要单独引入依赖,参考 如下:

javascript 复制代码
import { Graph, Shape } from '@antv/x6';
import { Selection } from '@antv/x6-plugin-selection';
import { Snapline } from '@antv/x6-plugin-snapline';
import { Keyboard } from '@antv/x6-plugin-keyboard';
import { Clipboard } from '@antv/x6-plugin-clipboard';
import { History } from '@antv/x6-plugin-history';
...
static init() {
    this.graph = new Graph({
      container: document.getElementById('container'), // 画布容器
      // shift 平移
      panning: {
       enabled: true, // 画布是否可以拖动
       eventTypes: ['leftMouseDown'],
       modifiers: 'shift', // 按住shift 可以平移
       },
      grid: { visible: true },      // 网格
      // 配置全局的连线规则
      connecting: {
        anchor: 'center', // 当连接到节点时,通过 anchor 来指定被连接的节点的锚点,默认值为 center。
        connectionPoint: 'anchor', // 指定连接点,默认值为 boundary
        allowBlank: false, // 是否允许连接到画布空白位置的点
        highlight: true, // 拖动边时,是否高亮显示所有可用的连接桩或节点
        snap: true, // 当 snap 设置为 true 时连线的过程中距离节点或者连接桩 50px 时会触发自动吸附
        // 连接的过程中创建新的边
        createEdge() {
          return new Shape.Edge({
            attrs: {
              line: {
                stroke: '#5F95FF',
                strokeWidth: 1,
                targetMarker: {
                  name: 'classic',
                  size: 8,
                },
              },
            },
            router: {
              name: 'manhattan',
            },
            zIndex: 0,
          });
        },
        // 在移动边的时候判断连接是否有效
        validateConnection({ sourceView, targetView, sourceMagnet, targetMagnet }) {
          if (sourceView === targetView) {
            return false;
          }
          if (!sourceMagnet) {
            return false;
          }
          if (!targetMagnet) {
            return false;
          }
          return true;
        },
      },
      // 可以通过 highlighting 选项来指定触发某种交互时的高亮样式
      highlighting: {
        magnetAvailable: {
          name: 'stroke',
          args: {
            padding: 14,
            attrs: {
              strokeWidth: 4,
              stroke: 'rgba(223,234,255)',
            },
          },
        },
      },
      snapline: true, // 启动对齐线
      history: true, // 启用撤销/重做
      // 启用剪切板
      clipboard: {
        enabled: true,
      },
      // 启用键盘快捷键
      keyboard: {
        enabled: true,
      },
      // 通过embedding可以将一个节点拖动到另一个节点中,使其成为另一节点的子节点
      embedding: {
        enabled: true,
        findParent({ node }) {
          const bbox = node.getBBox();
          return this.getNodes().filter(node => {
          // 只有 data.parent 为 true 的节点才是父节点
          const data = node.getData();
          if (data && data.parent) {
            const targetBBox = node.getBBox();
            return bbox.isIntersectWithRect(targetBBox);
          }
         return false;
         });
        },
      },
    });
    this.graph
      .use(
        // 激活键盘事件
        new Keyboard({
          enabled: true,
        }),
      )
      .use(
        // 激活重做事件
        new History({
          enabled: true,
        }),
      )
      .use(
        new Selection({
          className: 'flow-select-item',
          rubberband: true,
          showNodeSelectionBox: true,
        }),
      )
      .use(new Keyboard())
      .use(new Clipboard())
      .use(new History());
    this.initStencil();
    this.initGraphShape();
    this.initShape();
    this.initEvent();
    return this.graph;
  }

3.2 数据流处理

3.2.1 数据节点及插件配置能力实现

在完成了重要的拖拽功能的基础上,我们还需要给节点添加数据的配置能力,这里对业务需求认真分析后,我遇到了几个主要问题,前两个问题是:

markdown 复制代码
1. X6怎么实现监听节点的点击、双击触发例如高亮、弹窗操作?
2. 怎么能对指定节点进行数据配置?

带着这些问题,我翻阅了官方文档并找到了对应解法:

这里通过监听 cell:dblclick 事件,双击弹出对应节点弹窗,因为单击还有选中高亮的业务功能,这样来解决第一个问题:

less 复制代码
useEffect(() => {
    const graph = FlowGraph.init(); // 初始化流程图
    setIsReady(true);
    graph.on('cell:dblclick', ({ cell }) => {
      setType(cell.isNode() ? CONFIG_TYPE.NODE : CONFIG_TYPE.EDGE); // 判断点击的是节点还是边
      setCellId(cell.id); // 保存节点ID
      const cellAction = cell.attr('action');
      typeof cell.attr('action') === 'object' &&
        cell.attr('action', Object.values(cellAction).join(''));
      typeof cell.attr('transformType') === 'object' &&
        cell.attr('transformType', Object.values(cell.attr('transformType')).join(''));
        cell.isNode() && changeDrawerType(cell.attr('action'), { ...cell.attr(), id: cell.id });
    });
    ...
  }, []);
    const changeDrawerType = (choose, record) => {
    const { mixin_openZaDrawer } = props;
    mixin_openZaDrawer(choose, drawerContent(record)[choose]);
  };
  ...
  // 弹窗页面配置
  const drawerContent = row => ({
  ...
   transForm: {
      title: 'transform配置',
      footer: null,
      layout: 'horizontal',
      labelCol: { span: 3 },
      wrapperCol: { span: 15 },
      // labelAlign: 'left',
      grid: true,
      form,
      autoFocusFirstInput: true,
      className: 'form-base',
      drawerProps: {
        width: 1300,
        destroyOnClose: true,
        onClose: () => changeDrawerType('none'),
        bodyStyle: { paddingTop: 20, paddingBottom: 20 },
        zIndex: 1100,
      },
      submitter: {
        searchConfig: {
          submitText: '确认',
          resetText: '取消',
        },
      },
      onFinish: values => onHandleSaveTransform(values, row?.id),
      renderContent: () => {
        const basicData = parentFormRef?.current[0]?.current?.getFieldsValue();
        return <FlowTransform cellData={row} form={form} basicFormInfo={basicData} />;
      },
    },
   });

在X6中,画布的核心就是 cell 节点,通过配置 cell.attr 属性数据添加自定义属性来做个性 化操作。这里我们定义了画布节点的 action 判断是否为转换插件节点,同时也定义了 transformType 判断为是哪一类插件,从而来唤起对应配置弹窗,这样就解决了第二个问题。

3.2.2 数据存储回显和校验拦截

上述代码已经实现了基础的单点配置功能,然而最关键的问题来了,那就是:怎么将配置数据保存到对应节点/边,对接后端复杂的数据结构又如何处理?

这块说实话也琢磨了不少时间,最后发现其实同样的是对cell属性进行设置,上面的弹窗我们定义了 onFinish 做数据的表单提交处理,实现如下:

ini 复制代码
  // 表单的数据保存
  const onHandleSaveTransform = async (values, id) => {
    const { graph } = FlowGraph;
    const {
      inputSchema = [],
      outputSchema = [],
      model = [],
      outputInitSchema = [],
      ...others
    } = values;
    const cell = graph.getCellById(id); // 获取选择节点
    const oldData = cell.attr();
    const newData = {
      ...oldData,
      nodeData: values,
    };
    cell.updateAttrs(newData);
    message.success('配置成功');
    // 不返回不会关闭弹框
    changeDrawerType('none');
    form.resetFields();
    return true;
  };

这块逻辑非常容易懂,通过 cell.attr() 可以获取到对应节点的上一状态信息,数据都保存 在我们定义的 attr: { nodeData: ... } 中,合并数据后调用 cell.updateAttrs(...) 就完成了节点的数据更新。

当然只是更新了肯定是不够的,表单数据的获取和回填也很重要。在X6中的节点生表单的数据保存成时都会自动生成一个唯一标识id,这里我们从外层将节点数据 cellData 传入 ,通过 graph.getCellById(id) 方法我们获取到对应的cell元素,cell.isNode() 判断是否为节点,因为需要根据前置节点Source的配置数据做表单逻辑处理,这里用到 graph.getPredecessors() 方法获取到前置所有节点list,因为只有一个前置所以取第一个数据,后面就可以自由写逻辑做数据回填了。具体操作如下,这么看存取操作是不是很简单呢!

ini 复制代码
  import React, { useEffect, useState, useRef, useMemo } from 'react';
  ...
  const cellRef = useRef();
  useEffect(() => {
    if (cellData?.id) {
      const { graph } = InitGraph;
      const cell = graph.getCellById(cellData?.id); // 获取选择节点
      if (!cell || !cell.isNode()) {
        return;
      }
      cellRef.current = cell;
      setCellType(cell.attr('transformType'));
      const prevCell = graph.getPredecessors(cellRef.current)[0];
      cellData?.nodeData && setOutputDataSource(cellData?.nodeData?.outputSchema || []); // 存在历史数据则不用请求,直接展示历史数据
      if (isEmpty(prevCell?.attr('nodeData'))) return;
      const prevFormData = prevCell?.attr('nodeData');
      setPreSourceData(prevFormData);
      getInputTableData({
        dataNodeId: prevFormData?.sourceNodeId,
        database: prevFormData?.sourceInstance,
        table: prevFormData?.dbTable,
      });
    }
  }, [cellData?.id]); 
  ...

数据处理完,最后一步我们需要对数据进行一系列的校验拦截,因为存在节点与节点间的联动逻辑判断,同时也需要配合后端的数据结构,对画布数据的原始结构做一波处理,大致调整为

bash 复制代码
[
  {
    source:{...}, 
    tranform:{...}, 
    sink:{...}
  },
  ...
]    

代码部分展示如下,这里我主要的几个判断是:

  1. 节点数据的非空判断;

  2. 是否存在插件节点的前/后置节点未连接情况, 保证画布配置的链接是否符合业务规范;

  3. 各节点配置的完整性;

ini 复制代码
 // 处理数据并提交
  const filterNodesSubmit = async () => {
    const { graph } = FlowGraph;
    let isPass = false; // 是否能提交
    // 定义一个全局的nodesData数组
    const nodesData = [];
    // 获取所有节点元素
    const cells = graph.getNodes();
    // 遍历所有节点元素
    try {
      cells.forEach(cell => {
        const nodeId = cell.id;
        if (isEmpty(cell.attr('nodeData'))) {
          message.warning('存在节点信息配置为空,请检查链路信息');
          throw Error();
        }
        // 判断节点的id在nodesData中的各类型中的id都不存在的情况
        if (
          !nodesData.some(
            data =>
              data.source?.id === nodeId ||
              data.transform?.id === nodeId ||
              data.sink?.id === nodeId,
          )
        ) {
          // 获取前后节点是否存在并获取值
          const incomingEdges = graph.getIncomingEdges(cell) || [];
          const outgoingEdges = graph.getOutgoingEdges(cell) || [];
          // 判断节点是否有前置节点并且类型为source,当前节点为sink,并且前置节点为source
          if (cell.attr('action') === 'source' && outgoingEdges.length === 0) {
            message.warning('存在数据源配置缺少后续数据目标或数据转化插件,请检查链路信息');
            throw Error();
          }
          if (cell.attr('action') === 'sink' && incomingEdges.length === 0) {
            message.warning('配置数据目标前请先配置数据转化插件或数据源信息,并进行连接');
            throw Error();
          }
          if (
            cell.attr('action') === 'transForm' &&
            incomingEdges.length === 0 &&
            outgoingEdges.length === 0
          ) {
            message.warning('存在转化插件链路配置不规范,请检查链路信息');
            throw Error();
          }
          if (
            incomingEdges.length > 0 &&
            outgoingEdges.length === 0 &&
            cell.attr('action') === 'sink' &&
            incomingEdges[0]?.getSourceCell()?.attr('action') === 'source'
          ) {
            const sourceNode = incomingEdges[0].getSourceCell();
            if (isEmpty(sourceNode.attr('nodeData'))) {
              message.warning('存在数据源信息配置不规范,请检查链路信息');
              throw Error();
            }
            if (isEmpty(cell.attr('nodeData'))) {
              message.warning('存在数据目标信息配置不规范,请检查链路信息');
              throw Error();
            }
            nodesData.push({
              source: { ...sourceNode.attr('nodeData'), id: sourceNode?.id },
              sink: { ...cell.attr('nodeData'), id: cell?.id },
            });
          }
         ...
          // 判断当前节点类型为transform并且有前置和后置节点
          if (cell.attr('action') === 'transForm') {
            if (incomingEdges.length === 0) {
              message.warning('配置数据转化插件前请先配置数据源信息,并进行连接');
              throw Error();
            }
            if (outgoingEdges.length === 0) {
              message.warning('存在转化插件缺少数据目标信息');
              throw Error();
            }
            if (isEmpty(cell.attr('nodeData'))) {
              message.warning('存在转化插件配置不规范,请检查链路信息');
              throw Error();
            }
            const sourceNode = incomingEdges[0].getSourceCell();
            const targetNode = outgoingEdges[0].getTargetCell();
            ...
            nodesData.push({
              source: { ...sourceNode.attr('nodeData'), id: sourceNode?.id },
              transform: {
                options: {
                  model,
                  inputSchema: JSON.stringify(inputSchema),
                  outputSchema: JSON.stringify(outputSchema),
                  ...others,
                },
                database,
                table,
                optionType,
              },
              sink: { ...targetNode.attr('nodeData'), id: targetNode?.id },
            });
          }
        }
      });
    } catch (err) {
      console.log(err);
      return;
    }
    if (isEmpty(nodesData)) {
      message.warning('存在链路配置不规范,请检查链路信息');
      return;
    }
    isPass = true;
    // 校验通过,返回提交后台的整体数据
    ...
  };

这里使用了 graph.getIncomingEdges()graph.getOutgoingEdges() 方法,按官方文档解释 是:分别获取当前节点输入和输出边,直接点就是连接当前节点的上下游边。我们用这个方法 可以判断是否有连接且边的数量是否唯一,因为同一个插件只能允许一个source、sink连接。 然后通过 getSourceCell()getTargetCell() 获取类型为插件节点的上下游边的source、sink节 点在做逻辑判断,最后组装了完整的业务数据给到后端,自此就完成了一整个逻辑校验与数据 提交。

3.3 功能落地

经过一系列关于上述复杂的数据交互和逻辑处理后,最终落地了任务流编排得可视化功能初版,具体实现效果展示如下:

四、 总结与思考

本文介绍的内容比较碎片化,其实X6是一个功能很强大的库,可以开箱即用、定制化能力也很丰富,这里介绍的也只是一部分能力,如果想学习的还是需要自己去实践下。

在项目实践的过程中,也加深了我对前端可视化场景的思考,怎么根据需求去确定选型方向其实很重要,前期的准备是必不可少的。

我们在选择前端框架时,需要综合考虑项目需求、团队技术栈以及框架的特性和系统适配性,比如上手成本、运行性能、是否支持移动/客户端使用、项目依赖间的兼容性喝后期的维护成本等等。当你明确了这些方向后,其实能减少很多无用功,从而提高开发效率。当然市场上常见的Butterfly、JointJS、G6也有各 自适配的业务场景,选择适合自己的技术才是最好的选择。

随着前端技术的不断发展,前端可视化方向的技术也在不断演进,未来也许还可以结合大数据、AI等新技术,做出更智能、更高效的方案出来,想想还是挺有意思的事,所以还需要继续学习,持续拥抱新技术。

五、 参考文献

官网:X6官方文档

官网:Procomponents官方文档

博文:X6 在云音乐低代码流程编排中的实践

羽雀:X6问题FAQ

博文:前端流程图框架对比选型

博文:「AntV X6」从 5 个核心要素出发,快速上手AntV X6图可视化编排

博文:工作流引擎设计

相关推荐
余道各努力,千里自同风几秒前
前端 vue 如何区分开发环境
前端·javascript·vue.js
软件小伟9 分钟前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾30 分钟前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧39 分钟前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm1 小时前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
asleep7011 小时前
第8章利用CSS制作导航菜单
前端·css
hummhumm1 小时前
第 28 章 - Go语言 Web 开发入门
java·开发语言·前端·python·sql·golang·前端框架
幼儿园的小霸王2 小时前
通过socket设置版本更新提示
前端·vue.js·webpack·typescript·前端框架·anti-design-vue
疯狂的沙粒2 小时前
对 TypeScript 中高级类型的理解?应该在哪些方面可以更好的使用!
前端·javascript·typescript
gqkmiss2 小时前
Chrome 浏览器 131 版本开发者工具(DevTools)更新内容
前端·chrome·浏览器·chrome devtools