全网最详bpmn.js教材-封装组件篇

前言

Q: bpmn.js是什么? 🤔️

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

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

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

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

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

封装组件篇

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

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

通过阅读你可以学习到:

创建线节点

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

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

// CustomPalette.js
PaletteProvider.$inject = [
    ...
    'globalConnect'
]
PaletteProvider.prototype.getPaletteEntries = function(element) {
    const { globalConnect } = this
    
    function 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()
    }
}

这样就可以画出线了:

bpmnModeler.png

自定义modeler

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

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

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

// CustomPalette.js
function 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.js
function createConnect (type, group, className, title, options) {
   return {
     group,
     className,
     title: '新增' + title,
     action: {
       click: function (event) {
         globalConnect.toggle(event)
       }
     }
   }
 }

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

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

// utils/util.js
const 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.js
import { 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
}

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

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

bpmnModeler2.png

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

编写CustomRenderer.js代码

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

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

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

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

 // utils/util.js
const 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.js
import 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.computeStyle

    this.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 labels
    return 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 } = element
    const { field, attr } = customConfig[type]
    const url = `${STATICPATH}${field}.png`
    const customIcon = svgCreate('image', {
        ...attr,
        href: url
    })
    element['width'] = attr.width // 这里我是取了巧, 直接修改了元素的宽高
    element['height'] = attr.height
    svgAppend(parentNode, customIcon)
        // 判断是否有name属性来决定是否要渲染出label
    if (!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.name
        svgAppend(parentNode, text)
    }
    return customIcon
}

关键在于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来返回不同的结果:

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就丰富起来了😊.

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

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

/* 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.vue
props: {
    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做判断, 若是不为空的话则请求获取数据,否则赋值一个默认值;
  • 初始化成功之后, 在成功的函数中添加modelerelement的监听事件;
  • 初始化下载xml、svg的链接按钮.

例如:

// Bpmn.vue
async createNewDiagram () {
  const that = this
  let bpmnXmlStr = ''
  if (this.xmlUrl === '') { // 判断是否存在
      bpmnXmlStr = this.defaultXmlStr
      this.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教材-http请求篇》

《全网最详bpmn.js教材-事件篇》

《全网最详bpmn.js教材-renderer篇》

《全网最详bpmn.js教材-contextPad篇》

《全网最详bpmn.js教材-编辑、删除节点篇》

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342

推荐阅读更多精彩内容