ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue3 + Bpmn-js 工作流设计器开发实战

Vue3 + Bpmn-js 工作流设计器开发实战 开始接触 Bpmn-js 是因为一个绕不开的真实需求后台管理系统要上一套审批流甲方开口就要一个像画图工具一样拖拽出流程的页面。当时我快速对比了一圈方案最后定下来 Vue3 Bpmn-js 的组合把 BPMN 2.0 标准的流程设计器完整落地到了生产环境。这套方案最终做到了什么画布拖拽建模、节点属性配置、XML 导入导出、流程校验、自定义业务字段、暗黑主题适配从流程编辑器到执行引擎之间用标准 BPMN XML 打通。这篇文章会把整个选型、开发、踩坑的过程完整写出来适合准备在 Vue3 项目里集成 Bpmn-js、或者正在纠结流程图组件到底怎么选的开发者参考。我尽量不写废话直接给能用的结论和代码。1. 为什么不是自研画布Bpmn-js 选型的真实对比1.1 流程设计器不是简单的拖拽画板很多团队听到流程设计器第一反应是不就是拖几个框、画几条线吗自己用 SVG 或者 Canvas 写一个不得了。这种想法我特别理解但实际拆解需求之后会发现一个能上生产的设计器远比看起来能拖复杂得多。流程设计器至少要做这些事节点拖拽和连线、连线路径自动避障、节点增删改查、撤销重做、键盘快捷键、属性面板联动、模型序列化和反序列化、流程合法性校验、缩放平移视图控制。这还没算审批引擎读得懂这个最关键的要求。BPMN 2.0 本身是一套完整规范有 event、gateway、task、sequence flow 等各种元素类型每个元素有对应的 XML 语义。如果自研画布只实现了画几个矩形导出的数据引擎根本不认那这个设计器就只能当摆设。我当时把需求文档里流程可被审批引擎执行这句话划了重点设计器产出的必须是合法、可解析的 BPMN 2.0 XML而不是自定义 JSON。这个前提直接决定了技术选型方向——必须站在一个成熟的建模引擎上做而不是从零画。1.2 自研流程编辑器的隐性成本这里我想多说一句自研方案的隐性成本因为我确实见过团队在这上面耗了大半年。连线自动避障看起来简单实际涉及路径计算、节点碰撞检测、连线与节点绑定关系拖拽新增节点要处理 undo/redo 状态快照序列化要考虑所有元素类型、坐标、连线方式、扩展属性。更麻烦的是规范兼容。BPMN 2.0 的 XML schema 细节非常多流程引擎对 XML 的解析又很严格。自己定义一套数据结构后续每次对接新引擎都可能出现语义对不上的问题。用一个通俗点的类比自研画布像是在白纸上画建筑草图Bpmn-js 则是给你一套带结构计算、规范图纸、图层管理的制图软件。前者画得开心后者才能拿去施工。如果你只是做一个轻量拓扑图、思维导图那自研或者轻量库完全没问题。但只要涉及审批流、工作流引擎我建议直接站在 BPMN 规范实现者的肩膀上省下来的时间足够把业务打磨得更细。1.3 Bpmn-js 与 LogicFlow、AntV X6 的取舍当时我对比的三个主要选择LogicFlow、AntV X6、Bpmn-js。简单说下结论方便你按场景对号入座。LogicFlow 是滴滴开源的流程绘制框架基于 TypeScript 编写流程图所需的基础能力很全自定义节点也方便。但它主推的是一套自己的数据格式虽然提供 bpmn 适配插件整体重心还是通用流程图框架不是BPMN 规范引擎。AntV X6 是图编辑引擎能力非常强适合做脑图、ER 图、拓扑图、DAG 调度图这类场景。但如果要做 BPMN 设计器需要自己实现大量 BPMN 规范相关逻辑等于还是在造轮子。Bpmn-js 是 bpmn.io 官方出品的 BPMN 2.0 建模工具包内置了符合规范的 palette、contextPad、overlays、属性编辑能力输出 XML 直接能被 Camunda、Flowable、Activiti 这类引擎解析。缺点也很明显自定义节点样式不如通用流程图框架灵活文档偏少很多机制要读源码才能搞清楚。最终我的选择是 Bpmn-js核心判断依据就是标准优先。前端框架用 Vue3是因为整个后台体系已经全面迁到 Vue3 Vite设计器作为一个独立模块嵌进去需要跟现有技术栈统一后期维护成本最低。2. 环境准备与依赖安装版本是最容易踩坑的起点2.1 初始化 Vue3 Vite 项目如果是从零开始直接用官方脚手架就可以。我这边用的是 Vue 3.4 和 Vite 5完整依赖如下npm create vuelatest my-workflow-designer cd my-workflow-designer npm install bpmn-js装完之后先看一眼package.json确认 bpmn-js 的实际版本。这里有个非常关键的习惯一定要锁定版本。bpmn-js 的 API 在不同大版本之间有过调整尤其是属性面板和事件机制。我项目里锁在11.x配套的属性面板用的也是跟它兼容的版本。如果不锁定同事npm install拉到一个新大版本很可能出现运行时报错。2.2 bpmn-js 相关依赖的选型与分工除了核心的bpmn-js实际项目中通常还会用到这几个包建议先搞清楚它们各自干什么包名作用bpmn-js核心建模引擎基于 diagram-js提供画布、元素注册表、模型层bpmn-moddleBPMN 模型的读和写负责 XML 与内存模型对象之间的转换diagram-js底层图形交互框架bpmn-js 基于它构建一般不需要直接操作bpmn-js-properties-panel属性面板组件配合属性编辑使用bpmn-io/properties-panel新版属性面板的底层实现按需引入bpmn-js-bpmnlint流程建模规范检查工具可选很多人刚开始搞混 bpmn-js 和 bpmn-moddle其实一句话就能分清bpmn-js 负责画和交互bpmn-moddle 负责把模型变成 XML、把 XML 变成模型。在设计器里importXML、saveXML这些方法底层都是 bpmn-moddle 在干活。2.3 CSS 资源和打包处理的注意事项Bpmn-js 的样式是独立的 CSS 文件不像组件库那样自动按需引入。最少要引入两张表import bpmn-js/dist/assets/diagram-js.css import bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css这里有个比较容易忽略的细节diagram-js.css管的是画布、连线、拖拽框这些基础样式bpmn-embedded.css管的是 BPMN 元素图标和字体。如果只引了前者流程节点会全部变成空心方块图标全丢。如果你要接属性面板还要额外引入属性面板的样式import bpmn-js-properties-panel/dist/assets/properties-panel.css import bpmn-js/lib/assets/bpmn-js.css另一个重点Vite 打包 bpmn-js 时官方推荐的引入路径是模块内部的lib目录而不是直接import BpmnModeler from bpmn-js。因为包入口文件默认指向的是打包后的完整文件里面有些依赖在 Vite 下可能解析出问题。正确写法是import BpmnModeler from bpmn-js/lib/Modeler如果遇到 Vite 依赖预构建报错可以在vite.config.js里显式配置export default defineConfig({ optimizeDeps: { include: [bpmn-js, bpmn-moddle] } })3. 核心集成把画布稳定地嵌入 Vue3 组件3.1 组件结构与容器渲染Bpmn-js 是典型的命令式库你给它一个真实 DOM 容器它在里面生成画布。这和 Vue 的声明式渲染思路不一样所以组件里必须留一个不参与 Vue 更新的空白容器。我的组件结构大致是这样template div classdesigner-wrapper div classdesigner-canvas refcanvasRef/div div classdesigner-panel idjs-properties-panel/div /div /templatecanvasRef绑定的是画布容器js-properties-panel是属性面板容器。两个容器都必须有明确高度Bpmn-js 不会帮你处理容器尺寸画布初始化时如果容器高度是 0后面怎么调都白搭。我的样式大概是这样.designer-wrapper { display: flex; height: 100%; width: 100%; } .designer-canvas { flex: 1; height: 100%; background: #fafafa; } .designer-panel { width: 300px; height: 100%; overflow-y: auto; border-left: 1px solid #e0e0e0; }3.2 Composition API 中的实例化与响应式避坑到了核心环节初始化。在 Vue3 组合式 API 里我推荐用shallowRef存 BpmnModeler 实例并且配合markRaw使用。直接上代码import { shallowRef, onMounted, onBeforeUnmount, markRaw } from vue import BpmnModeler from bpmn-js/lib/Modeler const canvasRef ref(null) const modeler shallowRef(null) onMounted(() { modeler.value markRaw(new BpmnModeler({ container: canvasRef.value, propertiesPanel: { parent: #js-properties-panel } })) modeler.value.createDiagram() })这里有个 Vue3 特有的深坑如果把modeler直接定义成ref()或者把它放进 reactive 对象里Vue 会用 Proxy 包一层。而 bpmn-js 内部有大量基于instanceof检查和事件监听器的逻辑代理对象会破坏这些判断出现事件触发不了元素选不中报错说不是合法实例之类的诡异问题。shallowRef可以避免深层响应式代理markRaw则是进一步明确告诉 Vue这个对象别给我做响应式处理。这两个搭配使用基本能杜绝这一类问题。3.3 组件销毁时的清理避免事件泄漏集成命令式库生命周期管理是重中之重。Bpmn-js 内部会往容器上挂 DOM 事件监听器还会注册各种模块实例。组件卸载时如果只删掉 DOM 而不调用destroy()这些监听器会留在内存里。我的销毁逻辑是这么写的onBeforeUnmount(() { if (modeler.value) { modeler.value.destroy() modeler.value null } // 如果容器是组件自己创建的顺手清理一下 canvasRef.value?.removeAttribute(style) })另外如果给 modeler 注册过自定义事件监听销毁逻辑里应该先off再destroy养成好习惯。示例const selectionChanged (event) { // 处理选中元素变化的逻辑 updatePropertiesPanel(event) } modeler.value.on(selection.changed, selectionChanged) onBeforeUnmount(() { modeler.value?.off(selection.changed, selectionChanged) modeler.value?.destroy() modeler.value null })4. 功能闭环导入导出、工具栏、属性面板4.1 导入与导出 XML 的完整实现设计器不能只活在浏览器里得有打开流程和保存流程两个口子。导入的逻辑是后端把 BPMN XML 字符串传给前端前端调用importXMLasync function openBpmn(xmlString) { if (!modeler.value) return try { const result await modeler.value.importXML(xmlString) const { warnings } result if (warnings warnings.length) { console.warn(导入过程存在警告:, warnings) } // 导入成功后把视口调整到合适位置 const canvas modeler.value.get(canvas) canvas.zoom(fit-viewport, auto) } catch (err) { console.error(导入失败:, err) window.$message?.error(流程文件解析失败) } }导出的核心是saveXML这个方法返回 Promise拿到xml字段就是完整的 BPMN 2.0 字符串async function saveBpmn() { if (!modeler.value) return null const { xml } await modeler.value.saveXML({ format: true }) return xml }format: true是让输出的 XML 自动缩进换行方便后端存库和排查问题。建议保存前先做一次流程校验后面会讲到校验通过再调saveXML。有些场景还需要导出图片可以用saveSVGconst { svg } await modeler.value.saveSVG()得到的 svg 字符串可以直接显示、下载或者交给后端转 PNG。这个功能在生成流程图快照、流程文档时很实用。4.2 撤销重做、调整画布视角与缩放Bpmn-js 的撤销重做能力内置于 commandStack 模块使用方式非常直接const commandStack modeler.value.get(commandStack) commandStack.undo() commandStack.redo()为了让工具栏上的撤销/重做按钮状态实时变化监听commandStack.changed事件modeler.value.on(commandStack.changed, () { undoable.value commandStack.canUndo() redoable.value commandStack.canRedo() })缩放和视角控制通过 canvas 模块实现const canvas modeler.value.get(canvas) // 放大/缩小 canvas.zoom(1.2) canvas.zoom(0.8) // 一键适应视口推荐在打开流程后调用 canvas.zoom(fit-viewport, auto) // 返回默认缩放 canvas.zoom(return)工具栏一般就是放一排按钮打开、保存、撤销、重做、放大、缩小、适应视口、校验。每个按钮对应上面这些 API。4.3 属性面板的接入与数据同步属性面板是设计器能配置业务的关键。老版本的属性面板已经集成在 bpmn-js 里但新版bpmn-js 11把它拆成了独立模块。初始化时需要指定属性面板容器同时配置 additionalModulesimport BpmnModeler from bpmn-js/lib/Modeler import propertiesPanelModule from bpmn-js-properties-panel import propertiesProviderModule from bpmn-js-properties-panel/lib/provider/camunda import camundaModdle from camunda-bpmn-moddle/resources/camunda modeler.value markRaw(new BpmnModeler({ container: canvasRef.value, propertiesPanel: { parent: #js-properties-panel }, additionalModules: [ propertiesPanelModule, propertiesProviderModule ], moddleExtensions: { camunda: camundaModdle } }))选中元素时属性面板会自动跟随显示对应元素的属性。如果你想在面板外部做一些联动比如右侧表单显示当前节点信息监听selection.changedmodeler.value.on(selection.changed, (event) { const element event.newSelection?.[0] if (element) { // 通过业务属性扩展读取自定义字段 currentElement.value element } })5. 定制扩展自定义节点样式、校验与业务字段5.1 通过 moddle 扩展添加自定义业务属性真实项目中光有 BPMN 标准属性远远不够。比如审批节点需要配置审批人角色会签/或签超时时间这些都是自定义字段。Bpmn-js 提供 moddle 扩展机制来支持自定义属性。第一步建一个 JSON 描述自定义属性的命名空间和字段{ name: Custom, prefix: custom, uri: http://mycompany.com/schema/custom, xml: { tagAlias: lowerCase }, types: [ { name: CustomTaskElement, superClass: [bpmn:Task], properties: [ { name: approvalType, isAttr: true, type: String }, { name: approverList, isAttr: true, type: String } ] } ] }第二步在初始化 modeler 时注册这个扩展import customModdle from ./custom-moddle.json modeler.value markRaw(new BpmnModeler({ container: canvasRef.value, // other config... moddleExtensions: { custom: customModdle } }))第三步通过元素业务对象读写扩展属性function getApprovalType(element) { const bo element.businessObject return bo.get(custom:approvalType) || single } function setApprovalType(element, value) { const bo element.businessObject const modeling modeler.value.get(modeling) modeling.updateProperties(element, { custom:approvalType: value }) }注意自定义属性一定用modeling.updateProperties去改这样能进入 undo/redo 操作栈用户按 CtrlZ 时可以回退。5.2 流程校验与错误点位提示保存前校验是必须的。我的校验逻辑分两层第一层是 Bpmn-js 自带的导入解析校验上面importXML会返回 warnings第二层是业务级校验。业务校验通常要检查这些点流程必须有开始事件和结束事件所有节点必须至少有一条出线网关节点的条件连线必须配置条件表达式每个审批节点必须配置审批人不能有孤立节点实现思路很简单遍历元素注册表逐个检查。function validateFlow() { const elementRegistry modeler.value.get(elementRegistry) const errors [] const allElements elementRegistry.getAll() let hasStart false let hasEnd false allElements.forEach((element) { const bo element.businessObject if (bo.$type bpmn:StartEvent) hasStart true if (bo.$type bpmn:EndEvent) hasEnd true if (bo.$type bpmn:Task) { if (!bo.get(custom:approverList)) { errors.push({ elementId: element.id, message: 节点 ${bo.name || element.id} 未配置审批人 }) } } }) if (!hasStart) errors.push({ elementId: , message: 流程缺少开始节点 }) if (!hasEnd) errors.push({ elementId: , message: 流程缺少结束节点 }) return errors }校验结果除了弹窗提示最好还能在画布上标红。这里可以用 overlays 功能在出错的节点上叠加一个错误角标const overlays modeler.value.get(overlays) errors.forEach((error) { if (error.elementId) { overlays.add(error.elementId, { position: { top: 0, right: 0 }, html: div classerror-badge${error.message}/div }) } })5.3 自定义 Palette 和 ContextPad 的入口默认 palette左侧元素工具栏包含所有 BPMN 元素类型对业务人员来说太多了容易误拖。我通常会精简 palette只保留业务需要的开始事件、任务、用户任务、排他网关、并行网关、结束事件、连线。实现方式是通过 additionalModules 注入自定义 palette providerclass CustomPaletteProvider { constructor(palette, create, elementFactory, handTool, lassoTool) { this.palette palette this.create create this.elementFactory elementFactory this.handTool handTool this.lassoTool lassoTool palette.registerProvider(this) } getPaletteEntries() { const { create, elementFactory, handTool, lassoTool } this function createAction(type) { return function (event) { const shape elementFactory.createShape({ type }) create.start(event, shape) } } return { hand-tool: { group: tools, className: bpmn-icon-hand-tool, title: 拖拽画布, action: { click: (event) handTool.activate(event) } }, lassoTool: { group: tools, className: bpmn-icon-lasso-tool, title: 框选, action: { click: (event) lassoTool.activate(event) } }, bpmn-start-event: { group: events, className: bpmn-icon-start-event-none, title: 开始事件, action: { click: createAction(bpmn:StartEvent) } }, bpmn-user-task: { group: activities, className: bpmn-icon-user-task, title: 审批节点, action: { click: createAction(bpmn:UserTask) } }, bpmn-exclusive-gateway: { group: gateways, className: bpmn-icon-gateway-xor, title: 排他网关, action: { click: createAction(bpmn:ExclusiveGateway) } }, bpmn-end-event: { group: events, className: bpmn-icon-end-event-none, title: 结束事件, action: { click: createAction(bpmn:EndEvent) } } } } }然后在初始化时把这个 provider 加进 additionalModulesimport CustomPaletteProvider from ./CustomPaletteProvider new BpmnModeler({ additionalModules: [CustomPaletteProvider] })同理ContextPad右键/选中节点时弹出的操作栏也可以自定义 provider隐藏不想要的入口比如替换类型这类高级操作对业务用户就不太友好。6. 踩坑实录六类高频问题的完整排查链路6.1 画布空白或样式错乱这类问题我见得太多了第一反应永远是三查查容器高度、查 CSS 引入、查 DOM 是否被替换。容器高度是最常见的。如果外层容器用height: autoBpmn-js 初始化时量到的高度是 0画布根本不会渲染。排查方法打开控制台查看.djs-container的实际尺寸为 0 就说明父级高度链断了。解决办法是给画布容器设定明确高度或者用flex布局并保证父级有高度。CSS 引入的问题也很隐蔽。很多人只引入了 bpmn-js-properties-panel 的样式漏掉 diagram-js.css结果连线箭头、拖动框显示异常。另一个是 Vue 单文件组件里的 scoped 样式如果你把.djs-container写在 scoped 里属性选择器会让它失效。解决办法是放到全局样式或者用:deep()穿透。6.2 组件销毁后事件仍然触发的内存泄漏这个问题在我们内部分两个层级出现过。第一次是组件被切换后用户还能在控制台看到 old modeler 的日志说明实例没有被正确销毁监听器还活在内存里。当时排查链路是先确认组件的 onBeforeUnmount 是否执行再确认 destroy() 是否被调用最后检查是否有外部模块比如属性面板 provider 里注册的全局事件没有被释放。第二次更隐蔽初始化时用了ref()保存 modeler 实例Vue 的 Proxy 包了一层内部模块注册的事件引用关系变乱导致销毁时清理不干净。这类问题在浏览器 performance 内存面板里能看到每次打开关闭设计器内存只增不减的曲线。解决办法就是前面的shallowRefmarkRawdestroy()。如果设计器是在某些 SPA 路由里反复进入退出建议写一个包裹 layer 或 composable把创建和销毁收敛到同一个出口避免忘记调用。6.3 自定义属性在保存后被引擎忽略这个问题非常典型页面上配置的自定义属性导出 XML 时确实在节点上但流程引擎加载后读不到。排查链路是这样的先看导出的 XML 里有没有xmlns:custom这个命名空间声明。如果 moddle 扩展注册成功XML 根节点应该自动带上命名空间声明。如果没有问题多半出在 moddleExtensions 的 key 和 JSON 里的 prefix 不一致或者 additionalModules 没加上。再看属性写入方式。如果直接改businessObject的属性而没有走modeling.updateProperties很多属性不会触发 XML 序列化的脏检查导出时可能丢失或不对。一定要走建模 API。最后还有一种情况引擎侧解析器不支持自定义前缀。比如我们后端用的引擎默认只认custom前缀换成别的就忽略。这个属于契约问题最好在设计器端和引擎端维护一份属性字典避免各写各的。6.4 弹窗/抽屉中打开设计器的尺寸问题在 Modal 或 Drawer 里嵌入设计器最常见的坑是弹窗打开时画布渲染不出来或者渲染成一个很窄的条。原因是弹窗初始状态v-if或display: noneBpmn-js 初始化时容器宽高为 0之后弹窗显示但画布不会自动重算尺寸。解决方案有三种按推荐程度排弹窗完全显示后再挂载组件用v-if控制打开弹窗时把visible设为 true让设计器组件在弹窗渲染完成后才 mount。这样初始化时容器已经有真实尺寸。延迟初始化nextTick或setTimeout(() init(), 0)给弹窗动画留出完成时间。监听弹窗生命周期在after-open钩子里调用canvas.zoom(fit-viewport)并触发一次 resize。如果你用了自适应布局窗口尺寸变化时还应该监听 resize 事件const resizeObserver new ResizeObserver(() { modeler.value?.get(canvas).zoom(fit-viewport, auto) }) resizeObserver.observe(canvasRef.value)6.5 暗黑主题下画布配色适配现在很多后台管理系统支持暗黑模式Bpmn-js 默认的高亮蓝、白底背景在暗色主题下会非常刺眼。最麻烦的是它部分样式写在行内样式或者 SVG 属性里光靠覆盖 CSS 不够。实践下来比较可行的方案是用 CSS 变量覆盖关键颜色。新版 diagram-js 支持通过 CSS 变量控制部分颜色比如.dark-theme .djs-container { --color-directory: #1e1e1e; --color-white: #2d2d2d; --color-black: #e0e0e0; --color-blue: #409eff; }对于节点填充色、边框色这类写进 SVG 属性的样式可以在导入流程后遍历元素批量调整或者用自定义渲染器覆写 BaseRenderer 的绘制方法。但要注意直接修改 BPMN 元素的内部 fill 属性XML 里不会持久化只在画布层做视觉覆盖这样不会破坏流程数据这个思路很重要。6.6 Vite 构建时 bpmn-js 报错的处理最后说下构建阶段遇到的坑。Vite 对大型 CJS 依赖做预构建时偶尔会报Cant resolve clipboard或者process is not defined这类错误。常规处理手段有两个一是调整优化配置export default defineConfig({ optimizeDeps: { include: [bpmn-js], exclude: [bpmn-moddle] }, resolve: { dedupe: [bpmn-js] } })二是严格按照模块路径引入避免走包入口的浏览器产物。比如bpmn-js/lib/Modeler、bpmn-js-properties-panel/lib/PropertiesPanel这些路径指向的是源码模块让 Vite 自己打包兼容性更好。如果用了 bpmnlint 一类的辅助包建议检查它们的 main 字段是否指向 ESM必要时也用optimizeDeps兜底。整个流程设计器从选型到落地最核心的一条经验是把 Bpmn-js 当作建模引擎而非UI 组件库来用所有业务字段、校验规则、存储结构都建立在标准 BPMN 模型之上而不是另搞一套自定义数据结构再去做转换。这样设计器前端、引擎后端、流程监控端拿到的始终是同一种语言。如果你也是第一次在 Vue3 项目里接入 Bpmn-js建议先把最小 demo 跑通再逐步叠加属性面板、自定义扩展、校验这些能力每加一层就保存一次 XML 到后端确认数据链路没有被破坏。这套流程走完你会对整个 BPMN 建模体系的理解上一个台阶后面再做流程回显、版本对比、流程模拟这些进阶功能就都是水到渠成的事了。
返回列表