前言

Q: bpmn.js是什么? 🤔️

bpmn.js是一个BPMN2.0渲染工具包和web建模器, 使得画流程图的功能在前端来完成.

Q: 我为什么要写该系列的教材? 🤔️

因为公司业务的需要因而要在项目中使用到bpmn.js,但是由于bpmn.js的开发者是国外友人, 因此国内对这方面的教材很少, 也没有详细的文档. 所以很多使用方式很多坑都得自己去找.在将其琢磨完之后, 决定写一系列关于它的教材来帮助更多bpmn.js的使用者或者是期于找到一种好的绘制流程图的开发者. 同时也是自己对其的一种巩固.

由于是系列的文章, 所以更新的可能会比较频繁, 您要是无意间刷到了且不是您所需要的还请谅解😊.

不求赞👍不求心❤️. 只希望能对你有一点小小的帮助.

封装组件篇

在进入这一章节的学习之前, 我希望你能先掌握前面几节的知识点: 自定义palette、自定义renderer、自定义contextPad、编辑删除节点.

因为这一章节会将前面几节的内容做一个汇总, 然后提供一个可用的bpmn组件解决方案.

通过阅读你可以学习到:

创建线节点

首先让我们先来了解一下线节点是如何创建的.

我以CustomPalette.js为例子🌰, 还记得在之前讲的createTask吗, 创建线和它差不多:

  1. // CustomPalette.js
  2. PaletteProvider.$inject = [
  3. ...
  4. 'globalConnect'
  5. ]
  6. PaletteProvider.prototype.getPaletteEntries = function(element) {
  7. const { globalConnect } = this
  8. function createConnect () {
  9. return {
  10. group: 'tools',
  11. className: 'icon-custom icon-custom-flow',
  12. title: '新增线',
  13. action: {
  14. click: function (event) {
  15. globalConnect.toggle(event)
  16. }
  17. }
  18. }
  19. }
  20. return {
  21. 'create.lindaidai-task': {...},
  22. 'global-connect-tool': createConnect()
  23. }
  24. }

这样就可以画出线了:

bpmnModeler.png

自定义modeler

经过了上面那么的例子, 其实我们不难发现, 在每个关键的函数中, 都是将自己想要自定义的东西通过函数返回值传递出去.

而且返回值的内容都大同小异, 无非就是group、className等等东西, 那么这样的话, 我们是不是可以将其整合一下, 减少许多代码量呢?

我们可以构建这样一个函数:

  1. // CustomPalette.js
  2. function createAction (type, group, className, title, options) {
  3. function createListener (event) {
  4. var shape = elementFactory.createShape(assign({ type }, options))
  5. create.start(event, shape)
  6. }
  7. return {
  8. group,
  9. className,
  10. title: '新增' + title,
  11. action: {
  12. dragstart: createListener,
  13. click: createListener
  14. }
  15. }
  16. }

它接收所有元素不同的属性, 然后返回一个自定义元素.

但是线的创建可能有些不同:

  1. // CustomPalette.js
  2. function createConnect (type, group, className, title, options) {
  3. return {
  4. group,
  5. className,
  6. title: '新增' + title,
  7. action: {
  8. click: function (event) {
  9. globalConnect.toggle(event)
  10. }
  11. }
  12. }
  13. }

因此我这里把创建元素的函数分为两类: createActioncreateConnect.

接下来我们只需要构建一个这样的数组:

  1. // utils/util.js
  2. const flowAction = { // 线
  3. type: 'global-connect-tool',
  4. action: ['bpmn:SequenceFlow', 'tools', 'icon-custom icon-custom-flow', '连接线']
  5. }
  6. const customShapeAction = [ // shape
  7. {
  8. type: 'create.start-event',
  9. action: ['bpmn:StartEvent', 'event', 'icon-custom icon-custom-start', '开始节点']
  10. },
  11. {
  12. type: 'create.end-event',
  13. action: ['bpmn:EndEvent', 'event', 'icon-custom icon-custom-end', '结束节点']
  14. },
  15. {
  16. type: 'create.task',
  17. action: ['bpmn:Task', 'activity', 'icon-custom icon-custom-task', '普通任务']
  18. },
  19. {
  20. type: 'create.businessRule-task',
  21. action: ['bpmn:BusinessRuleTask', 'activity', 'icon-custom icon-custom-businessRule', 'businessRule任务']
  22. },
  23. {
  24. type: 'create.exclusive-gateway',
  25. action: ['bpmn:ExclusiveGateway', 'activity', 'icon-custom icon-custom-exclusive-gateway', '网关']
  26. },
  27. {
  28. type: 'create.dataObjectReference',
  29. action: ['bpmn:DataObjectReference', 'activity', 'icon-custom icon-custom-data', '变量']
  30. }
  31. ]
  32. const customFlowAction = [
  33. flowAction
  34. ]
  35. export { customShapeAction, customFlowAction }

