ARTICLE DETAIL

资讯详情

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

Vue 项目集成 jsPlumb 实现可视化流程编排的避坑指南

Vue 项目集成 jsPlumb 实现可视化流程编排的避坑指南 做可视化流程编排的时候我在 Vue 项目里入了 jsPlumb 的坑前后折腾了两周把社区版能踩的坑基本踩了个遍。如果你正打算在 Vue 里做流程图、拓扑图、依赖关系图这一类“节点 连线”的功能这篇总结应该能帮你省下不少时间。我会从选型逻辑、基础概念、组件封装、数据驱动渲染到交互扩展和避坑实录一条线讲清楚所有代码都基于 Vue 2 的 Options API 写法Vue 3 的迁移我也顺便提一下。先交代一下背景当时项目需要做一个自动化任务编排面板用户要能拖拽节点、拉线连接节点、配置连线属性节点之间还要能删除、高亮、右键操作。一开始我用 SVG 手写过连线容易做但“拖拽节点时连线跟着走”“两个节点间自动选锚点”“撤销重做”这些需求一加上来手写方案立刻变得不可维护。后来调研了 G6、X6、LogicFlow 和 jsPlumb综合业务量和团队熟悉度选了 jsPlumb。下面进入正题。1. 为什么在 Vue 项目里选 jsPlumb场景与选型逻辑1.1 我遇到的实际场景可视化连线需求从哪里来需求方给的原话是“搞一个像蓝湖原型一样的流程配置页”。用户从左侧物料栏拖出节点放到画布上然后从一个节点拖出线到另一个节点表示先后依赖。连线可以带标签比如“成功”、“失败”节点可以选中、删除、复制切换页面时所有配置要保存到后端。这类场景在技术层拆开就是三件事节点渲染、连线绘制、交互编辑。节点渲染 Vue 天然擅长v-for 一铺就完事连线绘制和交互编辑才是难点。连线不是静态的节点移动时线要跟着变两个节点之间可能有多个端点线拖开要能自动吸附删除节点时所有关联的线要一起消失。这些能力如果从零写至少得处理 SVG 路径计算、端点碰撞检测、事件命中区、拖拽重绘等等开发周期不可控。jsPlumb 的价值在于把这些底层的几何和交互逻辑都封装好了你只需要给节点元素声明连接点告诉它“从这个点拉到那个点”剩下的路径、箭头、拖拽跟随它都帮你算好。社区版免费满足我们这种非商用的内部工具绰绰有余。1.2 jsPlumb 和普通连线方案的本质区别很多人第一次接触 jsPlumb会误以为它只是一个带箭头的 SVG 连线库其实它的核心是“端点-连线”的数据模型。普通 SVG 连线方案是你手动维护每条线段的起点和终点坐标节点一移动你得自己重新计算所有线的坐标。jsPlumb 的思路是你先在 DOM 元素上注册一组Endpoint端点然后调用connect({ source, target })声明“这两个端点之间有条线”。从那以后你基本不用管坐标节点移动时 jsPlumb 内部会监听元素位置变化自动重算连接线路径。这种模型的好处是你的业务代码只需要维护“谁连接到谁”这份关系数据而不是维护每条线的具体像素路径。对于流程图、拓扑图这种连接关系复杂、节点可任意拖拽的场景开发效率会高非常多。1.3 Vue 生态里的替代品为什么最终没选它们做技术选型的时候我主要对比了 AntV X6、G6 和 LogicFlow。X6 和 G6 是蚂蚁开源的图编辑引擎能力非常强内置了节点、边、画布缩放、小地图、撤销重做等一系列工具如果需求是“从零搭一个像 processon 一样的完整图编辑应用”直接选它们更合适。但我们项目的核心是 Vue 的业务组件图编辑只是其中一小块功能引入 G6 意味着要学习它的一套数据模型和渲染机制节点内部的表单控件也要适配它的自定义节点机制改造成本不低。LogicFlow 是滴滴开源的流程编排框架同样很优秀但它的定位偏向“流程编排”内置了很多流程节点的交互规则自定义样式时反而需要一定学习成本。jsPlumb 的社区版功能没有这些框架那么华丽但它的心智模型更简单你的节点就是普通 DOM 元素用 Vue 模板随便写jsPlumb 只负责挂端点和画线。这意味着我们可以完全保留 Vue 的组件化开发方式节点内部放表单、按钮、状态徽章都很自由。权衡之下团队一致决定选它。2. 集成前的准备工作版本选择与基础概念扫盲2.1 版本坑jsPlumb 2.x 和 3.x 的 API 差异jsPlumb 目前市面上常见的是 2.x 和 3.x 两套 API差异非常大。2.x 的写法是全局单例import { jsPlumb } from jsplumb; jsPlumb.connect({ source: node1, target: node2 });3.x 开始改成了实例化模式包名也变成了jsplumb/browser-uiimport { newInstance } from jsplumb/browser-ui; const plumbInstance newInstance({ container: document.getElementById(container) }); plumbInstance.connect({ source: node1, target: node2 });如果搜索资料时不注意版本很容易把 2.x 和 3.x 的代码混在一起然后得到一堆莫名其妙的报错。我的建议是如果项目是维护老代码优先沿用项目里已用的版本如果是新项目且资料少、团队经验少用 2.15.6 反而更稳妥。因为 2.x 的中文文章、社区案例、踩坑记录远比 3.x 丰富API 也更简单。下面所有示例我都基于 2.x 来写。2.2 必须搞懂的五个核心概念Instance、Endpoint、Connection、Overlay、Anchor很多人一上来就急着写代码结果被各种配置项绕晕。我先用一张表格把这五个核心概念理清楚概念作用类比Instance实例jsPlumb 的管理器负责所有端点、连线的生命周期相当于整个画布的“管家”Endpoint端点挂在节点元素上的连接点连线从这里拉出或接入相当于排插上的插孔Connection连线两个端点之间的一条线可以设置箭头、样式、标签相当于插上排插的电线Overlay覆盖物附加在连线上的视觉元素比如箭头、标签相当于电线上的标签贴纸Anchor锚点端点位于元素上的摆位比如顶部、底部、中心相当于插孔在排插上的位置在代码里Endpoint 一般通过endpoint配置项定义常见的样式有Dot小圆点和Rectangle小方块。Connection 配置里最重要的三个字段是source、target和connector。Anchor 可以用[0.5, 1]这种比例坐标表示也可以直接用Bottom、Top这样的命名位置。这些概念虽然多但理解了它们后面配置起来就很顺。2.3 在 Vue 项目里引入 jsPlumb 的三种方式我在公司项目里见过三种引入方式各有适用场景。第一种是挂在Vue.prototype上import { jsPlumb } from jsplumb; Vue.prototype.$jsPlumb jsPlumb;这种方式写起来方便但问题也很明显Vue 组件的生命周期会导致实例没有统一管理页面跳转后 jsPlumb 内部的 DOM 引用还残留内存泄漏很隐蔽。第二种是在需要用的组件里直接 importimport { jsPlumb } from jsplumb;这种方式比挂全局好因为你可以在组件的beforeDestroy里针对这个模块的实例做清理但如果你有多个页面用同一个 jsPlumb 实例状态容易互相污染。第三种是封装成一个独立的图表组件把 jsPlumb 初始化、连线绘制、事件绑定全部收敛到组件内部对外只暴露nodes和edges两个 props。这也是我觉得最推荐的方式下面专门用一整节来展开。3. 在 Vue 组件中封装 jsPlumb生命周期与渲染时机3.1 初始化时机为什么必须等 DOM 完全渲染jsPlumb 通过元素的id或者selector查找 DOM 节点来挂载端点所以初始化之前目标节点必须已经真实存在于页面上。在 Vue 里mounted钩子虽然表示组件挂载完成但如果你用v-for动态渲染节点列表mounted时这些子节点大概率还没有渲染完。我第一版代码就犯了这个问题在mounted里直接jsPlumb.connect结果控制台报“source element is null”。解决办法是用this.$nextTickmounted() { this.$nextTick(() { this.initPlumb(); }); }如果你在v-for里还嵌套了子组件或者渲染逻辑里依赖了异步数据更稳妥的方式是把初始化逻辑放在数据赋值之后并且用setTimeout(0)再包一层确保布局已经稳定。这不是玄学jsPlumb 读取元素位置的时候如果布局还没 ready算出来的端点坐标就是错的后面拖拽会差几个像素。3.2 封装一个可复用的 FlowChart 组件的思路我最终把 jsPlumb 逻辑封装成了一个FlowChart.vue组件结构大概是这样的template div refcontainer classflow-chart-wrapper div v-fornode in nodes :keynode.id :idnode.id classflow-node :style{ left: node.x px, top: node.y px } {{ node.label }} /div /div /template script import { jsPlumb } from jsplumb; export default { name: FlowChart, props: { nodes: { type: Array, required: true }, edges: { type: Array, required: true } }, data() { return { plumbInstance: null, edgeMap: new Map() }; }, mounted() { this.$nextTick(() { this.initPlumb(); }); }, beforeDestroy() { this.destroyPlumb(); }, methods: { initPlumb() { const plumbInstance jsPlumb.getInstance({ Container: this.$refs.container, ConnectionOverlays: [ [Arrow, { location: 1, width: 12, length: 12 }] ] }); plumbInstance.bind(click, (conn) this.handleConnectionClick(conn)); this.plumbInstance plumbInstance; this.renderEdges(this.edges); }, renderEdges(edges) { edges.forEach((edge) { const conn this.plumbInstance.connect({ source: edge.source, target: edge.target, parameters: { id: edge.id } }); this.edgeMap.set(edge.id, conn); }); }, destroyPlumb() { if (this.plumbInstance) { this.plumbInstance.reset(); this.plumbInstance.destroy(); this.plumbInstance null; } this.edgeMap.clear(); } } }; /script几个关键点jsPlumb.getInstance()创建独立实例而不是用全局的jsPlumb单例。多页面多图互不干扰。Container配置项指定画布容器这样 jsPlumb 自动把连线 SVG 容器挂在这个 div 里样式范围可控。连线数据用parameters挂到 connection 对象上后续从事件回调里能反查业务 id。edgeMap用来维护业务 edge id 和 jsPlumb Connection 对象的映射增删改查都走这份 map。3.3 Vue 响应式数据如何与 jsPlumb 实例解耦这是我在项目里踩得比较深的坑之一。我一开始图省事把 jsPlumb 返回的 Connection 对象直接塞进了 Vue 的 data 里想用:class动态绑定样式。结果页面一卡一卡的选中一个节点要等几百毫秒。原因很简单jsPlumb 的内部对象包含了大量的非响应式属性Vue 的响应式系统会递归遍历它们把它们全部转换成 getter/setter性能和内存都会被拖垮。正确的做法是Vue data 里只维护业务数据nodes 和 edgesjsPlumb 对象只存在普通对象或 Map 里不放进 data。比如上面代码里的edgeMap就没有放在 data 里而是用了普通属性。如果你要使用plumbInstance的某个状态来驱动 UI比如“当前选中的连线 id”那你只需要把selectedEdgeId放进 data连线的高亮样式根据selectedEdgeId动态绑定到业务数据层而不是直接去改 jsPlumb 对象的属性。这样 Vue 的响应式系统不会碰那些庞杂的 jsPlumb 内部对象。4. 数据驱动连线从数组到图表的完整实现4.1 定义节点和连线的数据模型在开始写代码前先定义好数据模型后面所有逻辑都会轻松很多。新手最容易犯的错是把 DOM 元素或坐标直接塞进连线数据比如source: router-1还好但source: document.getElementById(...)就麻烦了。我推荐的最小模型如下nodes: [ { id: node1, label: 启动, x: 120, y: 160 }, { id: node2, label: 支付, x: 360, y: 160 } ], edges: [ { id: edge1, source: node1, target: node2, label: 下一步 } ]id必须全局唯一它同时作为 DOM 元素 id 和 jsPlumb 关联标识。x、y是节点在画布内的坐标渲染时作为绝对定位的left和top。label可以挂在节点上也可以挂在连线上用于 Overlay 展示。4.2 渲染节点、绘制连线的核心代码节点渲染直接用v-for生成 div注意id要和节点 id 一致这是 jsPlumb 识别元素的关键div v-fornode in nodes :keynode.id :idnode.id classflow-node :style{ left: node.x px, top: node.y px } {{ node.label }} /div连线绘制在initPlumb之后调用遍历 edges 数组renderEdges(edges) { const { plumbInstance } this; edges.forEach((edge) { const connection plumbInstance.connect({ source: edge.source, target: edge.target, parameters: { id: edge.id }, overlays: [ [Label, { label: edge.label || , cssClass: flow-edge-label }] ] }); this.edgeMap.set(edge.id, connection); }); }这里我特意在overlays里配置了Label这样连线上能显示文本。如果你还需要箭头在initPlumb配置ConnectionOverlays里已经加了Arrow。这样每个连接都会自动带上箭头。4.3 动态增删节点/连线时如何保持同步实际项目中用户会随时添加、删除节点和连线不能让这些操作只改 DOM 而不改数据。我在FlowChart组件里用watch监听edgesprop 的变化watch: { edges: { deep: true, handler(newEdges, oldEdges) { this.syncEdges(newEdges, oldEdges); } } }, methods: { syncEdges(newEdges, oldEdges) { const { plumbInstance, edgeMap } this; // 删除已经不在新数组里的边 oldEdges.forEach((oldEdge) { if (!newEdges.find((edge) edge.id oldEdge.id)) { const conn edgeMap.get(oldEdge.id); if (conn) { plumbInstance.deleteConnection(conn); edgeMap.delete(oldEdge.id); } } }); // 新增新数组里有但旧数组里没有的边 newEdges.forEach((edge) { if (!edgeMap.get(edge.id)) { const conn plumbInstance.connect({ source: edge.source, target: edge.target, parameters: { id: edge.id }, overlays: [[Label, { label: edge.label || , cssClass: flow-edge-label }]] }); edgeMap.set(edge.id, conn); } }); } }这里要特别注意删除连线不能只从数组里踢掉因为 jsPlumb 内部还保留着这条连接会导致画布上出现“幽灵线”。必须先调用deleteConnection再把业务数组里对应的项删掉顺序不能反。4.4 连线状态高亮、标签和箭头配置连线的视觉状态是业务里很常见的需求比如流程走到哪一步了把当前步骤相关的连线标绿。jsPlumb 里最直接的办法是setPaintStylehighlightEdge(edgeId) { const conn this.edgeMap.get(edgeId); if (!conn) return; conn.setPaintStyle({ stroke: #52c41a, strokeWidth: 3 }); // 如果想给箭头和标签同步变色也要单独 set conn.getOverlay(arrow).setPaintStyle({ fill: #52c41a }); }如果你设置的 Overlay 有 id可以直接用getOverlay(id)拿到。但如果你在初始化时用的是数组形式要拿到具体的 Overlay 可能比较麻烦。我习惯在connect时给关键 Overlay 指定idoverlays: [ [Arrow, { id: arrow, location: 1, width: 12, length: 12 }], [Label, { id: label, label: edge.label || , cssClass: flow-edge-label }] ]这样后续高亮时就能精确操作箭头和文字。5. 交互能力扩展拖拽、点击、右键菜单与缩放平移5.1 节点自由拖拽与连线跟随jsPlumb 的拖拽功能是内建的只需要调用draggable方法this.nodes.forEach((node) { this.plumbInstance.draggable(node.id); });调用之后节点就能在画布内被鼠标拖着走连线会跟着自动重算。但你还需要把拖拽结果同步回 Vue 数据否则刷新页面后节点又回到初始位置。我在initPlumb里绑定了drag事件this.plumbInstance.bind(drag, (event) { const nodeId event.el.id; const node this.nodes.find((item) item.id nodeId); if (node) { node.x event.pos[0]; node.y event.pos[1]; } });注意事件对象里event.pos是一个数组第一位是 x第二位是 y。如果你不想每次拖拽都触发 Vue 的更新可以在dragstop里一次性更新坐标拖动过程中只改元素样式不动数据性能会好很多。5.2 连线点击、双击和右键事件的绑定连线交互是流编辑器的核心。jsPlumb 提供了一组事件绑定方法click点击连线dblclick双击连线contextmenu右键连线2.x 用contextmenu事件绑定例如我想实现点击连线时把该连线标记为选中双击删除this.plumbInstance.bind(click, (conn) { this.selectedEdgeId conn.parameters.id; }); this.plumbInstance.bind(dblclick, (conn) { this.removeEdge(conn.parameters.id); });右键菜单我一般不用原生系统菜单而是在回调里手动弹一个自定义div。注意要在contextmenu回调里调用event.preventDefault()否则系统菜单会弹出来。点击空白区域时关闭菜单可以监听画布容器的click事件。5.3 实现画布缩放与平移的两种方案缩放和平移是图编辑产品里很常见的需求但 jsPlumb 社区版对这块支持有限我一开始天真地给最外层 div 加transform: scale(0.8)结果节点确实缩小了但 jsPlumb 计算端点位置的逻辑立刻错乱连线对不上节点。比较稳妥的做法有两种。方案一父容器滚动 坐标偏移实现平移。把画布容器放在一个overflow: auto的父容器里画布本身足够大平移靠滚动条完成。这种方案不改任何坐标jsPlumb 完全无感缺点是滚动条样式比较生硬用户体验一般。方案二用 jsPlumb 的setZoom方法实现缩放。2.x 的 BrowserJsPlumbInstance 提供了setZoom但它是整体缩放 SVG 容器不会改变 DOM 节点的 CSS 大小所以你需要同时给节点元素做 CSS 缩放两边保持一致。代码大致是this.plumbInstance.setZoom(0.8); this.$refs.container.style.transform scale(0.8);这种方法需要小心处理 transform 原点中心布局和对齐问题我实际用下来在小范围缩放0.6-1.2内效果还行但超过这个范围连线的粗细和箭头位置会有偏差。如果项目需要复杂的缩放、平移、小地图建议尽早引入成熟的画布基础框架不要自己硬掰。6. 踩坑实录Vue jsPlumb 最容易翻车的 5 个场景6.1 页面切换后 jsPlumb 实例残留导致的内存泄漏这个问题在 SPA 里极其常见。Vue 组件销毁了但 jsPlumb 实例还活着它内部注册的事件监听还指向已经销毁的 DOM 节点。表现就是页面切换回来再切走内存只增不减或者切回页面时控制台报错 “Cannot set property of undefined”。解决方案很固定在组件销毁前做三件事beforeDestroy() { this.plumbInstance.bind(dblclick, null); this.plumbInstance.deleteEveryConnection(); this.plumbInstance.reset(); this.plumbInstance.destroy(); this.plumbInstance null; }reset会清除所有端点、连线、拖拽绑定destroy会把实例内部生成的事件监听一并清理掉。顺序不能乱先删连线再复位再销毁。6.2 元素被销毁后连接线“幽灵连线”问题这个现象是你明明把 nodes 数组里的某个节点删了数据上也看不到了但画布上还挂着几条断了的线或者连接到已经不存在的元素上的线。原因在于 jsPlumb 内部维护着自己的节点和连线注册表你直接删掉 DOM 元素或从 Vue 数组里移除jsPlumb 并不知道。所以删除节点前要先找到所有连到该节点的 connection并逐个删除removeNode(nodeId) { // 找到所有连接这个节点的 connection const connections this.plumbInstance.select({ source: nodeId }).concat( this.plumbInstance.select({ target: nodeId }) ); connections.forEach((conn) { const edgeId conn.parameters.id; this.plumbInstance.deleteConnection(conn); this.edgeMap.delete(edgeId); }); // 再从业务数据里移除 this.nodes this.nodes.filter((node) node.id ! nodeId); }如果你偷懒直接刷新整个 nodes 数组也需要先调用deleteEveryConnection()清空旧连线再重新渲染。否则很容易出现幽灵线。6.3 组件更新时 jsPlumb 与 Vue 的渲染冲突Vue 更新 DOM 的方式是 diff 算法如果节点列表用index作为key那么删除一个节点后后面节点的 DOM 引用会悄悄转移jsPlumb 还在按旧 id 找元素自然就乱了。所以v-for渲染节点时必须用业务 id 作为 keydiv :keynode.id :idnode.id另一个问题是如果你在 jsPlumb 已经把某些 div 设置为可拖拽后又通过 Vue 的v-if从新渲染了这组 divjsPlumb 的拖拽绑定会失效或错乱。遇到这种情况最保险的做法是重新初始化整个 jsPlumb 实例而不是尝试一段段修补。6.4 拖拽导致的高频更新如何做防抖我在 5.1 提到过drag事件会在拖拽过程中高频触发通常是每帧一次。如果每次都在这个事件里更新 Vue dataVue 会疯狂地触发重渲染整个画布跟着卡顿。我的优化方案是把“数据同步”延后到dragstop事件里this.plumbInstance.bind(drag, (event) { // 只是在 DOM 上做轻量处理不碰 Vue data }); this.plumbInstance.bind(dragstop, (event) { const nodeId event.el.id; const node this.nodes.find((item) item.id nodeId); if (node) { node.x event.pos[0]; node.y event.pos[1]; this.$emit(node-moved, { id: nodeId, x: node.x, y: node.y }); } });这样拖拽过程中 Vue 完全不介入松手后再同步数据性能提升非常明显。如果你需要拖拽过程中实时显示坐标可以用requestAnimationFrame做节流但终归不如只在结尾同步来得干净。6.5 与 Element UI / Ant Design 组件混用时的样式干扰jsPlumb 会在画布容器里生成一个svg元素并且在节点上附加一些内部类名比如jtk-draggable、jtk-endpoint。如果你在节点内部放了 Element UI 的 input 或 button可能会遇到两个问题。第一个是点击穿透。jsPlumb 的端点和连线是 SVG 元素默认会挡住底下 DOM 的点击解决办法是给画布容器里的 svg 设置pointer-events: none但这样又会导致连线点不中。更好的办法是给节点内部的交互元素设置z-index并把pointer-events: auto同时给节点容器设置position: relative。第二个是拖拽和表单控件冲突。如果你在节点内部放了一个 input用户想选中文字或点击下拉框却触发了节点拖拽。解决办法是给节点设置handle配置只允许拖拽把手区域this.plumbInstance.draggable(node.id, { handle: .node-drag-handle });然后在节点模板里把拖拽把手放在一小块固定区域其他内部控件的点击就交给原生行为。这个配置看起来不起眼但极大提升了节点内部交互的体验。7. 如果重新做一次架构上的几点回看写了这么多最后说点真正从项目里沉淀下来的判断。如果现在让我重新设计这个流程编排模块我会在一开始就做三件事第一把 jsPlumb 初始化、渲染、销毁全部收进一个独立的画布组件业务组件只负责传nodes和edges对外只暴露几个事件比如node-click、edge-click、node-moved。这样后期迁移到 Vue 3 或者换库业务层几乎不用动。第二数据模型从一开始就要严格区分“业务数据”和“视图数据”。节点坐标、连线关系属于业务数据应该保存在 Vuex 或 Pinia 里端点对象、Connection 对象、SVG 元素属于视图数据只存在于组件内部。两者用 id 关联尽量不要混着操作。第三复杂交互缩放、撤销、多选、小地图如果在需求初期就被提出来不要指望 jsPlumb 社区版全都能轻松实现。能言简意赅地评估工作量然后决定是自研还是引入 G6/X6比做了几周后再推翻重来要划算得多。jsPlumb 确实不是万能的最佳选择但在“Vue 中轻度连线图”这个组合下它是我目前用过的最顺手、最不折腾的方案。上面这些经验尤其是生命周期清理和数据解耦的部分希望你能在动手前就避开。真踩进去了也别慌按这几节的方法排查基本都能收回来。
返回列表