
1. 项目缘起为什么要在Vue3里折腾BPMN.js最近在重构一个后台管理系统产品经理拿着原型图过来说想加一个“流程设计器”的功能。用户可以在页面上拖拖拽拽画出一个审批流或者业务流然后保存下来后端能解析执行。我一听这不就是工作流引擎的前端可视化部分嘛。市面上成熟的方案不少但要么太重要么太贵要么定制化程度不够。作为一个有追求的前端我决定自己动手用 Vue3 BPMN.js 来搭一个。你可能要问为什么是 BPMN.jsBPMNBusiness Process Model and Notation是一套画流程图的国际标准就像 UML 之于软件设计。BPMN.js 则是基于这个标准的前端库它提供了画布、一套标准的图形元素比如活动、网关、事件以及序列化/反序列化的能力。用它的好处是你画出来的图是“标准”的后端可以找对应的标准解析库比如 Camunda、Flowable、Activiti来执行生态互通。而 Vue3 的响应式系统和 Composition API能让状态管理和组件逻辑变得更清晰尤其是处理这种画布交互复杂、状态繁多的场景。所以这个项目的核心目标就明确了在 Vue3 框架下深度集成 BPMN.js打造一个功能完整、交互友好、易于二次开发的工作流可视化设计器。它不仅要能画图还要能编辑属性、验证逻辑、导入导出最终生成后端引擎能“读懂”的 BPMN 2.0 XML。2. 环境搭建与BPMN.js核心概念拆解上手第一步不是急着写代码而是先把环境和核心概念理清楚。这能避免后面很多“为什么这个属性不生效”的坑。2.1 项目初始化与依赖安装我习惯用 Vite 来创建 Vue3 项目速度快配置简单。npm create vuelatest my-bpmn-editor # 按照提示选择 TypeScript, Router, Pinia 等看项目需要 cd my-bpmn-editor npm install然后安装 BPMN.js 的核心库及其相关依赖npm install bpmn-js diagram-js --save这里解释一下这几个包bpmn-js: 这是主角一个基于 BPMN 2.0 标准的工作流查看与编辑器。它封装了画布渲染、交互、建模规则等所有核心功能。diagram-js: 是bpmn-js的底层依赖提供了一个通用的图表交互框架。理解它有助于我们后续做自定义扩展。为了有更好的类型提示特别是用 TypeScript 的话可以安装类型定义npm install types/bpmn-js --save-dev2.2 理解BPMN.js的架构Modeler、Viewer与ModulesBPMN.js 主要暴露两个类BpmnModeler和BpmnViewer。顾名思义Modeler用于编辑设计Viewer仅用于查看。我们做设计器自然是用BpmnModeler。但BpmnModeler本身是一个“壳”它的能力由一个个“模块”拼装而成。这是diagram-js架构的精髓——高可插拔性。通过additionalModules选项我们可以注入自定义模块或者覆盖默认模块的行为。一个最简单的初始化代码如下import BpmnModeler from bpmn-js/lib/Modeler; const modeler new BpmnModeler({ container: document.getElementById(canvas), // 可以在这里传入自定义模块 additionalModules: [ // 你的自定义模块 ] });理解这个模块化架构至关重要。后续我们想要修改工具栏、添加上下文菜单、改变元素渲染样式都需要通过创建或覆盖模块来实现。2.3 第一个可运行的画布组件在 Vue3 中我们需要在组件挂载后初始化 BpmnModeler。这里有个关键点画布容器div必须已经存在于 DOM 中。我创建一个BpmnEditor.vue组件template div classbpmn-editor-container div refcanvasRef classcanvas/div div classproperties-panel idjs-properties-panel !-- 属性面板后续会集成 -- /div /div /template script setup langts import { onMounted, ref, onUnmounted } from vue; import BpmnModeler from bpmn-js/lib/Modeler; import bpmn-js/dist/assets/diagram-js.css; import bpmn-js/dist/assets/bpmn-font/css/bpmn.css; const canvasRef refHTMLElement(); let modeler: BpmnModeler | null null; onMounted(async () { if (!canvasRef.value) return; modeler new BpmnModeler({ container: canvasRef.value, }); try { // 创建一个空的流程图 const result await modeler.createDiagram(); console.log(Diagram created!); } catch (err) { console.error(Failed to create diagram, err); } }); onUnmounted(() { // 销毁实例释放内存 modeler?.destroy(); }); /script style scoped .bpmn-editor-container { display: flex; height: 800px; border: 1px solid #ccc; } .canvas { flex: 1; min-width: 0; /* 防止flex item溢出 */ } .properties-panel { width: 300px; border-left: 1px solid #ccc; overflow-y: auto; } /style运行起来你应该能看到一个空白的画布并且左侧的工具栏Palette已经出现了。你可以从工具栏拖拽“开始事件”、“用户任务”、“排他网关”等到画布上。这是一个重要的里程碑说明 BPMN.js 的基础环境已经跑通了。注意这里直接引入了 BPMN.js 自带的 CSS。这两个 CSS 文件包含了画布、元素、连接线等所有基础样式。千万不要遗漏否则你会看到一堆没有样式、位置错乱的图形。3. 核心功能实现从画图到生成XML光能画图还不够我们需要实现一个设计器的完整闭环创建、编辑、保存、导入。3.1 创建新流程图与打开现有XMLmodeler.createDiagram()创建的是一个非常简单的默认流程。通常我们需要一个更符合业务需求的模板或者打开一个已有的流程定义。创建带模板的流程图 我们可以先准备一个基础的 BPMN 2.0 XML 字符串作为模板。这个模板可以包含一个开始事件和一个结束事件或者一些预定义的任务。const defaultBpmnXml ?xml version1.0 encodingUTF-8? definitions xmlnshttp://www.omg.org/spec/BPMN/20100524/MODEL xmlns:bpmndihttp://www.omg.org/spec/BPMN/20100524/DI xmlns:dchttp://www.omg.org/spec/DD/20100524/DC targetNamespacehttp://bpmn.io/schema/bpmn process idProcess_1 isExecutablefalse startEvent idStartEvent_1 / /process bpmndi:BPMNDiagram idBPMNDiagram_1 bpmndi:BPMNPlane idBPMNPlane_1 bpmnElementProcess_1 bpmndi:BPMNShape id_BPMNShape_StartEvent_2 bpmnElementStartEvent_1 dc:Bounds x173 y102 width36 height36 / /bpmndi:BPMNShape /bpmndi:BPMNPlane /bpmndi:BPMNDiagram /definitions; // 在onMounted中用 importXML 代替 createDiagram const result await modeler.importXML(defaultBpmnXml);打开/导入已有XML 这是从后端获取流程定义后的标准操作。importXML方法会解析 XML 并在画布上渲染。// 假设从接口获取到 bpmnXmlString const openDiagram async (bpmnXmlString: string) { try { const { warnings } await modeler.importXML(bpmnXmlString); if (warnings.length) { console.warn(导入时有警告:, warnings); } // 导入成功后可以调整画布视图比如居中 modeler.get(canvas).zoom(fit-viewport); } catch (err) { console.error(导入BPMN XML失败, err); } };3.2 保存与导出获取当前流程的XML设计完成后我们需要获取当前的 BPMN XML传给后端保存。这是通过saveXML方法实现的。const saveDiagram async () { if (!modeler) return; try { const { xml } await modeler.saveXML({ format: true }); // format: true 会美化输出 console.log(当前的BPMN XML:, xml); // 这里可以将 xml 通过接口提交给后端 return xml; } catch (err) { console.error(保存XML失败, err); } };saveXML返回的 XML 是符合 BPMN 2.0 标准的包含了图形布局信息BPMNDiagram部分和流程语义信息process部分。后端的工作流引擎如 Camunda通常只关心process部分来驱动执行但前端保存时最好完整保存以便下次能原样打开。3.3 集成属性面板编辑元素业务属性画布上的每个元素任务、网关等都有业务属性比如“用户任务”需要指定办理人“脚本任务”需要指定脚本内容。BPMN.js 官方提供了一个属性面板库bpmn-js-properties-panel但它依赖于另一个框架inferno且定制起来比较麻烦。对于追求定制化和与项目UI风格统一的我们来说自己实现一个属性面板是更常见的选择。思路监听画布上的元素选择事件。根据选中元素的类型bpmn:UserTask,bpmn:ExclusiveGateway等渲染不同的表单。当表单值变化时更新 BPMN 模型的业务属性。实现步骤首先监听选择事件。我们需要用到 BPMN.js 的eventBus。import { ref, watch } from vue; const selectedElement refany(null); onMounted(() { if (!modeler) return; const eventBus modeler.get(eventBus); // 监听元素选择变化 eventBus.on(selection.changed, (event: any) { const newElement event.newSelection[0] || null; selectedElement.value newElement; }); // 监听元素直接点击有时候不改变选择只是点击 eventBus.on(element.click, (event: any) { selectedElement.value event.element; }); });然后在模板中根据selectedElement的类型动态渲染属性面板template div classproperties-panel div v-ifselectedElement h3属性编辑 - {{ elementTypeName }}/h3 !-- 根据元素类型渲染不同表单 -- div v-ifisUserTask(selectedElement) label办理人/label input v-modelformFields.assignee changeupdateProperty(assignee, formFields.assignee) / label候选组/label input v-modelformFields.candidateGroups changeupdateProperty(candidateGroups, formFields.candidateGroups) / /div div v-else-ifisScriptTask(selectedElement) label脚本/label textarea v-modelformFields.script changeupdateProperty(script, formFields.script) / /div !-- 通用属性如名称、ID -- div label名称/label input v-modelformFields.name changeupdateProperty(name, formFields.name) / /div /div div v-else p请点击画布中的元素以编辑其属性。/p /div /div /template script setup langts // ... 其他代码 import { watch } from vue; // 表单数据 const formFields ref({ name: , assignee: , candidateGroups: , script: }); // 监听选中元素变化更新表单 watch(selectedElement, (newElement) { if (!newElement) { formFields.value { name: , assignee: , candidateGroups: , script: }; return; } const businessObject newElement.businessObject; formFields.value.name businessObject.name || ; formFields.value.assignee businessObject.get(assignee) || ; formFields.value.candidateGroups businessObject.get(candidateGroups) || ; formFields.value.script businessObject.get(script) || ; }, { immediate: true }); // 更新属性到BPMN模型 const updateProperty (key: string, value: string) { if (!modeler || !selectedElement.value) return; const modeling modeler.get(modeling); modeling.updateProperties(selectedElement.value, { [key]: value }); }; // 元素类型判断辅助函数 const isUserTask (element: any) element element.type bpmn:UserTask; const isScriptTask (element: any) element element.type bpmn:ScriptTask; const elementTypeName computed(() { if (!selectedElement.value) return ; const type selectedElement.value.type; const map: Recordstring, string { bpmn:StartEvent: 开始事件, bpmn:UserTask: 用户任务, bpmn:ScriptTask: 脚本任务, bpmn:ExclusiveGateway: 排他网关, bpmn:EndEvent: 结束事件, }; return map[type] || type; }); /script这里的关键是modeling.updateProperties方法它是 BPMN.js 提供的 API用于更新元素的业务对象属性并且这个更新是响应式的会同步到最终的 XML 中。实操心得自己实现属性面板虽然前期工作量稍大但后期维护和定制化极其灵活。你可以轻松地将它和你项目中的 UI 组件库如 Element Plus、Ant Design Vue结合做出风格统一、体验优秀的编辑器。4. 深度定制与功能增强基础功能完成后产品肯定会提更多需求“这个工具栏图标不好看”、“能不能右键菜单加个‘复制’”、“用户任务能不能直接显示办理人”。这就需要我们深入 BPMN.js 的模块化系统进行定制。4.1 自定义建模规则什么可以连什么默认情况下BPMN.js 遵循 BPMN 2.0 规范。比如一个“开始事件”后面不能直接连一个“结束事件”中间必须有活动。但有时业务上有特殊需求比如允许“排他网关”直接连回自己形成循环。这就需要修改“连线规则”。我们需要创建一个自定义模块来覆盖默认的rules模块。// customRules.js export default { __init__: [customRules], customRules: [type, CustomRules] }; function CustomRules(eventBus) { eventBus.on(connection.create, function(context) { const { source, target } context; // 在这里编写你的自定义规则 // 如果返回 false则禁止创建此连接 // 例如禁止开始事件直接连结束事件 if (source.type bpmn:StartEvent target.type bpmn:EndEvent) { alert(不允许从开始事件直接连接到结束事件); return false; } // 允许排他网关连回自己 if (source.type bpmn:ExclusiveGateway target source) { return true; // 默认可能不允许这里显式允许 } }); }然后在初始化 Modeler 时注入这个模块import CustomRulesModule from ./customRules; const modeler new BpmnModeler({ container: canvasRef.value, additionalModules: [ CustomRulesModule ] });4.2 自定义上下文菜单右键菜单BPMN.js 的右键菜单也是通过模块提供的。我们可以替换或扩展它。首先需要禁用默认的上下文菜单模块然后提供我们自己的。这需要用到diagram-js的contextPad和popupMenu服务。// customContextMenu.js export default { __init__: [customContextMenuProvider], customContextMenuProvider: [type, CustomContextMenuProvider] }; function CustomContextMenuProvider(popupMenu, modeling, translate) { this._popupMenu popupMenu; this._modeling modeling; this._translate translate; // 注册我们自己菜单的提供者 popupMenu.registerProvider(bpmn-replace, this); } CustomContextMenuProvider.$inject [popupMenu, modeling, translate]; CustomContextMenuProvider.prototype.getPopupMenuEntries function(element) { const self this; return function(entries) { // 删除一些我们不想要的默认条目 delete entries[append.end-event]; // 添加自定义条目 entries[custom.delete] { label: self._translate(彻底删除), className: custom-delete, action: function() { if (confirm(确定要删除这个元素及其所有连接吗)) { self._modeling.removeElements([element]); } } }; entries[custom.copy] { label: self._translate(复制), className: custom-copy, action: function() { console.log(复制元素:, element.id); // 这里可以实现复制逻辑需要用到 clipboard 和 create 服务 alert(复制功能开发中...); } }; return entries; }; };同样在初始化时注入这个模块。注意因为我们要替换默认行为可能需要调整模块的加载顺序或覆盖默认模块。4.3 自定义渲染让元素显示业务数据默认情况下画布上的“用户任务”只显示一个图标和名称。我们希望在图形内部直接显示“办理人张三”这样更直观。这需要自定义一个“渲染器”。我们继承默认的渲染器然后重写特定元素的绘制方法。// customRenderer.js import BaseRenderer from diagram-js/lib/draw/BaseRenderer; const HIGH_PRIORITY 1500; // 优先级要高于默认渲染器 export default class CustomRenderer extends BaseRenderer { constructor(eventBus, bpmnRenderer) { super(eventBus, HIGH_PRIORITY); this.bpmnRenderer bpmnRenderer; } canRender(element) { // 只处理我们关心的元素类型 return element.type bpmn:UserTask; } drawShape(parentNode, element) { // 1. 先让默认的BPMN渲染器画出基础图形 const shape this.bpmnRenderer.drawShape(parentNode, element); // 2. 获取业务对象数据 const businessObject element.businessObject; const assignee businessObject.get(assignee); if (assignee) { // 3. 创建一个文本元素添加到图形内部 const text document.createElementNS(http://www.w3.org/2000/svg, text); text.setAttribute(x, 0); text.setAttribute(y, 30); // 调整Y坐标放在图形底部 text.setAttribute(fill, #333); text.setAttribute(font-size, 10); text.setAttribute(text-anchor, middle); text.textContent 办理人: ${assignee}; // 将文本添加到图形的SVG组中 parentNode.appendChild(text); } return shape; } } CustomRenderer.$inject [eventBus, bpmnRenderer]; // 导出模块 export default { __init__: [customRenderer], customRenderer: [type, CustomRenderer] };将这个模块注入后所有“用户任务”图形下方都会显示办理人信息。这个技巧非常强大可以用来显示各种自定义业务标签、状态图标等。踩坑实录自定义渲染时一定要注意 SVG 的坐标系。画布上的每个图形都是一个g组其内部坐标系的原点 (0,0) 通常是该图形的中心。在添加自定义文本或图形时需要通过x,y,transform等属性仔细调整位置否则很容易画到外面去。多使用浏览器开发者工具检查生成的 SVG 结构是调试的不二法门。5. 性能优化与工程化实践当流程图变得非常复杂包含数百个元素时性能问题就会凸显。同时项目大了代码结构也需要好好规划。5.1 应对复杂流程图的性能策略1. 延迟渲染与虚拟画布 对于超大型流程图可以考虑只渲染视口内的部分。但这需要对 BPMN.js 和 diagram-js 有极深的了解改动成本高。一个更务实的方案是优化操作体验。2. 操作防抖与批量更新 在属性面板输入时每次input事件都触发updateProperties可能会造成频繁的模型计算和重绘。可以使用防抖debounce来优化。import { debounce } from lodash-es; const updateProperty debounce((key: string, value: string) { if (!modeler || !selectedElement.value) return; const modeling modeler.get(modeling); modeling.updateProperties(selectedElement.value, { [key]: value }); }, 300); // 延迟300毫秒3. 谨慎使用监听器 在eventBus上监听太多事件如element.changed,shape.added会影响性能。确保在组件销毁时 (onUnmounted) 移除不必要的监听器。4. 使用bpmn-js的saveSVG替代复杂DOM操作 如果需要导出高清图片不要直接克隆或截图 DOM使用modeler.saveSVG()方法获取纯净的 SVG 字符串再转换为图片性能和质量都更好。5.2 状态管理与组件拆分随着功能增多把所有逻辑堆在一个BpmnEditor.vue里会变成“屎山”。合理的拆分至关重要。我建议的组件结构如下components/BpmnEditor/ ├── index.vue (主容器负责Modeler实例生命周期、全局状态) ├── BpmnCanvas.vue (仅负责画布容器纯UI) ├── BpmnToolbar.vue (自定义工具栏触发全局命令) ├── BpmnPropertiesPanel.vue (属性面板接收选中元素发送更新事件) └── hooks/ ├── useBpmnModeler.js (封装Modeler的创建、销毁、导入/导出方法) ├── useBpmnEvent.js (封装事件监听与触发) └── useBpmnState.js (使用Pinia管理流程图状态、选中元素等)使用 Vue3 的provide/inject或 Pinia 来共享modeler实例和状态。// stores/bpmnStore.js (Pinia) import { defineStore } from pinia; export const useBpmnStore defineStore(bpmn, { state: () ({ modeler: null, selectedElement: null, xml: , }), actions: { setModeler(instance) { this.modeler instance; }, // ... 其他 actions } }); // 在父组件中 import { useBpmnStore } from /stores/bpmnStore; const store useBpmnStore(); onMounted(async () { const modeler new BpmnModeler({...}); store.setModeler(modeler); }); // 在子组件如属性面板中 const store useBpmnStore(); const selectedElement computed(() store.selectedElement);5.3 打包优化与按需加载bpmn-js及其依赖体积不小。如果项目不是每个页面都需要流程设计器可以考虑异步加载。// BpmnEditor.vue script setup import { defineAsyncComponent } from vue; const BpmnCanvas defineAsyncComponent(() import(./BpmnCanvas.vue)); // ... 其他异步组件 /script对于bpmn-js本身它已经是按模块构建的但我们还可以利用 Vite 的 Rollup 配置进行更细粒度的优化确保未使用的模块被 tree-shaking。6. 常见问题排查与调试技巧开发过程中你肯定会遇到各种奇怪的问题。这里分享几个我踩过的坑和解决方法。问题一画布是空的或者工具栏不显示。检查CSS确认diagram-js.css和bpmn.css已正确引入。这是最常见的原因。检查容器尺寸确保画布容器的div有明确的宽高比如height: 600px;。如果高度为 0画布就无法渲染。检查控制台错误打开浏览器开发者工具查看 Console 和 Network 面板是否有 JS 报错或 CSS 文件加载失败。问题二导入XML后图形位置错乱或重叠。检查XML结构确保提供的 BPMN XML 是完整且有效的特别是bpmndi:BPMNPlane中的dc:Bounds坐标信息。如果坐标值异常大或为负图形可能跑到画布外。使用zoom(fit-viewport)导入成功后调用modeler.get(canvas).zoom(fit-viewport)可以自动调整视图让所有元素居中显示。问题三自定义的属性在保存的XML里找不到。确认属性命名空间BPMN.js 默认使用 Camunda 的扩展属性如camunda:assignee。如果你用的是activiti:assignee或其他需要确保在 XML 的根definitions里声明了对应的命名空间。检查更新方法确保使用的是modeling.updateProperties来更新业务对象属性而不是直接修改 DOM 或element对象。查看生成的XML用modeler.saveXML()拿到 XML 后仔细搜索你的属性名看它是否被正确序列化到了对应的元素节点下。问题四想扩展的元素类型在工具栏里找不到。BPMN.js 的默认工具栏Palette只提供了标准 BPMN 元素。如果你想添加一个自定义类型的任务比如一个特殊的“调用微服务任务”你需要自定义一个建模规则模块如 4.1 节允许创建该类型元素。自定义一个渲染器模块如 4.3 节定义这个元素在画布上的样子。自定义 Palette 提供器在工具栏上添加一个按钮。这需要创建一个新模块覆盖paletteProvider服务在getPaletteEntries方法里返回新的按钮定义。调试利器BPMN.js Inspector在开发环境中可以将modeler实例挂载到window对象上方便在浏览器控制台里直接调用 API 和检查内部状态。onMounted(() { modeler new BpmnModeler({...}); window.bpmnModeler modeler; // 仅供调试 });然后就可以在控制台里输入bpmnModeler.get(canvas).zoom(0.8)或bpmnModeler.get(elementRegistry).getAll()来进行调试了。从零开始构建一个 Vue3 BPMN.js 的工作流设计器就像搭积木先有骨架画布再添功能导入导出、属性编辑最后做美化与优化自定义、性能。整个过程最考验的不是对某个 API 的熟悉而是对 BPMN.js 模块化思想的理解和调试问题的耐心。当你看到自己亲手打造的设计器流畅运行并能与后端工作流引擎无缝对接时那种成就感是对所有折腾的最好回报。记住多查官方文档多读源码尤其是 diagram-js多动手实验社区的很多问题你都能自己找到答案。