同时构建一个方法来循环创建出上面👆的元素:

  1. // utils/util.js
  2. /**
  3. * 循环创建出一系列的元素
  4. * @param {Array} actions 元素集合
  5. * @param {Object} fn 处理的函数
  6. */
  7. export function batchCreateCustom(actions, fn) {
  8. const customs = {}
  9. actions.forEach(item => {
  10. customs[item['type']] = fn(...item['action'])
  11. })
  12. return customs
  13. }

编写CustomPalette.js代码

之后就可以在CustomPalette.js中来引用它们了:

  1. // CustomPalette.js
  2. import { customShapeAction, customFlowAction, batchCreateCustom } from './../../utils/util'
  3. PaletteProvider.prototype.getPaletteEntries = function(element) {
  4. var actions = {}
  5. const {
  6. create,
  7. elementFactory,
  8. globalConnect
  9. } = this;
  10. function createConnect(type, group, className, title, options) {
  11. return {
  12. group,
  13. className,
  14. title: '新增' + title,
  15. action: {
  16. click: function(event) {
  17. globalConnect.toggle(event)
  18. }
  19. }
  20. }
  21. }
  22. function createAction(type, group, className, title, options) {
  23. function createListener(event) {
  24. var shape = elementFactory.createShape(Object.assign({ type }, options))
  25. create.start(event, shape)
  26. }
  27. return {
  28. group,
  29. className,
  30. title: '新增' + title,
  31. action: {
  32. dragstart: createListener,
  33. click: createListener
  34. }
  35. }
  36. }
  37. Object.assign(actions, {
  38. ...batchCreateCustom(customFlowAction, createConnect), // 线
  39. ...batchCreateCustom(customShapeAction, createAction)
  40. })
  41. return actions
  42. }

这样看来代码是不是精简很多了呢😊.

让我们来看看页面的效果:

bpmnModeler2.png

此时左侧的工具栏就已经全部被替换成我们想要的图片了.

编写CustomRenderer.js代码

然后就到了编写renderer代码的时候了, 在编写之前, 同样的, 我们可以做一些配置项.

因为我们注意到在渲染自定义元素的的时候, 靠的就是svgCreate('image', {})这个方法.

它里面也是接收的一个图片的地址url和样式配置attr.

那么url的前缀我们就可以提取出来:

  1. // utils/util.js
  2. const STATICPATH = 'https://hexo-blog-1256114407.cos.ap-shenzhen-fsi.myqcloud.com/' // 静态文件路径
  3. const customConfig = { // 自定义元素的配置
  4. 'bpmn:StartEvent': {
  5. 'field': 'start',
  6. 'title': '开始节点',
  7. 'attr': { x: 0, y: 0, width: 40, height: 40 }
  8. },
  9. 'bpmn:EndEvent': {
  10. 'field': 'end',
  11. 'title': '结束节点',
  12. 'attr': { x: 0, y: 0, width: 40, height: 40 }
  13. },
  14. 'bpmn:SequenceFlow': {
  15. 'field': 'flow',
  16. 'title': '连接线',
  17. },
  18. 'bpmn:Task': {
  19. 'field': 'rules',
  20. 'title': '普通任务',
  21. 'attr': { x: 0, y: 0, width: 48, height: 48 }
  22. },
  23. 'bpmn:BusinessRuleTask': {
  24. 'field': 'variable',
  25. 'title': 'businessRule任务',
  26. 'attr': { x: 0, y: 0, width: 48, height: 48 }
  27. },
  28. 'bpmn:ExclusiveGateway': {
  29. 'field': 'decision',
  30. 'title': '网关',
  31. 'attr': { x: 0, y: 0, width: 48, height: 48 }
  32. },
  33. 'bpmn:DataObjectReference': {
  34. 'field': 'score',
  35. 'title': '变量',
  36. 'attr': { x: 0, y: 0, width: 48, height: 48 }
  37. }
  38. }
  39. const hasLabelElements = ['bpmn:StartEvent', 'bpmn:EndEvent', 'bpmn:ExclusiveGateway', 'bpmn:DataObjectReference'] // 一开始就有label标签的元素类型
  40. export { STATICPATH, customConfig, hasLabelElements }

