文章目录
- 前言
- 一、bpmn.js是什么?
- 1.bpmn.js简介
- 2.为什么要选择bpmn.js
- 二、在vue中集成Bpmn.js
- 1.下载依赖
- 2.引入样式
- 三,bpmn.js事件
- 1,监听modeler并绑定事件
- 2,监听element并绑定事件
- 3.通过监听事件判断操作方式
前言
流程编辑器
什么是流程编辑器:
流程编辑器是一种用于创建、编辑和管理流程图的工具。它提供了一个可视化的界面,使用户能够以图形化的方式定义和配置流程的各个步骤、条件和流程间的关系。
流程编辑器通常用于业务流程管理、工作流程管理和业务流程自动化等领域。它可以帮助用户轻松地设计和管理复杂的流程,而无需编写大量的代码。通过拖拽和连接不同的图形元素,用户可以定义流程的起始点、结束点、流程分支、条件判断、任务执行等。
流程编辑器还通常提供了一些额外的功能,如版本控制、权限管理、流程模板的导入和导出等。它可以与其他系统集成,以便将流程定义应用于实际的业务场景中。
流程编辑器的目的是简化流程设计和管理的过程,提高工作效率,并确保流程的正确性和一致性。它在许多领域中都有广泛的应用,包括项目管理、工作流程自动化、电子商务等。
流程编辑器有多种不同的种类,每种都具有不同的特点和用途。以下是一些常见的流程编辑器种类:
- 工作流程编辑器(Workflow Editors):用于创建和管理工作流程,包括定义任务、流程分支、条件和工作流程的执行顺序等。
- 业务流程管理(BPM)编辑器(Business Process Management Editors):用于设计和管理业务流程,支持复杂的流程建模和流程优化。
- UML(统一建模语言)编辑器(UML Editors):用于创建和编辑UML图,包括类图、时序图、用例图等,用于软件系统的设计和建模。
- 数据流程编辑器(Data Flow Editors):用于创建和管理数据流程,包括数据输入、处理和输出的流程图。
- 网络拓扑编辑器(Network Topology Editors):用于设计和管理网络拓扑结构,包括节点、连接和网络设备的配置。
- 流程图编辑器(Flowchart Editors):用于创建和编辑流程图,包括流程的各个步骤、条件和流程控制的图形表示。
- 规则引擎编辑器(Rule Engine Editors):用于创建和管理规则引擎,包括定义规则、条件和规则执行顺序等。
这只是一些常见的流程编辑器种类,实际上还有许多其他类型的流程编辑器,每种都有其特定的用途和功能。具体使用哪种编辑器取决于具体的需求和应用场景。
我用的是业务流程编辑器(bpmn.js)
一、bpmn.js是什么?
1.bpmn.js简介
bpmn.js是一个用于在Web应用程序中渲染和编辑BPMN(Business Process Model and Notation)流程图的JavaScript库。它提供了一套功能强大的API和工具,可以帮助开发人员在应用程序中集成BPMN流程图的显示和编辑功能。
使用bpmn.js,开发人员可以将BPMN流程图嵌入到他们的应用程序中,并与其它组件进行交互。它支持创建、修改和删除BPMN元素,如任务、网关、事件等,并提供了丰富的事件和回调函数,以便开发人员可以根据用户的操作进行相应的处理。
bpmn.js还支持将BPMN流程图导入和导出为XML格式,以便与其他BPMN工具进行交互和共享。它还提供了丰富的样式和主题选项,使开发人员可以自定义流程图的外观和样式。
总的来说,bpmn.js是一个强大的工具,可以帮助开发人员在Web应用程序中实现BPMN流程图的显示和编辑功能,并与其它组件进行集成。
官网:https://bpmn.io/.
2.为什么要选择bpmn.js
activiti 官方支持的流程编辑器是ActivitiModeler,现在已经停止维护而且如果需要前后端分离使用流程编辑器,并不是很友好。
选择使用bpmn.js有以下几个原因:
- 完整的BPMN支持:bpmn.js是一个专门用于处理BPMN流程图的库,它提供了完整的BPMN规范支持,包括各种BPMN元素、事件和流程控制等。这使得它成为构建和管理BPMN流程图的理想选择。
- 强大的功能和灵活性:bpmn.js提供了丰富的API和工具,使开发人员可以轻松地创建、修改和删除BPMN元素。它还支持导入和导出BPMN流程图,以便与其他BPMN工具进行交互和共享。此外,bpmn.js还提供了自定义样式和主题的选项,使开发人员可以根据需要自定义流程图的外观和样式。
- 跨平台和易于集成:bpmn.js是基于JavaScript的库,可以在各种Web应用程序中使用。它与现代Web技术和框架(如React、Angular和Vue.js)兼容,并且可以与其他组件和工具进行无缝集成。这使得它非常适合在现有的应用程序中添加BPMN流程图的显示和编辑功能。
- 社区支持和活跃度:bpmn.js拥有庞大的开源社区支持,并且由Camunda等知名公司进行维护和更新。这意味着它有一个活跃的开发者社区,可以提供帮助、解决问题并分享经验。
总而言之,选择使用bpmn.js可以让开发人员轻松地在Web应用程序中实现BPMN流程图的显示和编辑功能,并且具有强大的功能、灵活性和跨平台的特点。
二、在vue中集成Bpmn.js
1.下载依赖
最简单的一种使用方式:直接使用
CDN
将bpmn.js
引入到代码中
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>BPMNJS</title>
<!--CDN加速-->
<script src="https://unpkg.com/bpmn-js@6.0.2/dist/bpmn-viewer.development.js"></script><!--引入一个简单的xml字符串-->
<script src="./xmlStr.js"></script>
<style>
#canvas {
height: 400px;
}
</style>
</head>
<body>
<div id="canvas"></div>
<script>
var bpmnJS = new BpmnJS({
container: '#canvas'
});
bpmnJS.importXML(xmlStr, err => {
if (!err) {
// 让图能自适应屏幕
var canvas = bpmnJS.get('canvas')
canvas.zoom('fit-viewport')
} else {
console.log('something went wrong:', err);
}
});
</script>
</body>
</html>
(上面的xmlStr.js
就是自定义的文件,里面放置了关于流程的xml,也可以不引用直接在vue文件中定义)
如上面的案例所示, 我们使用CDN
加速直接引入bpmn.js
, 然后本地指定一个容器(也就是id
为canvas
的那个div
), 接着用bpmn.js
提供的方法importXML
就可以解析xml
字符串生成对应的工作流图了。
运行代码:
上面提供的使用方式是一种最基本的方式,仅仅是将图展示出来,不能自己绘画也不能操作. 所以在工作中使用更多的还是采用npm安装到项目中使用. 我们可以使用以下命令进行安装:
使用
npm
下载
npm install --save bpmn-js
注意: 如果在已有项目引入,可能会因为版本问题导致启动失败,最好是看看相对于的版本
我这里使用的版本是:
"dependencies": {
"bpmn-js": "^6.2.1",
"bpmn-js-properties-panel": "^0.33.1",
"camunda-bpmn-moddle": "^4.3.0",
"core-js": "^3.4.4",
"houtaroy-bpmn-js-properties-panel-activiti": "0.0.1",
"svg-sprite-loader": "3.7.3",
},
2.引入样式
安装好依赖后,在main.js
文件中引入样式:
// bpmn 相关依赖
import 'bpmn-js/dist/assets/diagram-js.css'
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css'
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-codes.css'
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css'
// 左边工具栏以及编辑节点的样式
import 'bpmn-js-properties-panel/dist/assets/bpmn-js-properties-panel.css'
新建一个bpmn.vue
页面,编写html
代码
<template>
<div id="app">
<div class="container">
<!-- 创建一个canvas画布 npmn-js是通过canvas实现绘图的,并设置ref让vue获取到element -->
<div class="bpmn-container">
<div class="bpmn-canvas" ref="canvas"></div>
<!-- 工具栏显示的地方 -->
<div class="bpmn-js-properties-panel" id="js-properties-panel"></div>
</div>
<!-- 把操作按钮写在这里面 -->
<div class="action">
<el-button icon="el-icon-download" @click="downloadBpmn" title="下载流程文件"></el-button>
<el-button icon="el-icon-picture" @click="downloadSvg" title="下载流程图"></el-button>
<el-button type="success" icon="el-icon-check" circle title="保存修改" @click="editModel"></el-button>
<a hidden ref="downloadLink"></a>
</div>
</div>
</div>
</template>
编写js
代码
<script>
import BpmnModeler from 'bpmn-js/lib/Modeler'
// 工具栏相关
// import propertiesProviderModule from 'bpmn-js-properties-panel/lib/provider/camunda'
import propertiesPanelModule from 'bpmn-js-properties-panel'
// import camundaModdleDescriptor from 'camunda-bpmn-moddle/resources/camunda'
import activitiModdleDescriptor from './activiti.json'
// 引入
import propertiesProviderModule from 'houtaroy-bpmn-js-properties-panel-activiti/lib/provider/activiti'
// 汉化
import customTranslate from './customTranslate.js'
export default {
data () {
return {
modelId: '',
bpmnModeler: null,
canvas: null,
bpmnTemplate: ``
}
},
methods: {
newDiagram () {
this.createNewDiagram(this.bpmnTemplate)
},
// 下载bpmn xml文件
downloadBpmn () {
const that = this
that.bpmnModeler.saveXML({ format: true }, (err, xml) => {
if (!err) {
// 获取文件名
const name = `${that.getFilename(xml)}.bpmn20.xml`
// 将文件名以及数据交给下载方法
that.download({ name: name, data: xml })
}
})
},
// 下载bpmn.svg流程图片
downloadSvg () {
const that = this
that.bpmnModeler.saveXML({ format: true }, (err, date) => {
if (!err) {
// 获取文件名
const name = `${that.getFilename(date)}.svg`
// 从建模器画布中提取svg图形标签
let context = ''
const djsGroupAll = that.$refs.canvas.querySelectorAll('.djs-group')
for (let item of djsGroupAll) {
context += item.innerHTML
}
// 获取svg的基本数据,长宽高
const viewport = that.$refs.canvas
.querySelector('.viewport')
.getBBox()
// 将标签和数据拼接成一个完整正常的svg图形
const svg = `
<svg
xmlns='http://www.w3.org/2000/svg'
xmlns:xlink='http://www.w3.org/1999/xlink'
width='${viewport.width}'
height='${viewport.height}'
viewBox='${viewport.x} ${viewport.y} ${viewport.width} ${viewport.height}'
version='1.1'
>
${context}
</svg>
`
// 将文件名以及数据交给下载方法
that.download({ name: name, data: svg })
}
})
},
// 获取文件名
getFilename (xml) {
const regex = /<process.*?id="(.*?)"/
const match = xml.match(regex)
if (match) {
return match[1]
}
return null
},
// 编辑模型
editModel () {
const that = this
that.bpmnModeler.saveXML({ format: true }, (err, xml) => {
if (!err) {
// 获取文件名
const name = `${that.getFilename(xml)}`
// // 从建模器画布中提取svg图形标签
// let context = ''
// const djsGroupAll = this.$refs.canvas.querySelectorAll('.djs-group')
// for (let item of djsGroupAll) {
// context += item.innerHTML
// }
// // 获取svg的基本数据,长宽高
// const viewport = this.$refs.canvas
// .querySelector('.viewport')
// .getBBox()
// // 将标签和数据拼接成一个完整正常的svg图形
// const svg = `
// <svg
// xmlns='http://www.w3.org/2000/svg'
// xmlns:xlink='http://www.w3.org/1999/xlink'
// width='${viewport.width}'
// height='${viewport.height}'
// viewBox='${viewport.x} ${viewport.y} ${viewport.width} ${viewport.height}'
// version='1.1'
// >
// ${context}
// </svg>
// `
that.$http({
url: '',
method: 'post',
data: that.$http.adornData({
modelId: that.modelId,
name: name,
bpmnXml: xml,
svg: '',
descritpion: ''
})
}).then(({ data }) => {
that.$message({
message: that.$i18n.t('publics.operation'),
type: 'success',
duration: 1500,
onClose: () => {}
})
})
}
})
},
// 获取流程图数据
getModel () {
const that = this
this.$http({
url: '',
method: 'get',
params: this.$http.adornParams({
modelId: that.modelId
})
}).then(({ data }) => {
that.bpmnTemplate = '`' + data + '`'
this.$message({
message: this.$i18n.t('publics.operation'),
type: 'success',
duration: 1500,
onClose: () => {
that.init()
}
})
})
},
download ({ name = 'diagram.bpmn', data }) {
// 这里就获取到了之前设置的隐藏链接
const downloadLink = this.$refs.downloadLink
// 把数据转换为URI,下载要用到的
const encodedData = encodeURIComponent(data)
if (data) {
// 将数据给到链接
downloadLink.href =
'data:application/bpmn20-xml;charset=UTF-8,' + encodedData
// 设置文件名
downloadLink.download = name
// 触发点击事件开始下载
downloadLink.click()
}
},
async init () {
// 获取画布 element
const that = this
that.canvas = that.$refs.canvas
// 将汉化包装成一个模块
const customTranslateModule = {
translate: ['value', customTranslate]
}
// 创建Bpmn对象
that.bpmnModeler = new BpmnModeler({
// 设置bpmn的绘图容器为上门获取的画布 element
container: that.canvas,
// 加入工具栏支持
propertiesPanel: {
parent: '#js-properties-panel'
},
additionalModules: [
// 工具栏模块
propertiesProviderModule,
propertiesPanelModule,
// 汉化模块
customTranslateModule
],
moddleExtensions: {
activiti: activitiModdleDescriptor
}
})
await that.createNewDiagram(that.bpmnTemplate)
},
clearBpmn () {
this.bpmnModeler.clear()
},
async createNewDiagram (bpmnTemplate) {
const that = this
// 将字符串转换成图显示出来;
this.bpmnModeler.importXML(bpmnTemplate, err => {
if (err) {
that.$Message.error('打开模型出错,请确认该模型符合Bpmn2.0规范')
} else {
// 让图能自适应屏幕
var canvas = that.bpmnModeler.get('canvas')
canvas.zoom('fit-viewport')
}
})
}
},
created () {
this.getModel()
// // 删除 bpmn logo bpmn.io官方要求不给删或者隐藏,否则侵权 内部使用
// const bjsIoLogo = document.querySelector('.bjs-powered-by')
// while (bjsIoLogo.firstChild) {
// bjsIoLogo.removeChild(bjsIoLogo.firstChild)
// }
},
beforeDestroy () {
this.clearBpmn()
}
}
</script>
编写styly
样式
<style>
.bpmn-container {
width: 100%;
height: 100vh;
display: flex;
}
.bpmn-canvas {
width: calc(100% - 300px);
height: 100vh;
}
.bpmn-js-properties-panel {
width: 320px;
height: inherit;
overflow-y: auto;
}
.action {
position: fixed;
bottom: 40px;
left: 800px;
display: flex;
}
</style>
在这里需要注意我的代码中,在
import
导入camunda
时我注释了,是因为Bpmn.js默认支持的是camunda
,而我的后端使用的是activiti
,两者是不兼容的。所以需要丢弃camunda
,换成activiti
下载activiti
插件
"houtaroy-bpmn-js-properties-panel-activiti": "0.0.1",
更换对应引入的camunda
汉化包:customTranslate.js
+translationsGerman
import translations from './translationsGerman'
export default function customTranslate (template, replacements) {
replacements = replacements || {}
// Translate
template = translations[template] || template
// Replace
return template.replace(/{([^}]+)}/g, function (_, key) {
var str = replacements[key]
if (
translations[replacements[key]] !== null &&
translations[replacements[key]] !== 'undefined'
) {
str = translations[replacements[key]]
}
return str || '{' + key + '}'
})
}
export default {
// Labels
'Activate the global connect tool': '激活全局连接工具',
'Append {type}': '追加 {type}',
'Append EndEvent': '追加 结束事件 ',
'Append Task': '追加 任务',
'Append Gateway': '追加 网关',
'Append Intermediate/Boundary Event': '追加 中间/边界 事件',
'Add Lane above': '在上面添加道',
'Divide into two Lanes': '分割成两个道',
'Divide into three Lanes': '分割成三个道',
'Add Lane below': '在下面添加道',
'Append compensation activity': '追加补偿活动',
'Change type': '修改类型',
'Connect using Association': '使用关联连接',
'Connect using Sequence/MessageFlow or Association': '使用顺序/消息流或者关联连接',
'Connect using DataInputAssociation': '使用数据输入关联连接',
'Remove': '移除',
'Activate the hand tool': '激活抓手工具',
'Activate the lasso tool': '激活套索工具',
'Activate the create/remove space tool': '激活创建/删除空间工具',
'Create expanded SubProcess': '创建扩展子过程',
'Create IntermediateThrowEvent/BoundaryEvent': '创建中间抛出事件/边界事件',
'Create Pool/Participant': '创建池/参与者',
'Parallel Multi Instance': '并行多重事件',
'Sequential Multi Instance': '时序多重事件',
'DataObjectReference': '数据对象参考',
'DataStoreReference': '数据存储参考',
'Loop': '循环',
} // 这里只是部分的汉化,多的就不写出来了,如果有需要的可以去网上找找有很多
然后还有activiti.json
这个是更换activiti
必不可少的,可以看看元示例
{
"name": "Activiti",
"uri": "http://activiti.org/bpmn",
"prefix": "activiti",
"xml": {
"tagAlias": "lowerCase"
},
"associations": [],
"types": [
{
"name": "Process",
"isAbstract": true,
"extends": [
"bpmn:Process"
],
"properties": [
{
"name": "diagramRelationId",
"isAttr": true,
"type": "String"
}
]
},
{
"name": "InOutBinding",
"superClass": [
"Element"
], // 就是将camunda用activiti替换掉,还有挺多的无法全部展示
引入完成后就可以看看流程编辑器的样子。
到这里一个完整的bpmn.js
就引入完成了,下面再讲讲bpmn.js的事件以及监听器吧
三,bpmn.js事件
这里主要是说明关于bpmn.js
的一些事件, 通过此章节你可以了解到:
- 监听
modeler
并绑定事件 - 监听
element
并绑定事件 - 通过监听事件判断操作方式
1,监听modeler并绑定事件
有些时候我们期望的是在用户在进行不同操作的时候能够监听到他操作的是什么, 从而做想要做的事情.
是进行了shape
的新增还是进行了线的新增.
比如如下的一些监听事件:
shape.added
新增一个shape
之后触发;shape.move.end
移动完一个shape
之后触发;shape.removed
删除一个shape
之后触发;
继续在项目案例bpmn.vue
的基础上创建一个event.vue
文件:
// event.vue
<script>
...
success () {
this.addModelerListener()
},
// 监听 modeler
addModelerListener() {
const bpmnjs = this.bpmnModeler
const that = this
// 这里我是用了一个forEach给modeler上添加要绑定的事件
const events = ['shape.added', 'shape.move.end', 'shape.removed', 'connect.end', 'connect.move']
events.forEach(function(event) {
that.bpmnModeler.on(event, e => {
console.log(event, e)
var elementRegistry = bpmnjs.get('elementRegistry')
var shape = e.element ? elementRegistry.get(e.element.id) : e.shape
console.log(shape)
})
})
},
然后就可以获取到相关节点的信息
其实具体有哪些事件我在官网上都没有找到说明, 以上只是我在查找到bpmn.io/diagram.js/…文件之后, 取的一些我项目里有用到的事件.
2,监听element并绑定事件
上面介绍的是监听modeler并绑定事件, 可能你也需要监听用户点击图形上的element或者监听某个element改变:
- element.click 点击元素;
- element.changed 当元素发生改变的时候(包括新增、移动、删除元素)
继续在success()
上添加监听事件:
// event.vue
<script>
...
success () {
...
this.addEventBusListener()
},
addEventBusListener () {
let that = this
const eventBus = this.bpmnModeler.get('eventBus') // 需要使用eventBus
const eventTypes = ['element.click', 'element.changed'] // 需要监听的事件集合
eventTypes.forEach(function(eventType) {
eventBus.on(eventType, function(e) {
console.log(e)
})
})
}
</script>
配置好addEventBusListener()
函数后, 在进行元素的点击、新增、移动、删除的时候都能监听到了.
但是有一点很不好, 你在点击“画布”的时候, 也就是根元素也可能会触发此事件, 我们一般都不希望此时会触发, 因此我们可以在on
回调中添加一些判断, 来避免掉不需要的情况:
eventBus.on(eventType, function(e) {
if (!e || e.element.type == 'bpmn:Process') return // 这里我的根元素是bpmn:Process
console.log(e)
})
此时我们可以把监听到返回的节点信息打印出来看看:
如上图, 它会打印出该节点的Shape
信息和DOM
信息等, 但我们可能只关注于Shape
信息(也就是该节点的id
、type
等等信息), 此时我们可以使用elementRegistry
来获取Shape
信息:
eventBus.on(eventType, function(e) {
if (!e || e.element.type == 'bpmn:Process') return // 这里我的根元素是bpmn:Process
console.log(e)
var elementRegistry = this.bpmnModeler.get('elementRegistry')
var shape = elementRegistry.get(e.element.id) // 传递id进去
console.log(shape) // {Shape}
console.log(e.element) // {Shape}
console.log(JSON.stringify(shape)===JSON.stringify(e.element)) // true
})
或者你也可以直接就用e.element
获取到Shape
的信息, 我比较了一下它们两是一样的. 但是官方是推荐使用elementRegistry
的方式.
3.通过监听事件判断操作方式
上面我们已经介绍了modeler
和element
的监听绑定方式, 在事件应用中, 你更多的需要知道用户要进行什么操作, 好写对应的业务逻辑.
这里就以工作中要用到的场景为案例进行讲解.
- 新增了shape
- 新增了线(connection)
- 删除了shape和connection
- 移动了shape和线
// event.vue
...
success () {
this.addModelerListener()
this.addEventBusListener()
},
// 添加绑定事件
addBpmnListener () {
const that = this
// 获取a标签dom节点
const downloadLink = this.$refs.saveDiagram
const downloadSvgLink = this.$refs.saveSvg
// 给图绑定事件,当图有发生改变就会触发这个事件
this.bpmnModeler.on('commandStack.changed', function () {
that.saveSVG(function(err, svg) {
that.setEncoded(downloadSvgLink, 'diagram.svg', err ? null : svg)
})
that.saveDiagram(function(err, xml) {
that.setEncoded(downloadLink, 'diagram.bpmn', err ? null : xml)
})
})
},
addModelerListener() {
// 监听 modeler
const bpmnjs = this.bpmnModeler
const that = this
// 'shape.removed', 'connect.end', 'connect.move'
const events = ['shape.added', 'shape.move.end', 'shape.removed']
events.forEach(function(event) {
that.bpmnModeler.on(event, e => {
var elementRegistry = bpmnjs.get('elementRegistry')
var shape = e.element ? elementRegistry.get(e.element.id) : e.shape
// console.log(shape)
if (event === 'shape.added') {
console.log('新增了shape')
} else if (event === 'shape.move.end') {
console.log('移动了shape')
} else if (event === 'shape.removed') {
console.log('删除了shape')
}
})
})
},
addEventBusListener() {
// 监听 element
let that = this
const eventBus = this.bpmnModeler.get('eventBus')
const eventTypes = ['element.click', 'element.changed']
eventTypes.forEach(function(eventType) {
eventBus.on(eventType, function(e) {
if (!e || e.element.type == 'bpmn:Process') return
if (eventType === 'element.changed') {
that.elementChanged(eventType, e)
} else if (eventType === 'element.click') {
console.log('点击了element')
}
})
})
},
elementChanged(eventType, e) {
var shape = this.getShape(e.element.id)
if (!shape) {
// 若是shape为null则表示删除, 无论是shape还是connect删除都调用此处
console.log('无效的shape')
// 由于上面已经用 shape.removed 检测了shape的删除, 因此这里只判断是否是线
if (this.isSequenceFlow(shape.type)) {
console.log('删除了线')
}
}
if (!this.isInvalid(shape.type)) {
if (this.isSequenceFlow(shape.type)) {
console.log('改变了线')
}
}
},
getShape(id) {
var elementRegistry = this.bpmnModeler.get('elementRegistry')
return elementRegistry.get(id)
},
isInvalid (param) { // 判断是否是无效的值
return param === null || param === undefined || param === ''
},
isSequenceFlow (type) { // 判断是否是线
return type === 'bpmn:SequenceFlow'
}
更多关于bpmn.js的学习,可以看看这个大佬写的:
系列相关推荐:
《全网最详bpmn.js教材-基础篇》
《全网最详bpmn.js教材-http请求篇》
《全网最详bpmn.js教材-renderer篇》
《全网最详bpmn.js教材-contextPad篇》
《全网最详bpmn.js教材-编辑、删除节点篇》