前言
Q: bpmn.js是什么? 🤔️
bpmn.js是一个BPMN2.0渲染工具包和web建模器, 使得画流程图的功能在前端来完成.
Q: 我为什么要写该系列的教材? 🤔️
因为公司业务的需要因而要在项目中使用到bpmn.js,但是由于bpmn.js的开发者是国外友人, 因此国内对这方面的教材很少, 也没有详细的文档. 所以很多使用方式很多坑都得自己去找.在将其琢磨完之后, 决定写一系列关于它的教材来帮助更多bpmn.js的使用者或者是期于找到一种好的绘制流程图的开发者. 同时也是自己对其的一种巩固.
由于是系列的文章, 所以更新的可能会比较频繁, 您要是无意间刷到了且不是您所需要的还请谅解😊.
不求赞👍不求心❤️. 只希望能对你有一点小小的帮助.
封装组件篇
在进入这一章节的学习之前, 我希望你能先掌握前面几节的知识点: 自定义palette、自定义renderer、自定义contextPad、编辑删除节点.
因为这一章节会将前面几节的内容做一个汇总, 然后提供一个可用的bpmn组件解决方案.
通过阅读你可以学习到:
创建线节点
首先让我们先来了解一下线节点是如何创建的.
我以CustomPalette.js为例子🌰, 还记得在之前讲的createTask吗, 创建线和它差不多:
// CustomPalette.jsPaletteProvider.$inject = [...'globalConnect']PaletteProvider.prototype.getPaletteEntries = function(element) {const { globalConnect } = thisfunction createConnect () {return {group: 'tools',className: 'icon-custom icon-custom-flow',title: '新增线',action: {click: function (event) {globalConnect.toggle(event)}}}}return {'create.lindaidai-task': {...},'global-connect-tool': createConnect()}}
这样就可以画出线了:
自定义modeler
经过了上面那么的例子, 其实我们不难发现, 在每个关键的函数中, 都是将自己想要自定义的东西通过函数返回值传递出去.
而且返回值的内容都大同小异, 无非就是group、className等等东西, 那么这样的话, 我们是不是可以将其整合一下, 减少许多代码量呢?
我们可以构建这样一个函数:
// CustomPalette.jsfunction createAction (type, group, className, title, options) {function createListener (event) {var shape = elementFactory.createShape(assign({ type }, options))create.start(event, shape)}return {group,className,title: '新增' + title,action: {dragstart: createListener,click: createListener}}}
它接收所有元素不同的属性, 然后返回一个自定义元素.
但是线的创建可能有些不同:
// CustomPalette.jsfunction createConnect (type, group, className, title, options) {return {group,className,title: '新增' + title,action: {click: function (event) {globalConnect.toggle(event)}}}}
因此我这里把创建元素的函数分为两类: createAction和createConnect.
接下来我们只需要构建一个这样的数组:
// utils/util.jsconst flowAction = { // 线type: 'global-connect-tool',action: ['bpmn:SequenceFlow', 'tools', 'icon-custom icon-custom-flow', '连接线']}const customShapeAction = [ // shape{type: 'create.start-event',action: ['bpmn:StartEvent', 'event', 'icon-custom icon-custom-start', '开始节点']},{type: 'create.end-event',action: ['bpmn:EndEvent', 'event', 'icon-custom icon-custom-end', '结束节点']},{type: 'create.task',action: ['bpmn:Task', 'activity', 'icon-custom icon-custom-task', '普通任务']},{type: 'create.businessRule-task',action: ['bpmn:BusinessRuleTask', 'activity', 'icon-custom icon-custom-businessRule', 'businessRule任务']},{type: 'create.exclusive-gateway',action: ['bpmn:ExclusiveGateway', 'activity', 'icon-custom icon-custom-exclusive-gateway', '网关']},{type: 'create.dataObjectReference',action: ['bpmn:DataObjectReference', 'activity', 'icon-custom icon-custom-data', '变量']}]const customFlowAction = [flowAction]export { customShapeAction, customFlowAction }
同时构建一个方法来循环创建出上面👆的元素:
// utils/util.js/*** 循环创建出一系列的元素* @param {Array} actions 元素集合* @param {Object} fn 处理的函数*/export function batchCreateCustom(actions, fn) {const customs = {}actions.forEach(item => {customs[item['type']] = fn(...item['action'])})return customs}
编写CustomPalette.js代码
之后就可以在CustomPalette.js中来引用它们了:
// CustomPalette.jsimport { customShapeAction, customFlowAction, batchCreateCustom } from './../../utils/util'PaletteProvider.prototype.getPaletteEntries = function(element) {var actions = {}const {create,elementFactory,globalConnect} = this;function createConnect(type, group, className, title, options) {return {group,className,title: '新增' + title,action: {click: function(event) {globalConnect.toggle(event)}}}}function createAction(type, group, className, title, options) {function createListener(event) {var shape = elementFactory.createShape(Object.assign({ type }, options))create.start(event, shape)}return {group,className,title: '新增' + title,action: {dragstart: createListener,click: createListener}}}Object.assign(actions, {...batchCreateCustom(customFlowAction, createConnect), // 线...batchCreateCustom(customShapeAction, createAction)})return actions}
这样看来代码是不是精简很多了呢😊.
让我们来看看页面的效果:
此时左侧的工具栏就已经全部被替换成我们想要的图片了.
编写CustomRenderer.js代码
然后就到了编写renderer代码的时候了, 在编写之前, 同样的, 我们可以做一些配置项.
因为我们注意到在渲染自定义元素的的时候, 靠的就是svgCreate('image', {})这个方法.
它里面也是接收的一个图片的地址url和样式配置attr.
那么url的前缀我们就可以提取出来:
// utils/util.jsconst STATICPATH = 'https://hexo-blog-1256114407.cos.ap-shenzhen-fsi.myqcloud.com/' // 静态文件路径const customConfig = { // 自定义元素的配置'bpmn:StartEvent': {'field': 'start','title': '开始节点','attr': { x: 0, y: 0, width: 40, height: 40 }},'bpmn:EndEvent': {'field': 'end','title': '结束节点','attr': { x: 0, y: 0, width: 40, height: 40 }},'bpmn:SequenceFlow': {'field': 'flow','title': '连接线',},'bpmn:Task': {'field': 'rules','title': '普通任务','attr': { x: 0, y: 0, width: 48, height: 48 }},'bpmn:BusinessRuleTask': {'field': 'variable','title': 'businessRule任务','attr': { x: 0, y: 0, width: 48, height: 48 }},'bpmn:ExclusiveGateway': {'field': 'decision','title': '网关','attr': { x: 0, y: 0, width: 48, height: 48 }},'bpmn:DataObjectReference': {'field': 'score','title': '变量','attr': { x: 0, y: 0, width: 48, height: 48 }}}const hasLabelElements = ['bpmn:StartEvent', 'bpmn:EndEvent', 'bpmn:ExclusiveGateway', 'bpmn:DataObjectReference'] // 一开始就有label标签的元素类型export { STATICPATH, customConfig, hasLabelElements }
然后只需要在编写drawShape方法的时候判断一下就可以了:
// CustomRenderer.jsimport inherits from 'inherits'import BaseRenderer from 'diagram-js/lib/draw/BaseRenderer'import {append as svgAppend,create as svgCreate} from 'tiny-svg'import { customElements, customConfig, STATICPATH, hasLabelElements } from '../../utils/util'/*** A renderer that knows how to render custom elements.*/export default function CustomRenderer(eventBus, styles, bpmnRenderer) {BaseRenderer.call(this, eventBus, 2000)var computeStyle = styles.computeStylethis.drawElements = function(parentNode, element) {console.log(element)const type = element.type // 获取到类型if (type !== 'label') {if (customElements.includes(type)) { // or customConfig[type]return drawCustomElements(parentNode, element)}const shape = bpmnRenderer.drawShape(parentNode, element)return shape} else {element}}}inherits(CustomRenderer, BaseRenderer)CustomRenderer.$inject = ['eventBus', 'styles', 'bpmnRenderer']CustomRenderer.prototype.canRender = function(element) {// ignore labelsreturn true// return !element.labelTarget;}CustomRenderer.prototype.drawShape = function(parentNode, element) {return this.drawElements(parentNode, element)}CustomRenderer.prototype.getShapePath = function(shape) {// console.log(shape)}function drawCustomElements(parentNode, element) {const { type } = elementconst { field, attr } = customConfig[type]const url = `${STATICPATH}${field}.png`const customIcon = svgCreate('image', {...attr,href: url})element['width'] = attr.width // 这里我是取了巧, 直接修改了元素的宽高element['height'] = attr.heightsvgAppend(parentNode, customIcon)// 判断是否有name属性来决定是否要渲染出labelif (!hasLabelElements.includes(type) && element.businessObject.name) {const text = svgCreate('text', {x: attr.x,y: attr.y + attr.height + 20,"font-size": "14","fill": "#000"})text.innerHTML = element.businessObject.namesvgAppend(parentNode, text)}return customIcon}
关键在于drawCustomElements函数中, 利用了url的一个字符串拼接.
这样的话, 自定义元素就可以都渲染出来了.
效果如下:
编写CustomContextProvider.js代码
完成了palette和renderer的编写, 接下来让我们看看contextPad是怎么编写的.
其实它的写法和palette差不多, 只不过有一点需要我们注意的:
不同类型的节点出现的contextPad的内容可能是不同的.
比如:
StartEvent会出现edit、delete、Task、BusinessRuleTask、ExclusiveGateway等等;EndEvent只能出现edit、delete;SequenceFlow只能出现edit、delete.
也就是说我们需要根据节点类型来返回不同的contextPad.
那么在编写getContextPadEntries函数返回值的时候, 就可以根据element.type来返回不同的结果:
import { isAny } from 'bpmn-js/lib/features/modeling/util/ModelingUtil'ContextPadProvider.prototype.getContextPadEntries = function(element) {... // 此处省略的代码可查看项目github源码// 只有点击列表中的元素才会产生的元素if (isAny(businessObject, ['bpmn:StartEvent', 'bpmn:Task', 'bpmn:BusinessRuleTask', 'bpmn:ExclusiveGateway', 'bpmn:DataObjectReference'])) {Object.assign(actions, {...batchCreateCustom(customShapeAction, createAction),...batchCreateCustom(customFlowAction, createConnect), // 连接线'edit': editElement(),'delete': deleteElement()})}// 结束节点和线只有删除和编辑if (isAny(businessObject, ['bpmn:EndEvent', 'bpmn:SequenceFlow', 'bpmn:DataOutputAssociation'])) {Object.assign(actions, {'edit': editElement(),'delete': deleteElement()})}return actions}
isAny的作用其实就是判断类型属不属于后面数组中, 类似于includes.
这样我们的contextPad就丰富起来了😊.
将bpmn封装成组件
有了自定义modeler的基础, 我们就可以将bpmn封装成一个组件, 在我们需要应用的地方引用这个组件就可以了.
为了给大家更好演示, 我新建了一个项目 bpmn-custom-modeler , 里面的依赖和配置都和 bpmn-vue-custom中相同, 只不过在这个新的项目里我是打算用自定义的modeler来覆盖它原有的, 并封装一个bpmn组件来供页面使用.
前期准备
在项目的components文件夹下新建一个名为bpmn的文件夹, 这里面用来存放封装的bpmn组件.
然后我们还可以准备一个空的xml作为组件中的默认显示(也就是若是一进来没有任何图形的时候应该显示的是什么内容), 这里我定义了一个newDiagram.js.
再在根目录下创建一个views文件来放一些页面文件, 这里我就再新建一个custom-modeler.vue用来引用封装好的bpmn组件来看效果.
组件的props
首先让我们来思考一下, 既然要把它封装成组件, 那么肯定是需要给这个组件里传递props(可以理解为参数). 它可以是一整个xml字符串, 也可以是一个bpmn文件的地址.
我以传入bpmn文件地址为例进行封装. 当然你们可以根据自己的业务需求来定.
也就是在引用这个组件的时候, 我期望的是这样写:
/* views/custom-modeler.vue */<template><bpmn :xmlUrl="xmlUrl" @change="changeBpmn"></bpmn></template><script>import { Bpmn } from './../components/bpmn'export default {components: {Bpmn},data () {return {xmlUrl: 'https://hexo-blog-1256114407.cos.ap-shenzhen-fsi.myqcloud.com/bpmnMock.bpmn'}},methods: {changeBpmn ($event) {}}}</script>
只要引用了bpmn组件, 然后传递一个url, 页面上就可以显示出对应的图形内容.
这样的话, 我们的Bpmn.vue中就应该这样定义props:
// Bpmn.vueprops: {xmlUrl: {type: String,default: ''}}
编写组件的hmtl代码
组件中的html代码十分容易, 主要是给画布一个盛放的容器, 再定义了两个按钮用于下载:
<!-- Bpmn.vue --><template><div class="containers"><div class="canvas" ref="canvas"></div><div id="js-properties-panel" class="panel"></div><ul class="buttons"><li><a ref="saveDiagram" href="javascript:" title="保存为bpmn">保存为bpmn</a></li><li><a ref="saveSvg" href="javascript:" title="保存为svg">保存为svg</a></li></ul></div></template>
编写组件的js代码
在js里, 我就将前面几节《全网最详bpmn.js教材-http请求篇》 和 《全网最详bpmn.js教材-http事件篇》
中的功能都整合了进来.
大体就是:
- 初始化的时候, 对输入进来的
xmlUrl做判断, 若是不为空的话则请求获取数据,否则赋值一个默认值; - 初始化成功之后, 在成功的函数中添加
modeler、element的监听事件; - 初始化下载
xml、svg的链接按钮.
例如:
// Bpmn.vueasync createNewDiagram () {const that = thislet bpmnXmlStr = ''if (this.xmlUrl === '') { // 判断是否存在bpmnXmlStr = this.defaultXmlStrthis.transformCanvas(bpmnXmlStr)} else {let res = await axios({method: 'get',timeout: 120000,url: that.xmlUrl,headers: { 'Content-Type': 'multipart/form-data' }})console.log(res)bpmnXmlStr = res['data']this.transformCanvas(bpmnXmlStr)}},transformCanvas(bpmnXmlStr) {// 将字符串转换成图显示出来this.bpmnModeler.importXML(bpmnXmlStr, (err) => {if (err) {console.error(err)} else {this.success()}// 让图能自适应屏幕var canvas = this.bpmnModeler.get('canvas')canvas.zoom('fit-viewport')})},success () {this.addBpmnListener()this.addModelerListener()this.addEventBusListener()},addBpmnListener () {},addModelerListener () {},addEventBusListener () {}
整合之后的代码有些多, 这里贴出来有点不太好, 详细代码在gitHub上有: LinDaiDai/bpmn-custom-modeler/Bpmn.vue
后语
项目案例Git地址: LinDaiDai/bpmn-vue-custom 喜欢的小伙伴请给个Star🌟呀, 谢谢😊
系列全部目录请查看此处: 《全网最详bpmn.js教材目录》
最后, 如果你也对bpmn.js 感兴趣可以进我们的bpmn.js交流群👇👇👇, 共同学习, 共同进步.
关注霖呆呆(LinDaiDai)的公众号, 选择 其它 菜单中的 bpmn.js群 即可😊.