然后只需要在编写drawShape方法的时候判断一下就可以了:

  1. // CustomRenderer.js
  2. import inherits from 'inherits'
  3. import BaseRenderer from 'diagram-js/lib/draw/BaseRenderer'
  4. import {
  5. append as svgAppend,
  6. create as svgCreate
  7. } from 'tiny-svg'
  8. import { customElements, customConfig, STATICPATH, hasLabelElements } from '../../utils/util'
  9. /**
  10. * A renderer that knows how to render custom elements.
  11. */
  12. export default function CustomRenderer(eventBus, styles, bpmnRenderer) {
  13. BaseRenderer.call(this, eventBus, 2000)
  14. var computeStyle = styles.computeStyle
  15. this.drawElements = function(parentNode, element) {
  16. console.log(element)
  17. const type = element.type // 获取到类型
  18. if (type !== 'label') {
  19. if (customElements.includes(type)) { // or customConfig[type]
  20. return drawCustomElements(parentNode, element)
  21. }
  22. const shape = bpmnRenderer.drawShape(parentNode, element)
  23. return shape
  24. } else {
  25. element
  26. }
  27. }
  28. }
  29. inherits(CustomRenderer, BaseRenderer)
  30. CustomRenderer.$inject = ['eventBus', 'styles', 'bpmnRenderer']
  31. CustomRenderer.prototype.canRender = function(element) {
  32. // ignore labels
  33. return true
  34. // return !element.labelTarget;
  35. }
  36. CustomRenderer.prototype.drawShape = function(parentNode, element) {
  37. return this.drawElements(parentNode, element)
  38. }
  39. CustomRenderer.prototype.getShapePath = function(shape) {
  40. // console.log(shape)
  41. }
  42. function drawCustomElements(parentNode, element) {
  43. const { type } = element
  44. const { field, attr } = customConfig[type]
  45. const url = `${STATICPATH}${field}.png`
  46. const customIcon = svgCreate('image', {
  47. ...attr,
  48. href: url
  49. })
  50. element['width'] = attr.width // 这里我是取了巧, 直接修改了元素的宽高
  51. element['height'] = attr.height
  52. svgAppend(parentNode, customIcon)
  53. // 判断是否有name属性来决定是否要渲染出label
  54. if (!hasLabelElements.includes(type) && element.businessObject.name) {
  55. const text = svgCreate('text', {
  56. x: attr.x,
  57. y: attr.y + attr.height + 20,
  58. "font-size": "14",
  59. "fill": "#000"
  60. })
  61. text.innerHTML = element.businessObject.name
  62. svgAppend(parentNode, text)
  63. }
  64. return customIcon
  65. }

关键在于drawCustomElements函数中, 利用了url的一个字符串拼接.

这样的话, 自定义元素就可以都渲染出来了.

效果如下:

bpmnModeler3.png

编写CustomContextProvider.js代码

完成了paletterenderer的编写, 接下来让我们看看contextPad是怎么编写的.

其实它的写法和palette差不多, 只不过有一点需要我们注意的:

不同类型的节点出现的contextPad的内容可能是不同的.

比如:

  • StartEvent会出现edit、delete、Task、BusinessRuleTask、ExclusiveGateway等等;
  • EndEvent只能出现edit、delete;
  • SequenceFlow只能出现edit、delete.

也就是说我们需要根据节点类型来返回不同的contextPad.

那么在编写getContextPadEntries函数返回值的时候, 就可以根据element.type来返回不同的结果:

  1. import { isAny } from 'bpmn-js/lib/features/modeling/util/ModelingUtil'
  2. ContextPadProvider.prototype.getContextPadEntries = function(element) {
  3. ... // 此处省略的代码可查看项目github源码
  4. // 只有点击列表中的元素才会产生的元素
  5. if (isAny(businessObject, ['bpmn:StartEvent', 'bpmn:Task', 'bpmn:BusinessRuleTask', 'bpmn:ExclusiveGateway', 'bpmn:DataObjectReference'])) {
  6. Object.assign(actions, {
  7. ...batchCreateCustom(customShapeAction, createAction),
  8. ...batchCreateCustom(customFlowAction, createConnect), // 连接线
  9. 'edit': editElement(),
  10. 'delete': deleteElement()
  11. })
  12. }
  13. // 结束节点和线只有删除和编辑
  14. if (isAny(businessObject, ['bpmn:EndEvent', 'bpmn:SequenceFlow', 'bpmn:DataOutputAssociation'])) {
  15. Object.assign(actions, {
  16. 'edit': editElement(),
  17. 'delete': deleteElement()
  18. })
  19. }
  20. return actions
  21. }

