likes
comments
collection
share

基于X6开发 JavaScript 蓝图

作者站长头像
站长
· 阅读数 6

蓝图是什么

这里的蓝图其实指的是使用图形化的技术来创建代码逻辑,像是虚幻引擎,建模软件 blender 等等都有这种功能,开发可以通过拖放节点,连线来决定代码执行顺序,可以设置参数,蓝图自带基础节点,比如流程类的循环,分支,数学类,判断等等

基于X6开发 JavaScript 蓝图

为什么选择X6

先分析下蓝图最基本的功能(图编辑)

  • 需要一个画布进行节点的放置、缩放管理等
  • 对各种类型的节点定义,拖拽到画布中,复制粘贴等
  • 对节点进行位置更改,框选等
  • 对节点连线,表示逻辑流程以及参数的设置等

如果要完全自己实现功能,需要的工作量还是很大的,所以肯定是要选择开源库的,我个人还是很喜欢 Antv 的,插件化,很容易定制和拓展,所以最后选了 X6 作为技术底座,来进行图编辑相关的功能,这是官网的介绍

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

这样只需要关心事情就变成了

  • 自定义节点格式以及对应图形
  • 连线处理,参数设置
  • 运行逻辑

最后技术选型选了 React + X6

实现

参考虚幻引擎的蓝图:

基于X6开发 JavaScript 蓝图

我们先要定义这个图形,然后区分节点类型,连接桩的布局可以使用 magnetX6 里只要 dom 上带有 magnet 属性,就可以作为边的连接点,参数设置只需要有个类型对应的设置器就行了

初始化 X6 实例

这里初始化需要做的比较多,比如注册插件、注册节点 shape、注册连线的路由,定义合法节点等等

这里说一下缩放调整网格背景,如果不处理,缩小之后网格会比较难看

基于X6开发 JavaScript 蓝图

const gridSize = {
  1: 16,
  0.5: 16 * 2,
  0.33: 16 * 3,
  0.16: 16 * 6,
};
export function updateGridSize(graph: Graph, scale: number) {
  if (scale <= 0.16) {
    graph.setGridSize(gridSize[0.16]);
  } else if (scale <= 0.33) {
    graph.setGridSize(gridSize[0.33]);
  } else if (scale <= 0.5) {
    graph.setGridSize(gridSize[0.5]);
  } else {
    graph.setGridSize(gridSize[1]);
  }
}

const graph: Graph = new Graph({
  // 略
  mousewheel: {
    enabled: true,
    guard(event: WheelEvent) {
      const scale = graph.transform.getScale().sx;
      if (scale >= 1 && event.deltaY < 80) {
        if (event.ctrlKey) {
          return true;
        }
        return false;
      }
      updateGridSize(graph, scale);

      return true;
    },
  },
  scaling: {
    min: 0.13,
    max: 2,
  },
  background: {
    color: '#262626',
  },
  grid: {
    visible: true,
    type: 'doubleMesh',
    size: 16,
    args: [
      {
        color: '#353535', // 主网格线颜色
        thickness: 2, // 主网格线宽度
      },
      {
        color: '#161616', // 次网格线颜色
        thickness: 2, // 次网格线宽度
        factor: 8, // 主次网格线间隔
      },
    ],
  }
})

基于X6开发 JavaScript 蓝图

然后就是设置 allowPort 来判断边和连接点是否能够连接,比如 input 出来的边只能和 output 连接,并且类型需要一致,比如是否都为执行引脚或者是相同数值类型的参数,也可以联动 highlight 来做一些高亮等处理

定义基本图形

X6 也是支持自定义 shape,用的是 React 所以用 @antv/x6-react-shape 插件的 register,所以直接定义一个BaseNode 作为最基本的节点,头部 icon 以及 节点名称,加上内部的执行引脚和参数引脚

import { register } from '@antv/x6-react-shape';

