Vue 集成 bpmn.js:构建可编辑业务流程图与 XML 读写
简介:面向 Vue.js 前端开发者与业务流程可视化需求者的实战示例包,聚焦在 Vue 项目中集成 bpmn.js 以渲染和编辑 BPMN 2.0 流程图,适合已具备 Vue 基础、希望快速上手流程建模组件的中级开发者参考。压缩包共 20 个文件,约 211KB,以 6 个 vue 组件、6 个 js 脚本和 3 个 json 配置为主,辅以 html 入口、图标图片与说明文档,覆盖组件封装、路由视图、状态管理与构建配置等环节,目录结构清晰便于按模块查阅。已有 1596 人学习下载,具备一定参考热度。资源完整呈现从依赖安装到 viewer 实例化、XML 导入与画布缩放的核心流程,并预留 modeler 扩展编辑能力的思路,读者可据此理解流程图数据加载、事件监听与交互处理的实现方式,为业务流程可视化与团队沟通场景提供可复用的代码骨架。
1. 在 Vue 里画一张能跑的业务流程图:bpmn.js 到底解决什么问题
接手一个审批流配置页面时,最省事的做法是让后端返回一张静态图片,前端只负责展示。但只要业务方提一句“节点要能拖、连线要能改、改完还要能存回后端”,静态图立刻翻车。这时候绕不开 bpmn.js:它把 BPMN 2.0 的建模能力搬进浏览器,让你在 Vue 页面里得到一个可拖拽、可连线、可导出 XML 的画布。它解决的不是“画图”,而是“画出来的图能被流程引擎读懂”。适合谁?做低代码平台、OA 审批、工单流转的前端,尤其是已经用 Vue 做中后台、又不想自己造一套图形编辑器的团队。下面按“装得上、画得出、存得下、改得动”的顺序拆。
2. 把 bpmn.js 塞进 Vue 工程:依赖、容器与初始化
bpmn.js 本身是一组模块的集合,不是一个开箱即用的 Vue 组件。它依赖几个底层库:bpmn-moddle负责 BPMN 语义解析,diagram-js负责画布与交互,bpmn-js把两者粘起来。Vue 这边只负责给一个 DOM 容器和生命周期钩子,剩下的交给 bpmn.js 自己管。理解这层分工,后面排查“为什么画布空白”“为什么事件不触发”才有方向。
2.1 依赖选型与版本对齐
常见做法是装三个包:bpmn-js(核心建模器)、bpmn-js-properties-panel(右侧属性面板)、bpmn-moddle(读写 XML)。如果要做自定义节点样式,再加diagram-js的样式覆盖。版本上最容易踩的坑是 properties-panel 和 bpmn-js 的大版本不匹配,表现是面板渲染出来但字段不联动。稳妥策略是锁同一主版本,比如 bpmn-js 17.x 配 properties-panel 5.x,装完先跑一个最小 demo 验证。
# 安装核心依赖,版本按项目锁文件对齐 npm install bpmn-js bpmn-js-properties-panel bpmn-moddle # 如果要用到 camunda 扩展属性,再补一个 moddle 扩展包 npm install camunda-bpmn-moddle这段命令只做一件事:把建模器和属性面板拉进工程。参数上没什么可调的,关键是装完检查package.json里三者版本是否落在兼容区间。我一般会先不装 properties-panel,只装 bpmn-js 跑通画布,确认基础渲染没问题再叠加面板,这样出问题时能快速定位是哪一层引入的。
2.2 Vue 组件里的容器与生命周期
bpmn.js 的Modeler需要一个真实 DOM 节点,且节点必须有明确宽高,否则画布高度为 0,看起来就是“什么都没渲染”。在 Vue 3 的setup里,用ref拿容器,在onMounted里实例化,在onBeforeUnmount里销毁。销毁这一步很多人省掉,结果切换路由后旧实例还挂着事件监听,内存一路涨。
import { ref, onMounted, onBeforeUnmount } from 'vue' import BpmnModeler from 'bpmn-js/lib/Modeler' export default { setup() { const canvasRef = ref(null) let modeler = null onMounted(async () => { // 容器必须有宽高,否则画布高度为 0 modeler = new BpmnModeler({ container: canvasRef.value, keyboard: { bindTo: document } // 让快捷键绑定到整个文档 }) // 载入一张最小合法 BPMN,避免空白画布 const { xml } = await fetch('/diagrams/empty.bpmn').then(r => r.json()) await modeler.importXML(xml) // 导入后自适应缩放,防止图形跑到视口外 modeler.get('canvas').zoom('fit-viewport') }) onBeforeUnmount(() => { // 必须销毁,否则事件监听和 DOM 引用泄漏 modeler && modeler.destroy() modeler = null }) return { canvasRef } } }逻辑说明:container传的是模板里<div ref="canvasRef" style="height:600px">对应的节点;keyboard.bindTo决定快捷键作用域,绑到document才能在画布外也响应删除键。importXML是异步的,必须await,否则紧接着的zoom会作用在空画布上。destroy()是官方提供的清理入口,别只把变量置空。参数上唯一要盯的是容器高度,用 CSS 写死或 flex 撑开都行,就是不能靠内容撑。
2.3 最小可运行验证
初始化完别急着接业务,先验证三件事:画布能显示默认的开始事件、能从左侧拖一个任务到画布、能选中节点。这三步过了,说明 moddle 解析、diagram-js 交互、Vue 容器三者都通了。如果拖拽没反应,八成是容器被上层元素挡住了 pointer 事件,或者keyboard配置冲突。验证通过再往下接属性面板和保存逻辑,返工成本最低。
3. 读写 BPMN XML:导入、导出与属性面板联动
画布能跑只是起点,真正决定这个页面能不能交付的,是 XML 的进出是否稳定。BPMN 文件本质是一段 XML,节点、连线、扩展属性全在里面。前端要做的就两件事:把后端给的 XML 导进来渲染,把用户改完的图导成 XML 存回去。中间再挂一个属性面板,让业务方改节点名称、审批人、条件表达式。
3.1 导入 XML 与错误处理
importXML返回一个 Promise,成功时给{ warnings },失败时抛错。很多人只写await不接catch,结果后端给了一段不合法 XML,页面直接白屏且控制台只有一行红字。正确姿势是把 warnings 也打出来,因为有些“能渲染但语义缺失”的问题只出现在 warnings 里。
async function loadDiagram(modeler, xmlString) { try { const { warnings } = await modeler.importXML(xmlString) if (warnings.length) { // 语义缺失不会阻断渲染,但会导致后续保存丢字段 console.warn('BPMN 导入警告:', warnings) } modeler.get('canvas').zoom('fit-viewport') } catch (err) { // 解析失败通常是 XML 结构不合法或命名空间缺失 console.error('BPMN 导入失败:', err.message) throw err } }参数说明:xmlString必须是完整带<definitions>根节点的 BPMN 2.0 文档,缺命名空间会直接抛错。warnings是数组,常见内容是“未知元素被忽略”,这类问题在保存时会把被忽略的节点丢掉,所以不能当无事发生。我一般会在开发环境把 warnings 直接弹到控制台显眼位置,逼自己处理。
3.2 导出 XML 与格式化
保存时用saveXML,它返回{ xml }。默认导出的 XML 是压缩成一行的,人眼没法看,版本对比也痛苦。加一个格式化步骤,用bpmn-moddle或简单的 XML 美化都行。导出前记得先触发一次saveSVG之外的校验,至少确认没有孤立节点。
async function saveDiagram(modeler) { const { xml } = await modeler.saveXML({ format: true }) // format: true 会做缩进,方便 diff 和人工排查 return xml }format: true是唯一值得关注的参数,它让输出带换行缩进。别小看这一点,流程配置经常要进 Git 做版本管理,一行 XML 的 diff 等于没有 diff。导出后建议在保存接口前做一次长度和根节点校验,防止空图覆盖线上配置。
3.3 属性面板与 moddle 扩展
属性面板要显示业务字段,就得让 moddle 认识这些字段。标准 BPMN 只有 name、id 这类基础属性,审批人、超时时间属于扩展属性,需要注册自定义 moddle 描述。常见做法是写一个 JSON 描述文件,声明命名空间和字段类型,实例化 Modeler 时通过moddleExtensions传进去。面板这边用properties-panel的 provider 机制把字段渲染成输入框。
| 配置项 | 作用 | 常见取值 |
|---|---|---|
| moddleExtensions | 注册扩展命名空间 | { camunda: camundaModdle } |
| propertiesPanel | 挂载右侧面板容器 | DOM 节点 |
| additionalModules | 注入自定义渲染/规则 | 数组 |
表格里三项是联动的:没有 moddleExtensions,面板里填的扩展字段保存时会丢;没有 additionalModules,自定义节点样式不生效。实际项目里我一般先只接标准属性,跑通保存回显,再逐个加扩展字段,避免一次性引入太多变量。
4. 自定义渲染与交互:让画布贴合业务语义
标准 BPMN 画出来是通用符号,业务方看不懂“这个方块代表会签还是或签”。到这一步就得动渲染层:换节点颜色、加图标、限制某些连线规则。bpmn.js 的渲染基于 diagram-js 的依赖注入,自定义渲染器通过additionalModules覆盖默认的BpmnRenderer。
4.1 自定义渲染器的注入方式
写一个继承BaseRenderer的类,在canRender里判断元素类型,在drawShape里画自己的图形。然后通过模块数组注入。注意注入顺序,自定义渲染器要排在默认渲染器之前,否则不生效。
import BaseRenderer from 'diagram-js/lib/draw/BaseRenderer' const HIGH_PRIORITY = 1500 class CustomRenderer extends BaseRenderer { constructor(eventBus, bpmnRenderer) { super(eventBus, HIGH_PRIORITY) this.bpmnRenderer = bpmnRenderer } canRender(element) { // 只接管服务任务,其余交回默认渲染器 return element.type === 'bpmn:ServiceTask' } drawShape(parentNode, element) { const shape = this.bpmnRenderer.drawShape(parentNode, element) // 在默认图形上叠加一个角标,表示这是外部服务节点 // 具体 SVG 操作略,核心是拿到 parentNode 后追加子元素 return shape } } CustomRenderer.$inject = ['eventBus', 'bpmnRenderer'] export default { __init__: ['customRenderer'], customRenderer: ['type', CustomRenderer] }逻辑说明:$inject声明依赖,diagram-js 按名字注入;HIGH_PRIORITY保证先于默认渲染器被询问。canRender返回 true 的元素才走drawShape,其余原样交回。参数上优先级数值越大越先执行,别设得比默认渲染器低,否则永远轮不到。这套机制的好处是不破坏原有交互,选中、拖拽、连线全都还在。
4.2 连线规则与校验
业务上常有“开始事件不能直接连结束事件”“网关必须有两条出线”这类约束。bpmn.js 提供rules模块,可以自定义connection.start、connection.end等规则,返回 false 就禁止该操作。实现方式是写一个 RuleProvider,在init里注册规则,再通过 additionalModules 注入。校验失败时给用户一个提示,别让连线默默弹回去,否则用户以为界面卡了。
4.3 事件总线上的常用钩子
eventBus是 bpmn.js 的神经中枢,元素选中、形状变化、属性更新都走它。常用事件有element.changed、selection.changed、commandStack.changed。最后一个特别有用:它标记了“有未保存修改”,可以据此控制保存按钮的禁用态和路由离开拦截。监听时记得在组件销毁前off掉,否则重复进入页面会叠加监听,一次操作触发多次回调,这种玄学问题排查起来很费时间。
5. 避坑与排查:那些让画布“看起来正常”的隐性故障
bpmn.js 的坑大多不报错,只是行为不对。下面几条是我在不同项目里反复遇到的,按“现象 → 原因 → 解决”记下来,遇到时直接对号入座。
5.1 画布空白但控制台无报错
现象:容器渲染了,importXML也 resolve 了,但画布一片白。原因通常是容器高度为 0,或者zoom在导入完成前被调用。解决:给容器写死height,并把zoom('fit-viewport')放到await importXML之后。还有一种情况是父级overflow: hidden把画布裁掉了,检查布局层级。
5.2 保存后扩展属性丢失
现象:面板里填的审批人保存后再打开没了。原因:moddle 没注册对应扩展命名空间,解析时把未知元素当垃圾扔了。解决:确认moddleExtensions已传入且命名空间与 XML 里一致,导入时看 warnings 是否有“unknown element”。这类问题在开发期就该用 warnings 拦住。
5.3 切换路由后内存持续上涨
现象:反复进出配置页,内存曲线一路向上。原因:Modeler实例没销毁,事件监听和 SVG 节点残留。解决:onBeforeUnmount里调destroy(),并把所有手动eventBus.on的监听off掉。别指望垃圾回收自动处理,diagram-js 内部有闭包引用。
5.4 自定义渲染器不生效
现象:注入了渲染器,但节点样式没变。原因:优先级低于默认渲染器,或canRender判断的类型写错(比如写成bpmn:Task但实际元素是bpmn:ServiceTask)。解决:把优先级调到默认之上,打印element.type确认实际类型。类型判断建议用includes做前缀匹配,别写死全名。
5.5 快捷键误删节点
现象:用户在输入框里按退格,画布上的节点被删了。原因:keyboard.bindTo绑到了document,输入框焦点没被排除。解决:bpmn.js 默认会忽略输入类元素,但如果自定义了面板或用了非标准输入组件,需要手动在keyboard配置里加排除规则,或在输入框聚焦时临时解绑。
6. 进阶:把流程图变成可校验的配置资产
画布能存能改之后,下一步是让这张图“有意义”。我的习惯是在保存前跑一遍自定义校验:遍历所有元素,检查网关出线数量、检查每个任务是否绑定了处理人、检查是否存在孤立节点。校验逻辑不写在渲染层,而是单独抽一个模块,输入是modeler.get('elementRegistry').getAll(),输出是问题列表。这样同一套规则既能用在保存前拦截,也能用在后端做二次校验,前后端口径一致。
function validateDiagram(modeler) { const elements = modeler.get('elementRegistry').getAll() const issues = [] elements.forEach(el => { // 网关至少两条出线,否则流程走不下去 if (el.type === 'bpmn:ExclusiveGateway') { const outgoing = el.outgoing || [] if (outgoing.length < 2) { issues.push(`${el.id} 网关出线不足两条`) } } // 用户任务必须绑定处理人扩展属性 if (el.type === 'bpmn:UserTask' && !el.businessObject.assignee) { issues.push(`${el.id} 未绑定处理人`) } }) return issues }这段校验的价值在于把“图对不对”从人眼判断变成代码判断。参数上注意businessObject才是 moddle 解析后的语义对象,el本身是图形对象,扩展属性挂在businessObject上。校验结果可以直接驱动保存按钮的禁用态,也可以回写到节点上做高亮。
另一个值得做的进阶是版本对比。把每次保存的 XML 存一份快照,用文本 diff 看节点增删,比对着画布肉眼找差异靠谱得多。配合前面说的format: true,diff 结果可读性足够。从那以后我每次接流程配置需求,都会先把“导入 warnings 必须处理、销毁必须调 destroy、保存前必须跑校验”这三条写进验收清单,强制走一遍再提测。希望帮到你。
本文还有配套的精品资源,点击获取