isAny的作用其实就是判断类型属不属于后面数组中, 类似于includes.

这样我们的contextPad就丰富起来了😊.

bomnModeler4.png

将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文件地址为例进行封装. 当然你们可以根据自己的业务需求来定.

也就是在引用这个组件的时候, 我期望的是这样写:

  1. /* views/custom-modeler.vue */
  2. <template>
  3. <bpmn :xmlUrl="xmlUrl" @change="changeBpmn"></bpmn>
  4. </template>
  5. <script>
  6. import { Bpmn } from './../components/bpmn'
  7. export default {
  8. components: {
  9. Bpmn
  10. },
  11. data () {
  12. return {
  13. xmlUrl: 'https://hexo-blog-1256114407.cos.ap-shenzhen-fsi.myqcloud.com/bpmnMock.bpmn'
  14. }
  15. },
  16. methods: {
  17. changeBpmn ($event) {}
  18. }
  19. }
  20. </script>

只要引用了bpmn组件, 然后传递一个url, 页面上就可以显示出对应的图形内容.

这样的话, 我们的Bpmn.vue中就应该这样定义props:

  1. // Bpmn.vue
  2. props: {
  3. xmlUrl: {
  4. type: String,
  5. default: ''
  6. }
  7. }

编写组件的hmtl代码

组件中的html代码十分容易, 主要是给画布一个盛放的容器, 再定义了两个按钮用于下载:

  1. <!-- Bpmn.vue -->
  2. <template>
  3. <div class="containers">
  4. <div class="canvas" ref="canvas"></div>
  5. <div id="js-properties-panel" class="panel"></div>
  6. <ul class="buttons">
  7. <li>
  8. <a ref="saveDiagram" href="javascript:" title="保存为bpmn">保存为bpmn</a>
  9. </li>
  10. <li>
  11. <a ref="saveSvg" href="javascript:" title="保存为svg">保存为svg</a>
  12. </li>
  13. </ul>
  14. </div>
  15. </template>

编写组件的js代码

js里, 我就将前面几节《全网最详bpmn.js教材-http请求篇》《全网最详bpmn.js教材-http事件篇》 中的功能都整合了进来.

大体就是:

  • 初始化的时候, 对输入进来的xmlUrl做判断, 若是不为空的话则请求获取数据,否则赋值一个默认值;
  • 初始化成功之后, 在成功的函数中添加modelerelement的监听事件;
  • 初始化下载xml、svg的链接按钮.

例如:

  1. // Bpmn.vue
  2. async createNewDiagram () {
  3. const that = this
  4. let bpmnXmlStr = ''
  5. if (this.xmlUrl === '') { // 判断是否存在
  6. bpmnXmlStr = this.defaultXmlStr
  7. this.transformCanvas(bpmnXmlStr)
  8. } else {
  9. let res = await axios({
  10. method: 'get',
  11. timeout: 120000,
  12. url: that.xmlUrl,
  13. headers: { 'Content-Type': 'multipart/form-data' }
  14. })
  15. console.log(res)
  16. bpmnXmlStr = res['data']
  17. this.transformCanvas(bpmnXmlStr)
  18. }
  19. },
  20. transformCanvas(bpmnXmlStr) {
  21. // 将字符串转换成图显示出来
  22. this.bpmnModeler.importXML(bpmnXmlStr, (err) => {
  23. if (err) {
  24. console.error(err)
  25. } else {
  26. this.success()
  27. }
  28. // 让图能自适应屏幕
  29. var canvas = this.bpmnModeler.get('canvas')
  30. canvas.zoom('fit-viewport')
  31. })
  32. },
  33. success () {
  34. this.addBpmnListener()
  35. this.addModelerListener()
  36. this.addEventBusListener()
  37. },
  38. addBpmnListener () {},
  39. addModelerListener () {},
  40. addEventBusListener () {}

整合之后的代码有些多, 这里贴出来有点不太好, 详细代码在gitHub上有: LinDaiDai/bpmn-custom-modeler/Bpmn.vue

后语

项目案例Git地址: LinDaiDai/bpmn-vue-custom 喜欢的小伙伴请给个Star🌟呀, 谢谢😊

系列全部目录请查看此处: 《全网最详bpmn.js教材目录》

最后, 如果你也对bpmn.js 感兴趣可以进我们的bpmn.js交流群👇👇👇, 共同学习, 共同进步.

关注霖呆呆(LinDaiDai)的公众号, 选择 其它 菜单中的 bpmn.js群 即可😊.

LinDaiDai公众号二维码.jpg