十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

使用Vue+ElementUI+JsPlumb构建可视化流程设计器

使用Vue+ElementUI+JsPlumb构建可视化流程设计器 简介这是一份基于VUEElementUIJsPlumb构建的流程设计器资源easy-flow面向需要快速集成可视化流程编辑功能的前端开发者。通过vuedraggable实现节点拖拽支持拖拽添加节点、点击连线设置条件、加载已有流程数据、画布拖拽以及连线样式、锚点、类型的自定义覆盖并内置力导图展示适合用于OA审批流、工单流转等业务场景。压缩包共55个文件以25个js源码、7个vue组件为主另有html页面、css样式、图片字体及项目配置等整体仅2.17MB结构轻量便于直接阅读和二次开发。资源已有1086人学习使用。解压后可见清晰的src/components等模块划分附有构建配置与说明文档可直接在Vue工程中引入组件复用也可参考其实现思路扩展自定义节点与连线规则节省从零开发流程设计器的时间。1. 流程设计器不只是画连线数据流才是核心流程设计器放到今天已经不算新鲜需求但它仍然是每个中后台系统里最难啃的一块硬骨头。BPM 审批流、工单流转、数据编排、告警规则链路几乎每个业务线到最后都会问一句能不能像 Visio 一样拖拽配置而不是改 JSON这时候最稳妥的方案就是 Vue ElementUI JsPlumb vuedraggable 这个组合。ElementUI 负责表单和属性面板vuedraggable 解决节点的拖拽排布JsPlumb 承载连线的生成与拓扑计算三层各管一摊替换成本也低。这篇文章就按照这个技术栈从项目初始化、拖拽落位、连线生成、数据序列化到打包后的坑完整讲一遍设计器的搭建路径。2. 项目初始化与 JsPlumb、vuedraggable 的版本选型2.1 先确定用 Vue2 还是 Vue3标题里写的是 ElementUI 而不是 Element Plus所以这里默认基于 Vue2。ElementUI 官方并不支持 Vue3如果项目要往 Vue3 迁移组件库得换成 Element Plus同时 vuedraggable 也需要升级到vue/compat或者使用vuedraggablenext分支这不是简单改改 import 就能完成的。所以在一开始就要定下来如果是从零起的新项目且没有历史包袱建议直接 Vue3 Element Plus如果是在维护已有老系统那么 Vue2.7 ElementUI 2.15 是当前最成熟稳定的搭配。Vue2.7 还引入了 Composition API既兼容老写法又能渐进式重构是一个折中方案。2.2 用 vue/cli 初始化项目并安装依赖假设已经配置好了 Node.js 环境和 npm 镜像直接通过官方脚手架创建项目vue create flow-designer # 选择 Manually select features勾选 Router、Vuex、CSS Pre-processors cd flow-designer npm install element-ui2.15.13 npm install jsplumb2.15.6 npm install vuedraggable2.24.3 npm install lodash npm install --save-dev sass sass-loader10这里几个版本号建议锁死。jsplumb2.15.6 是社区版里比较完善的版本MIT 协议可以放心商用vuedraggable2.24 对应的是 Vue2 版本不要装成 4.x。sass-loader锁 10 是因为 Vue2.7 的 webpack 4 环境下sass-loader 11 以上会出现编译兼容问题。启动项目后浏览器里访问可以看到 Vue 默认页面说明环境已经通了。2.3 为什么不选 AntV X6 或 LogicFlow这是设计器选型时必须回答的问题。AntV X6 和 LogicFlow 本身就是流程图引擎自带节点、连线、缩放的完整能力如果只引入 JsPlumb确实要自己写很多底层逻辑比如端点的碰撞检测、连线的避障算法、节点拖动时连线的重绘。但它们的代价是学习成本和改造成本都高尤其是业务节点上有大量自定义表单、权限控制、数据联动时X6 的节点渲染机制反而会成为一种束缚。JsPlumb 的思路更接近 DOM 操作节点就是普通的 div连线是由 SVG 或 Canvas 画出来的 overlay这意味着可以完全复用 ElementUI 的组件体系来渲染节点内容改造自由度最高。两者的取舍如下维度JsPlumbAntV X6 / LogicFlow学习曲线较低API 贴近 DOM 操作较高需要理解图模型节点渲染直接操作 DOM 元素自定义节点渲染机制自定义能力极度自由受框架约束连线避障支持有限内置自动避障社区与文档老牌稳定中文资料更丰富2.4 推荐的项目目录结构流程设计器不是单页应用里的一个小组件它至少包含节点面板、画布区、属性面板、顶部工具栏四大部分。这是我常用的目录结构src/views/designer/ ├── index.vue # 设计器主页面 ├── components/ │ ├── NodePanel.vue # 左侧拖拽源面板 │ ├── FlowCanvas.vue # 画布区域 │ ├── PropertyPanel.vue # 右侧属性面板 │ └── Toolbar.vue # 工具栏 ├── core/ │ ├── flowManager.js # JsPlumb 实例封装 │ ├── dataModel.js # 节点/连线数据模型 │ └── eventBus.js # 组件间通信 └── mock/ └── template.js # 节点模板定义FlowCanvas 里会引入 core 目录下的 flowManager避免在组件里塞一大坨 JsPlumb 逻辑。NodePanel 和 PropertyPanel 之间通过 Vuex 共享当前选中节点 id这样属性面板才能实时编辑画布中选中的节点。3. 用 vuedraggable 实现节点从面板拖入画布3.1 NodePanel 的拖拽源配置NodePanel 本质上是一个节点模板列表每个模板用 ElementUI 的 card 卡片展示图标和名称。要让这些卡片能被拖拽到画布区关键在vuedraggable的group和clone配置template div classnode-panel el-card v-fornode in nodeTypes :keynode.type classnode-card draggable :listnodeTypes :group{ name: flow-node, pull: clone, put: false } :clonecloneNode :sortfalse item-keytype template #item{ element } div classnode-item i :classelement.icon / span{{ element.name }}/span /div /template /draggable /el-card /div /template注意group中的pull: clone表示从这个列表拖出时是复制一份原始列表不会被移除。:sortfalse禁止面板内的节点排序变化因为左侧模板顺序固定拖拽行为只会发生在跨容器之间。// 在 NodePanel 中 cloneNode(origin) { return { ...origin, id: node_${Date.now()}, x: 0, y: 0, name: origin.name, props: JSON.parse(JSON.stringify(origin.defaultProps)) }; }clone 函数接收原始节点模板返回一个新对象。一定要用深拷贝处理defaultProps否则多个实例会共享同一个 props 引用修改一个节点属性时其他节点也会跟着变。3.2 FlowCanvas 画布区的 receive 配置画布区对应的 draggable 组件用来接收外部拖入的节点核心配置是group.name与左侧保持一致put: true表示允许放入div classcanvas-container refcanvasContainer draggable classcanvas-area :listcanvasNodes groupflow-node item-keyid addonNodeAdded updateonNodePosUpdated template #item{ element } div :idelement.id classflow-node click.stopselectNode(element) span classnode-label{{ element.name }}/span el-icon classnode-close click.stopremoveNode(element) el-icon-close / /el-icon /div /template /draggable /div这里有一个关键点list里存放的是节点的数据对象而 vuedraggable 在拖拽结束时会把元素插入到 list 中。插入后 DOM 才生成但 JsPlumb 需要等 DOM 真实渲染完成后才能绑定端点所以add事件触发时不能立即调用 connect 相关方法。3.3 落位后渲染节点的完整流程节点被拖入画布后要经历三个阶段的处理数据落位、渲染 DOM、绑定 JsPlumb 端点。我一般把这三步集中在 FlowCanvas 的一个方法里onNodeAdded(evt) { const newNode this.canvasNodes[evt.newIndex]; const container this.$refs.canvasContainer; this.$nextTick(() { this.$nextTick(() { // 第二次 nextTick 确保 DOM 完全渲染 const domEl document.getElementById(newNode.id); const containerRect container.getBoundingClientRect(); const nodeRect domEl.getBoundingClientRect(); // 记录节点在画布中的坐标 newNode.x nodeRect.left - containerRect.left; newNode.y nodeRect.top - containerRect.top; // 让 JsPlumb 感知到新节点 flowManager.addNode(newNode.id); }); }); }双层$nextTick是为了确保 vuedraggable 的动画过渡也结束。如果只用一个 nextTickdomEl 可能已经存在但节点宽度高度还没稳定记录到的坐标会有偏差。flowManager.addNode内部会调用plumb.addEndpoint和plumb.draggable把节点变成可连线的元素同时也支持在画布内继续拖动。4. JsPlumb 连线初始化与拖拽连线的参数配置4.1 初始化 jsPlumb 实例JsPlumb 在项目里应该只初始化一次之后所有操作都围绕这个实例展开。我把实例封装成了flowManager.js避免在组件里散落多处初始化// flowManager.js import { jsPlumb } from jsplumb; const instance jsPlumb.getInstance({ Connector: [Bezier, { curviness: 60 }], PaintStyle: { stroke: #5470c6, strokeWidth: 2 }, HoverPaintStyle: { stroke: #ff4d4f, strokeWidth: 2 }, EndpointStyle: { radius: 5, fill: #5470c6 }, EndpointHoverStyle: { fill: #ff4d4f }, Anchor: [Continuous, { faces: [top, bottom, left, right] }], ConnectionOverlays: [ [Arrow, { location: 0.5, length: 8, width: 8 }], [Label, { label: , id: edgeLabel }] ] }); export function addNode(nodeId) { instance.addEndpoint(nodeId, { isSource: true, isTarget: true, maxConnections: -1, endpoint: Dot }); instance.draggable(nodeId, { containment: canvasContainer }); } export function connectNodes(sourceId, targetId) { instance.connect({ source: sourceId, target: targetId, connector: [Bezier, { curviness: 60 }], overlays: [[Label, { label: , id: edgeLabel }]] }); } export default instance;4.2 最终代码结构两个事件需要注册mounted() { flowInstance.ready(() { flowInstance.bind(connection, (info) btnsFlowHandler(info)); flowInstance.bind(connectionDetached, (info) btnsFlowHandler(info)); flowInstance.bind(connectionMoved, (info) btnsFlowHandler(info)); }); }当用户在画布空白处点击鼠标左键后检查当前是否有未被连接的 source 节点如果有就往下一条 connecting 状态推进。当用户点击一个目标节点时先判断它是否已经连接过是则忽略否则调用connect完成连接。这样能防止误操作产生重复连线。同时connectionMoved需要监听是因为拖动已有连线到一个新端点时JsPlumb 会触发connectionMoved事件此时旧的连接关系已被移除但数据模型还没同步必须统一处理。4.3 连线参数详解上面代码里出现的连线参数是实际项目中最常调的几张表参数可选值说明connectorBezier/Flowchart/StateMachineBezier 平滑Flowchart 适合直角连线StateMachine 适合可回溯的环形连接curviness0~100曲线弯曲程度只对 Bezier 和 Flowchart 生效anchorContinuous或[x, y]节点上连接点的位置Continuous 会自动避让到边界endpointDot/Rectangle/Blank端点的视觉类型Dot 适合大多数场景maxConnections正整数或 -1单个节点最大连接数-1 表示不限制overlaysArrow/Label/Custom连线上叠加的元素通常放箭头和连线名称箭头一定要配置location它表示箭头在线段上的位置0 为起点0.5 为中间1 为终点。流程设计器里箭头应该指向目标节点所以location设置为 0.5 以上比较合适通常在 0.5 到 0.8 之间。4.4 删除连线与节点删除逻辑也要和数据模型捆绑。JsPlumb 提供了deleteConnection和remove两个 API但调用之前必须先从 Vuex 的 edges 列表里找到这条连线对应的数据对象。我维护了一个edgeMapkey 是 sourceId targetIdvalue 是完整连线信息这样删除时能快速定位。removeEdge(connection) { const key connection.sourceId _ connection.targetId; const edge this.edgeMap[key]; if (edge) { flowInstance.deleteConnection(connection); this.$store.commit(designer/removeEdge, edge.flowId); } }5. 用 ElementUI 构建节点属性面板与数据序列化5.1 PropertyPanel 的节点属性编辑属性面板的职责是编辑当前选中节点包括节点名称、类型、处理人、超时时间等业务字段。它依赖 Vuex 中的currentNodeId通过 getter 获取节点完整数据用 ElementUI 的表单组件渲染。el-form v-ifcurrentNode :modelcurrentNode label-width80px sizesmall el-form-item label节点名称 el-input v-modelcurrentNode.name placeholder请输入节点名称 / /el-form-item el-form-item label节点类型 el-tag{{ typeLabel(currentNode.type) }}/el-tag /el-form-item el-form-item label处理人 el-select v-modelcurrentNode.props.assignee multiple filterable placeholder请选择处理人 el-option v-foruser in userList :keyuser.id :labeluser.name :valueuser.id / /el-select /el-form-item /el-form这里有个细节要注意v-model直接绑定currentNode是可行的因为 jsPlumb 节点对象是一个响应式对象修改name属性时FlowCanvas 里对应节点 DOM 的标签文本也会通过 mutation 重新渲染。如果遇到节点名称在画布上不更新的情况排查顺序是先确认 Vuex 里数据是否变了再看 JS 原生的 DOM 操作有没有被 Vue 的虚拟 DOM 覆盖。5.2 把流程数据序列化为 JSON流程设计器最终交付给后端的是一份 JSON 数据后端将它存储到数据库。序列化的核心是把 Vuex 中的 canvasNodes 和 edges 转换成不含 UI 状态的纯净数据结构buildFlowJSON() { const nodes this.canvasNodes.map((node) ({ id: node.id, type: node.type, name: node.name, x: node.x, y: node.y, props: node.props })); const edges this.edges.map((edge) ({ id: edge.flowId, source: edge.sourceId, target: edge.targetId, label: edge.label })); return { version: 1.0.0, name: this.flowName, nodes, edges }; }注意不要直接把 canvasNodes 整个 JSON.stringify因为中间含有很多 JsPlumb 内部状态。x 和 y 是节点在画布容器内部的绝对坐标后端不需要关心这些坐标但前端重载时要用它还原节点位置。5.3 从 JSON 恢复设计器状态反向的流程是加载 JSON 后先把节点一个一个插入画布并绑定 JsPlumb 端点然后通过connectNodes建立连线。这个过程中窗口尺寸必须保持一致因为节点的坐标是相对画布左上角的如果容器有内边距要额外减去。loadFlow(json) { // 先清空旧状态 this.canvasNodes []; this.edges []; flowInstance.reset(); // 依次创建节点 json.nodes.forEach((node) { this.canvasNodes.push({ ...node }); this.$nextTick(() { const domEl document.getElementById(node.id); if (domEl) { domEl.style.left node.x px; domEl.style.top node.y px; flowManager.addNode(node.id); } }); }); // 创建连线 json.edges.forEach((edge) { flowManager.connectNodes(edge.source, edge.target); }); }这里加载连线前必须确保所有节点的 DOM 都存在否则 JsPlumb 找不到端点就会报错。我的经验是加载节点的 nextTick 中加一个 Promise.all 机制所有节点渲染完成后再统一连边。6. 进阶技巧打包后布局异常的修复与数据校验6.1 打包后连线位置错乱的常见原因与修复开发环境一切正常npm run build之后连线全部错位这是 JsPlumb 设计器最典型的部署问题。排查的时候先看控制台有没有报错如果没有多半是容器尺寸检测时机不对。Vue 打包后页面加载顺序发生了变化DOM 还没渲染完整时 JsPlumb 就计算了各节点的位置。此时要在 FlowCanvas 的mounted里加上延迟重绘并监听窗口 resize 事件mounted() { this.$nextTick(() { setTimeout(() { flowInstance.repaintEverything(); flowInstance.recalculateOffsets(); }, 300); }); window.addEventListener(resize, this.$_resizeHandler); }, beforeDestroy() { window.removeEventListener(resize, this.$_resizeHandler); }, methods: { $_resizeHandler() { this.$nextTick(() { flowInstance.repaintEverything(); }); } }如果 300 毫秒不够稳定可以改成监听图片的 load 事件因为画布里有背景图时JsPlumb 计算坐标的基准是容器元素如果容器尺寸被图片撑开时机不对就会错乱。6.2 保存前对流程图做天然校验流程设计器上线后最常见的用户操作是保存一个不完整的流程如节点孤立、没有起始节点、连成环等。保存前在 Toolbar 里做一次完整校验validateFlow() { const errors []; const nodes this.canvasNodes; const edges this.edges; if (nodes.length 0) { errors.push(流程不能为空); return errors; } // 校验是否存在孤立节点 nodes.forEach((node) { const connected edges.some( (e) e.source node.id || e.target node.id ); if (!connected) { errors.push(节点「${node.name}」没有与其他节点连接); } }); // 校验是否存在环 if (this.hasCycle(edges)) { errors.push(流程中不允许存在循环回路); } return errors; }防止设计器生成不合法流程比后端做二次校验成本更低。另外一个容易被忽略的问题是节点与节点之间的连线数量一对节点之间只能有一条连线否则流程引擎在调度时会出现重复执行。6.3 撤销重做的轻量实现思路撤销重做用命令模式可以实现但流程设计器里频繁操作节点增删和连线增删会带来性能问题。我的建议是维护一个快照栈每次操作完成后把整个buildFlowJSON()的结果压栈撤销时弹栈并整体重绘。这个方案在节点数不超过 200 时体验足够流畅超过这个数量建议引入索引数据库存储增量快照但大多数业务场景 200 个节点已经上限了。本文还有配套的精品资源点击获取
返回列表