export enum ShapeName {
  BASENODE = 'base-node',
}
function BaseNode(props) {
  const data = props.node.getData();
  const nodeWrapRef = useRef<HTMLDivElement>(null);

  useLayoutEffect(() => {
    if (nodeWrapRef.current) {
      const boundRect = nodeWrapRef.current.getBoundingClientRect();
      props.node.setSize(boundRect.width, boundRect.height);
    }
  }, []);

  useLayoutEffect(() => {
    const pos = props.node.getPosition();
    props.node.setPosition(pos.x + 0.000000001, pos.y  + 0.000000001);
  }, []);

  return (
    <div className="custom-base-node" ref={nodeWrapRef}>
      <div className="custom-base-node-top">{data.name}</div>
      <div className="custom-base-node-content"></div>
    </div>
  );
}
register({
  shape: ShapeName.BASENODE,
  component: BaseNode,
});

这里第一个 useLayoutEffect 想要自适应节点的大小,所以设置了一次size,第二个则是因为 graph.fromJSON 渲染完发现边会出现错乱的情况,暂时用 setPosition hack了一下,还没去找真正的原因

这两点有更好的做法大家可以说一下

定义蓝图节点分类

因为流程总是由一个事件触发开始的,比如鼠标、键盘、或者是观察的事件等,然后根据执行引脚执行相关的流程,一般也是函数体,所以先枚举一个 NodeType 以及对应的 icon 以及 颜色 来区分下类型

enum NodeType {
  FUNCTION = 'Function',
  EVENT = 'Event',
}
export const NodeTypeMeta = {
  [NodeType.FUNCTION]: {
    icon: SVGIcon.function,
    color: '64, 109, 247',
  },
  [NodeType.EVENT]: {
    icon: SVGIcon.event,
    color: '180, 25, 25',
  }
};

使用 JSON 描述节点

接下来就要想想如何使用 JSON 描述一个蓝图节点, X6 是推荐在 data 中去携带业务数据,并且提供 getData setData 等相关联的操作,所以我们额外的属性就放在 data 里即可,我们先简单定义一下需要的类型

export interface Pin {
  // 引脚类型
  type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'exec';
  // 设置器
  setter?: string;
  // 参数显示名称
  displayName?: string;
  // 参数名称
  name?: string;
  // 存储的类型 比如是 object 或者是 array
  fields?: Pin | Pin[];
  // 参数值
  value?: unknown;
  [key: string]: unknown;
}
export interface NodeDataItem {
  //用于判断触发了哪一个节点
  id: string;
  //节点名称
  name: string;
  //节点类型
  type: NodeType;
  //节点的shape 默认是 base-node
  nodeShape?: ShapeName;
  //描述
  description?: string;
  //执行输入
  execInput?: Pin[];
  //执行输出
  execOutput?: Pin[];
  //参数输入
  input?: Pin[];
  //参数输出
  output?: Pin[];
  [key: string]: unknown;
}

假设是一个按钮 ,需要一个点击事件的节点描述, 让用户只需要这么写(这里 event 的 fields 参数太多, 就不写了):

{
  "name": "onClick",
  "displayName": "点击",
  "output": [
    {
      "name": "event",
      "displayName": "event",
      "type": "object"
    }
  ]
}

然后在 createNode 的时候添加一些默认的字段,比如 shape 直接用 ShapeName.BaseNode 和 type 为 NodeType.Event等字段,顺便存一个 originalData 来方便复制粘贴

// data 为上面的 json 处理过的
const node = graph.createNode({
  shape: data.nodeShape || ShapeName.BASENODE,
  type: data.type,
  width: 300,
  height: 150,
  ...data.defaultX6Setting,
  data: {
    ...data,
    originalData: cloneDeep(data),
  },
});

我们看看添加节点后展示的样子

基于X6开发 JavaScript 蓝图

如何执行流程

可以通过 X6toJSON 把图的 Cells 全部存储起来,然后在代码运行的时候,通过执行边进行寻找下一个执行节点,参数也是通过边进行寻找,如果没有边则直接读取当前的 value,就可以了

效果展示

图相关的操作逻辑 X6 已经做的非常好了,所以只需要额外做一点事情就可以完成我们所需要的业务功能,这是最后在产品中集成的效果展示 点击链接查看.mp4

最后

在这个示例中的图表其实也是用的 AntvG2,所以非常感谢 Antv 让我节省了很多时间,作为开源的使用者,所以在使用的过程中将遇到的问题解决后也会回馈到社区,在 issue 里或者群聊中解答疑问,虽然都是微不足道的小问题,但也算是做到从开源中来,到开源中去了

基于X6开发 JavaScript 蓝图

希望 Antv 越来越好,能够帮助到越来越多的人