blog-frontend
blog-frontend copied to clipboard
流程图编辑器调研
流程图编辑器
1. 前言
本篇内容包含:
- 工作流方向全方位介绍
- 基本概念
- BPMN规范
- BPMN生态
- 实例:流程设计器 + 流程引擎实现一个接口
- 前端流程设计器对比:
- bpmn.js
- X6
- LogicFlow
- 结果结论
1.1 基本概念
工作流:做一些事情的时候,把事情抽象为几个步骤,再合理的组织这些步骤,再来做这件事。
流程设计器:用图形化的方式来表示工作流,通常在Web环境。
流程引擎:用来执行我们组织的步骤的引擎,通常在后端环境。
BPMN:**业务流程模型和标记法**(BPMN, Business Process Model and Notation),业务流程建模的行业规范,从图上的节点、连线的含义、提交到工作流引擎的数据格式,都给出了具体的定义。
1.2 构思一下整体流程
假设我们现在可以通过在网页上画图的方式描述做一件事情的大体流程,然后通过编码/拖拽之类的方式补齐部分处理节点需要执行的操作,此时,这份数据结构包含两块数据:
- 视图数据:节点、连线的视图数据、坐标数据,用于从这份数据中还原流程图的展示
- 模型数据:流程图本质是数据结构中的有向图,因此包含了:图的数据结构,也就是“流向,从哪里来,到哪里去”,以及“执行的操作”
这个数据不管是用XML还是JSON承载都是可以的,反正都会通过“流程引擎”解析文件(流程引擎仅需要模型数据),得到执行的全过程。
即:前端流程编辑器 → 生成数据 → 流程引擎执行
2. 业务流程模型和标记法 (BPMN规范)
不同的人画的图不一样、不同的流程设计器产出的数据也不一样。显然是需要统一的,这就是这个规范出现的背景。
无论后续开发是否基于这个规范、是否兼容这个规范,了解它都是有价值的。
2.1 最简单的例子
图上元素均为BPMN元素
现在描述了一个流程,开始 → 做一件事 → 结束,然后就能够导出一份符合BPMN2.0规范的文件(.bpmn后缀,通常是XML),看一下文件内容:
2.2 BPMN核心概念简述
中文文档可以参考 AWS中文文档,下面进行一些简单概述:
只有三个核心概念:
- 事件Events:事件用来描述流程的生命周期中发生了什么事。形状总是一个圆圈。
- 活动Activities:活动是业务流程定义的核心元素,描述执行什么操作。形状总是一个方框。
- 网关Gateway:网关用来控制流程的执行流向(可以简单理解为if-else)。形状总是一个旋转了的正方形。
排他网关:理解为 if- else if - else 的逻辑控制流程
2.3 BPMN 流程图例
从代码上,等价于:
const var1 = 'unknown data'
if (var1 === 2) {
service3()
return
}
if (var1 === 1) {
service1()
} else {
service2()
}
service4()
2.4 生态(流程引擎方向)
-
Java(这几个都是含有了流程设计器的,不过年龄都比较大了)
-
NodeJS(这边流程引擎稍微比较拉,npm上也没什么下载量)
- bpmn-moddle:用 JavaScript 读写 BPMN 2.0 XML 文件的库。
- **bpmn-engine:流程引擎**
- bpmn: 流程引擎
- CabloyJS: 全栈框架,实现了BPMN规范的流程引擎
2.5 流程引擎NodeJS后端示例
背景:产品需要一个登录功能,登录流程已经通过流程设计器设计完成(如下图),产出了一份bpmn文件,后端如何根据这份bpmn文件,实现一个POST /login接口?
示例流程
login.bpmn内容
后端示例选型:express.js + bpmn-engine
简单NodeJS后端应用的基本雏形:
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const apiToBpmnFile = {
login: fs.readFileSync("./source/login.bpmn"),
};
app.post("/login", (req, res) => {
startEngine(req, res, apiToBpmnFile.login);
});
app.listen(6666);
startEngine实现:
function startEngine(req, res, source) {
// 初始化engine
const engine = new Engine({
source,
});
// 开始执行
engine.execute({
services,
variables: {
...req.body,
},
});
// 执行完毕返回结果
engine.once("end", (scope) => {
return res.json(scope.environment.output);
});
}
service实现:
const axios = require("axios");
module.exports = {
async checkPassword(scope, next) {
const r = await axios.post("https://xxxxxxx/api/user/login", {
userName: scope.environment.variables.username,
userPwd: scope.environment.variables.password,
});
scope.environment.output = r.data;
next();
},
};
实际效果:
3. 流程编辑器对比
前端绘制图形无非就是 HTML + CSS、Canvas、Svg 三种方式,对比如下:
在流程图的场景下,不需要渲染大量的节点(最多几千个元素),对于动画的诉求也不高。Svg 基于 DOM 的特性会更适合,一个是学习成本和开发成本更低,另一个是基于 DOM 可以做的拓展也更多。
3.1 候选流程图编辑器
选取了三个现代的且具有代表性的做了一下详细调研:
名称 | Github状态 | 渲染方式 | 开发体验(文档、API、源码、社区) | 拓展性 | 性能 |
---|---|---|---|---|---|
bpnm.js | 6.8k star | svg | 🌟 | 🌟 | - |
antv - X6 | 4.1k star | svg | 🌟🌟🌟🌟🌟 | 🌟🌟🌟🌟 | 🌟🌟🌟🌟🌟 |
滴滴 - LogicFlow | 3.7k star | svg | 🌟🌟🌟🌟 | 🌟🌟🌟🌟🌟 | 🌟🌟🌟 |
其他编辑器,供参考:
Excalidraw: 在线白板,canvas,基于rough.js实现手绘风格的线条,美观性不错
**Draw.io(与mxgraph同源): 老牌作图工具了,非常全能,可以看下网易云音乐基于DrawIO二次开发用于内部绘图工具**
**go.js: 基于canvas绘制,主打一个复古风格**
activiti-modeler:activiti的流程设计器,也是主打一个复古
3.2 bpmn-js - 在线使用
bpmn.js 基本操作界面
目前看到的bpmn.js的评价,其实正面评价不多,或者说基本没有正面评价
优势:
- 对BPMN规范的还原度高
劣势:
- “源码的可读性问题,导致前端并不能理解bpmn-js的思路。为了实现需求,硬着头皮乱改最终的结果往往是前端跑路。我们部门之前有个给教育部门做审批的项目,哪个前端接手,哪个前端没多久就离职。”
- “由于bpmn-js的自定义机制不够开放,当某些产品需要做更多深度扩展的时候,其自定义效果往往不满足。例如我之前看到的要给流程图来个展开收起的功能,用bpmn-js基本不可能实现。”
- 没有官方文档,这是最逆天的,即便是2022年9月的回复,也没有编写文档。目前的”文档“只有看源码或者中文社区的解释文章。这一点也导致中文社区的bpmn.js用户都处于“个别开拓者”的微信群中,比较封闭,毕竟拉了群可以恰饭。
3.2.1 小结
bpmn.js 是一个遵循了 BPMN 规范的流程设计器,但是除了 BPMN 规范之外,似乎没有其他亮点可供参考。
3.3 Antv - X6
X6 2.0版本
官方文章说:更重要的是我们希望通过插件机制,联合社区开发者一起将一些优秀的功能沉淀下来,可以畅想不久之后,X6 具有非常丰富的图编辑场景插件集合,它能大幅降低应用开发成本。
虽然是插件机制,但是并没有提供自定义插件的口子,这里看起来拓展上时不太方便的,也许会有自己维护一份X6的可能。
X6 架构图
3.3.1 渲染Vue组件到SVG中的原理
渲染自定义组件可能是后面开发重要的一个环节,且实现方式非常不错,内容会比较细
实现的核心:
- SVG foreignObject: SVG中的
<foreignObject>
元素允许包含来自不同的 XML 命名空间的元素。在浏览器的上下文中,很可能是 XHTML / HTML。 - Vue3 teleport: 将其插槽内容渲染到 DOM 中的另一个位置。
整体思路(x6-vue-shape源码):
-
SVG foreignObject可以支持插入HTML,那么不管是Vue还是React还是HTML字符串,只要提供一个挂载点rootEl,就可以渲染出来一个组件,注册Vue自定义节点就是这样的流程:
- createApp(Vue组件)
- app.mount(rootEl下的一个节点)
例如伪代码:
setHtml(rootEl) { const node = document.createElement('div') rootEl.appendChild(node) if (!this.isMounted) { this.app = createApp(VueComponent) // this.app.use(ElementPlus) this.app.mount(node) this.isMounted = true } }
这样做有什么问题呢?挂载一个Vue自定义节点到SVG中需要createApp,这个app和全局main.js中创建的app是不一样的,已经跨App了,那么全局注册的ElementPlus组件在这个app中是没有的,组件渲染不出来。
-
Vue3 teleport: 因此,X6使用了teleport,利用全局的app来渲染组件,然后搬运到挂载节点下。
Telport注释位置即为组件原始位置,随后被传送到了svg中的挂载节点下
整体代码如下
import { defineComponent, onMounted, ref } from "vue"; import { Graph } from "@antv/x6"; import { register, getTeleport } from "@antv/x6-vue-shape"; // 在普通vue组件上额外包装后的组件 import Count from './component/button.vue' const TeleportContainer = getTeleport(); export default defineComponent({ name: "x6-element", setup() { const graph = ref(); onMounted(async () => { graph.value = new Graph({ container: document.getElementById("container") as HTMLElement, width: 600, height: 400, grid: true, }); register({ shape: "custom-vue-node", component: Count, }); graph.value.addNode({ shape: "custom-vue-node", data: { num: 0, }, }); }) return () => { return ( <div> <div id="container" style={{width: "100%", height: '100%'}}></div> <TeleportContainer/> </div> ) } } })
实现源码如下:
// x6-vue-shape简化代码,同时也适用于LogicFlow import { defineComponent, Fragment, Teleport, h, reactive, markRaw, Component, VNode } from "vue"; export const registerMap = reactive<Record<string, Component>>({}); export function register( key: string, comp: Component, rootEl: HTMLElement, options: Record<any, any> = {}, ): VNode { const div = document.createElement("div"); rootEl.appendChild(div); const core = h(comp, { ...options }); registerMap[key] = markRaw( defineComponent({ render: () => h(Teleport, { to: div }, [core]), }), ); return core; } export function getTeleport(): Component { return defineComponent({ setup() { return () => { return h( Fragment, {}, Object.keys(registerMap).map((id) => h(registerMap[id])), ); }; }, }); }
3.3.2 给自定义组件添加连接桩
在上方添加了两个连接桩
register({
shape: "custom-vue-node",
component: Count,
// 连接桩定位依赖组件宽高
width: 100,
// 连接桩配置
ports: {
groups: {
group1: {
position: 'top',
// 自定义连接桩样式
attrs: {
circle: { r: 6 }
},
},
},
// 配置两个连接桩
items: [
{ id: 'port1', group: 'group1' },
{ id: 'port2', group: 'group1' },
],
},
});
可以看到配置连接桩还是比较简单,只是需要处理Vue组件宽高的问题,可以参考这个issue下的回答
这个在框架内部是不容易做到的,但是外部可以实现,在节点内部的 vue 组件渲染完成后,获取尺寸,然后 resize 节点。不过还是建议在项目中固定节点宽和高。
3.3.3 小结
- 性能:“X6 2.0 新的渲染引擎比通用的 SVG 渲染引擎快 5 倍以上”
- 架构:“开放的插件架构”,但是并没有自定义插件的口子
- 自定义组件渲染:Vue3 teleport的方式比较亮眼,在渲染组件以及连接桩的方面是没有大问题的。
- 数据格式转换:通过 graph.toJSON 等方法转换
- 文档示例:比较清晰,有些问题还是要通过翻issue寻找
3.4 LogicFlow
LogicFlow 架构图
3.4.1 渲染Vue组件到SVG中
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
import { VNode } from 'vue';
import CustomComponent from '../components/button.vue';
// 仿照 x6-vue-shape 实现
import { register } from './vue-shape'
// view层
class VueHtmlNode extends HtmlNode {
private isMounted: Boolean;
private instance: VNode | null;
constructor (props: any) {
super(props)
this.isMounted = false
this.instance = null
}
setHtml(rootEl: HTMLElement) {
const prop = this.props.model.getProperties();
if (!this.isMounted) {
this.instance = register('vue-html', CustomComponent, rootEl, {
...prop,
onNodeClick: (val: number) => {
this.props.model.setProperties({ count: ++val })
},
})
this.isMounted = true
} else {
this.instance!.component!.props!.count = prop.count
}
}
}
// model层
class VueHtmlNodeModel extends HtmlNodeModel {
setAttributes() {
this.width = 214;
this.height = 32;
this.text.editable = false;
}
// 定义左右两个锚点
getDefaultAnchor() {
const { width, x, y, id } = this;
return [
{
x: x - width / 2,
y,
name: 'left',
id: `${id}_0`
},
{
x: x + width / 2,
y,
name: 'right',
id: `${id}_1`,
},
]
}
}
export default {
type: 'vue-html',
model: VueHtmlNodeModel,
view: VueHtmlNode
}
3.4.2 流程图部分节点展开收起
LogicFlow通过本身通过插件的方式支持了鼠标框选、分组(节点),要实现部分节点展开收起,只需要如下几步:
- 自定义分组节点的样式
- 框选:通过鼠标在画布上绘制一块区域,获取区域内所有的节点。
- 事件监听:创建一个和框选区域一样大的分组,然后将选区内的节点id传入
3.4.3 小结
直接对比X6与logicFlow
- 定义vue节点的方式,X6胜出
- x6: 普通Vue组件 → 包装一层,添加相关能力的Vue组件 → 注册
- logicFlow: 普通Vue组件 → 定义ViewClass和ModelClass → 注册
- 拓展能力:logicFlow胜出,主要是可以自定义插件
- 性能:X6胜出,见下表
- 数据格式处理:logicFlow胜出,带插件,能兼容BPMN规范
3.5 LogicFlow X6性能对比
X6 2.0 重新设计和实现了渲染架构,完全使用异步模式,充分利用异步渲染的特点,通过优先级调度、渲染任务合并、可视区域渲染等设计,无论是首屏还是交互,新的渲染引擎比通用的 SVG 渲染引擎快 5 倍以上,React 渲染模式首屏阻塞时长和 react-flow 不相上下
测试用例:均使用宽100,高80,带有4个连接桩的rect节点。且X6关闭了异步渲染。
100节点 + 100连线,10次渲染平均时间
框架 | 平均时间(ms) |
---|---|
LogicFlow | 264.8 |
X6 | 203.7 |
500节点 + 200连线,10次渲染平均时间
框架 | 平均时间(ms) |
---|---|
LogicFlow | 574.1 |
X6 | 397.3 |
5000节点 + 1000连线,10次渲染平均时间
框架 | 平均时间(ms) |
---|---|
LogicFlow | 8047.8 |
X6 | 3949.2 |
4. 结论
不同的业务目标会有不同的选型:
如果目标是快速且能与Java后端流程引擎结合的比较好,有两种选择,恰好都是国外的:
- bpmn.js(较为现代) + Java的 Activiti 或者 Camunda
- activiti-modeler(不够现代) + Java的 Activiti (差不多Activiti全家桶了,基本功能应该很丝滑)
如果目标在于足够美观、又新又好、定制能力强,有两种选择,恰好都是国内的:
- X6: 性能强、文档好
- LogicFlow: 稍逊X6一筹,但是对于BPMN规范的支持略微领先X6一步
如果我们基于 X6 或者其他现代流程设计器继续开发,需要做的事情:
- 实现基本的BPMN节点,根据二八定律,大概实现十个左右就能满足80%场景
- 实现一些需要的自定义节点、左侧物料面板、工具条、节点属性面板
- 数据结构转换:将设计器JSON数据,转换为后端通常需要的BPMN XML格式数据或者任意自定义格式
如果存在NodeJS后端服务需要用流程引擎,需要做的事情:
- 了解一下NodeJS生态流程引擎代码实现,考虑拓展or内部维护一份,上面NodeJS的例子踩坑不少。
后续补充:https://live.yworks.com/demos/layout/layoutstyles/,体验一下这个布局算法