ARTICLE DETAIL

资讯详情

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

Vue项目中的JsPlumb连线实战:节点拖拽、配置与数据回显全解析

Vue项目中的JsPlumb连线实战:节点拖拽、配置与数据回显全解析 JsPlumb这个库做前端可视化的人多少都听过。它最核心的能力就是把两个DOM节点用一根可拖拽的线连起来流程图、拓扑图、工作流编排这类需求基本都绕不开它。我在Vue项目里前后折腾过两轮jsPlumb第一次是Vue 2 jsPlumb 2.x第二次是Vue 3 jsPlumb 2.15每次都会碰到一些文档里写得含糊、不踩坑根本发现不了的细节。这篇文章就把我在Vue项目中使用jsPlumb的完整总结写下来从依赖引入、实例初始化、节点拖拽、连线配置到数据序列化再到各种坑的排查方法一次性说清楚。1. 整体思路拆解为什么在Vue里用jsPlumb会这么别扭先聊点底层的东西。jsPlumb这个库的本质是命令式DOM操作它自己在页面上创建SVG或者Canvas图层然后把端点、连线这些元素挂上去。Vue的核心是声明式渲染和响应式数据流两者天然不是一套思维模式。所以你在Vue里用jsPlumb最需要想清楚的一件事是谁负责节点的渲染谁负责连线的绘制。我的做法是节点依然用Vue的v-for渲染jsPlumb只负责两件事让节点可拖拽、维护节点之间的连线关系。这样数据流是单向的Vue管理节点的增删改jsPlumb管理连线两边不互相抢饭碗。还有一个必须提前想清楚的问题是用全局单例jsPlumb还是每个组件单独建一个实例。全局单例第一次写的时候确实爽随便哪个组件里都能拿过来用但项目一复杂就完蛋。两个页面共用一个画布容器会串数据页面销毁了实例还挂着内存泄漏排查起来能怀疑人生。我后来一律采用每个业务组件独立维护一个jsPlumb实例并且约定好实例的生命周期mounted里创建组件卸载前销毁。后面会细说。再一个容易忽略的点是版本。jsPlumb的API从2.x到5.x变化非常大网上很多教程还在写jsPlumb.connect但新版社区版的写法已经完全不一样了。我建议新项目用2.15.x这是社区版比较稳定的一代API文档和社区资料都最多遇到问题也好搜。5.x虽然新但改动大Vue相关的现成方案少没必要当小白鼠。1.1 核心需求解析一个典型的可视化连线场景把需求具象化一点。我做的那个项目是可视化流程编排左侧是节点库中间是画布右侧是属性面板。用户从左侧拖一个节点到画布上画布上的节点可以自由拖动节点之间通过连线表达依赖关系。点击某条连线右侧面板显示连线的属性可以改名称、改样式、删除连线。这个需求拆出来核心就四块节点的增删改查这个Vue组件加一个数组状态就能搞定。节点在画布上的自由拖拽jsPlumb的draggable方法。节点之间的连线与重新连接jsPlumb的addEndpoint、connect方法和相关事件。整个画布数据的保存与回显把节点坐标和连线关系序列化成JSON下次进入页面再还原。这四个能力如果纯手写绝对会崩溃但jsPlumb里有对应的核心API只是文档里藏得比较深。下面我把每一块的具体实现和参数选择都展开讲。2. 依赖引入与Vue组件的初始化配置先讲最简单的部分引入。npm安装没什么悬念npm install jsplumb2.15.6Vue 2里你可以在组件里直接import { jsPlumb } from jsplumb然后挂在data上或者直接挂在一个模块级变量里。Vue 3里也一样import之后在onMounted里初始化。这里有一个细节jsPlumb的ESM导出在Vite和Webpack下的行为不太一样。Webpack项目里import { jsPlumb } from jsplumb通常没问题Vite项目偶尔会碰到模块解析异常这时候可以改成import jsPlumbLibrary from jsplumb; const jsPlumb jsPlumbLibrary.jsPlumb || jsPlumbLibrary;原因是一些版本的jsPlumb打包产物是UMD格式Vite的预构建可能会把它包一层导致默认导出和命名导出的映射对不上。这个兼容写法在两个打包器下都能跑通。2.1 组件结构设计与ref绑定我的组件结构大致是这样template div refcontainer classflow-container div v-fornode in nodeList :keynode.id :idnode.id classflow-node :style{ left: node.x px, top: node.y px } {{ node.label }} /div /div /template几个关键点最外层的容器ref一定要绑定好jsPlumb实例化的时候需要把container传进去让jsPlumb知道所有连线和端点都绘制在这个容器内部这样才不至于出现连线跑到画布外面去的情况。每个节点必须有唯一的id而且这个id要稳定。jsPlumb通过id来管理端点和连线关系id一变整张图的连线全部需要重建。节点用绝对定位放在容器内坐标从数据数组里来。这一步很重要因为后面做数据回显时需要记录每个节点的坐标。初始化代码我放在onMounted里onMounted(() { instance jsPlumb.getInstance({ Container: containerRef.value }); // 允许节点拖拽 nodeList.value.forEach(node { instance.draggable(node.id, { containment: containerRef.value, grid: [10, 10] }); }); // 给节点添加端点 nodeList.value.forEach(node { setupEndpoints(node.id); }); });getInstance每次都要调用不要直接使用全局的jsPlumb对象。getInstance会返回一个独立的实例不同实例之间的端点和连线互不干扰这对组件化是必须的。我见过有人图省事直接用全局jsPlumb结果组件A的连线跑到组件B的画布上排查了一下午。2.2 销毁逻辑最容易忽略的一环Vue组件销毁时jsPlumb的清理是一个重点。很多人只记着创建忘了销毁结果就是组件切走再切回来画布上多出很多幽灵端点和线条。我的销毁代码onBeforeUnmount(() { if (instance) { instance.reset(); instance null; } });reset()方法会清空实例上所有的端点、连线和拖拽绑定。注意这里不要只调用deleteEveryEndpoint()因为deleteEveryEndpoint只清端点reset更彻底会把整个实例恢复到初始状态包括事件绑定都清掉。还有一个容易忽略的点如果你在节点上监听了jsPlumb的dblclick之类的事件销毁时也要把这些事件解除否则节点DOM被Vue回收了事件回调里还在操作jsPlumb实例很容易出现“Cannot read properties of null”之类的报错。我通常会在组件卸载时对节点统一执行一次事件解绑nodeList.value.forEach(node { instance.unbind(click, node.id); instance.unbind(dblclick, node.id); });当然如果你统一调用reset这一步其实可以省略但如果你在项目中用了两个不同生命周期的jsPlumb实例比如一个画布实例、一个用于缩略图的实例那么事件解绑还是要做的。3. 节点端点配置与连线操作细节端点这一个概念是jsPlumb进阶使用绕不开的坎。简单理解端点是连接线在节点上的锚点一条连线有两个端点分别是source和target。3.1 端点类型与样式参数端点类型我用得最多的两种Dot圆点和Rectangle矩形。Dot比较常用视觉上就是个可以拖出来的小圆点用户看到小圆点就知道可以连线。Rectangle适合做端口面比如设备上的网口。初始化端点的核心代码const endpointOptions { endpoint: Dot, anchor: [Perimeter, { shape: Rectangle }], paintStyle: { fill: #5679AD, outlineStroke: #28324e, outlineWidth: 1 }, isSource: true, isTarget: true, maxConnections: -1, connector: [Bezier, { curviness: 30 }], connectorStyle: { stroke: #5679AD, strokeWidth: 1.5, outlineStroke: transparent, outlineWidth: 4 }, connectorOverlays: [ [Arrow, { width: 12, length: 12, location: 0.5 }] ], dropOptions: { tolerance: touch, hoverClass: node-hover } }; instance.addEndpoint(nodeId, endpointOptions, parentElement);这里逐个说下为什么这么配anchor我常用Perimeter加矩形shape这样端点会自动分布在节点四周不会挤在一个点。默认的Continuous锚点会随着两个节点相对位置变化而自动调整端点位置视觉上更灵活但如果你需要端口固定就必须用Perimeter或固定坐标锚点。maxConnections: -1代表不限制连接数。如果你希望一个节点只能出一个线那就设置maxConnections: 1但要注意这个限制是对“从这个端点出发的连接”而言不是对整根线。connector选Bezier可以做平滑曲线视觉体验好如果节点很多连线很密用Straight性能更好。curviness控制曲线弯曲程度值越大弯得越厉害一般20到40之间比较合适。connectorOverlays里的Arrow是箭头的关键配置location: 0.5表示箭头在连线的中点如果需要箭头指向目标节点通常设置成location: 1会更好我在实际项目里习惯把箭头放在0.5的位置因为中间有个半透明的按钮用于删除连线。3.2 编程式连接与拖拽连接连线的创建有两种方式用户拖拽或者代码连接。用户拖拽的场景配置好isSource: true和isTarget: true之后jsPlumb会自动处理。如果需要在用户建立一条连接后做业务处理比如弹属性面板、存储数据要监听connection事件instance.bind(connection, (info) { // info.sourceId: 源节点id // info.targetId: 目标节点id // info.connection: 连接对象 saveConnection(info); });如果是代码连接比如回显数据时重建连线instance.connect({ source: sourceId, target: targetId, anchors: [Right, Left] });这里注意connect方法里传的anchors是数组形式分别对应source和target的锚点位置。如果你在addEndpoint时已经指定了anchor这里可以不传直接用端点的默认锚点。我自己在实际项目里遇到一个比较麻烦的问题用户把A节点连到B节点后想把这条线改绑到C节点。jsPlumb的事件体系里connectionDetached事件就是用来监听连线被断开的。当你监听到断开事件后需要同步更新数据数组。我的实现是把连线关联的数据存在一个links数组里每次connection和connectionDetached都去维护这个数组这样数据始终和画布保持一致回显时直接遍历links重建连接。3.3 连线样式的动态修改连线能不能被点选并高亮这个需求很常见。jsPlumb对已有的连接做样式修改最常用的方法// 设置选中态 connection.setPaintStyle({ stroke: #ff5722, strokeWidth: 2.5 }); // 设置未选中态 connection.setPaintStyle({ stroke: #5679AD, strokeWidth: 1.5 });监听连线点击jsPlumb 2.x里是click事件可以直接绑定在connection上instance.bind(click, (connection, event) { // 先把所有连线恢复成默认样式 allConnections.forEach(c c.setPaintStyle(defaultStyle)); // 再把当前点击的连线高亮 connection.setPaintStyle(activeStyle); currentSelectedConnection connection; });再配合一个删除按钮需要找到当前连线的sourceId和targetIdconst sourceId connection.sourceId; const targetId connection.targetId; instance.deleteConnection(connection);到这里节点上连接线的增删改查基本闭环了。下面讲一个更深入的问题数据如何保存与回显。4. 画布数据持久化与回显可视化画布通常需要保存用户画的图下次进来还能恢复。jsPlumb本身不提供序列化方案需要我们自己手动处理。我的做法是把节点数据和连线数据分别存储保存时统一成一个JSON对象。4.1 节点数据的保存节点数据相对简单遍历nodeList把每个节点的id、类型、x坐标、y坐标以及其他业务字段存下来就行。坐标获取方式const nodeData nodeList.value.map(node { const pos instance.getOffset(node.id); return { id: node.id, type: node.type, x: pos.left, y: pos.top, label: node.label }; });getOffset返回的是节点相对container的偏移量。这里有一个版本差异旧版本可能用getPosition但2.x以后推荐用getOffset。4.2 连线数据的保存连线数据需要记录sourceId、targetId可能还有sourceEndpointId和targetEndpointId。我一般只存sourceId和targetId因为如果每个节点只有一个source端点和一个target端点这俩就够用了。如果节点有多个端点就必须把endpointId也存下来否则回显时不知道线具体接在那个端口上。const linkData linkList.value.map(link ({ sourceId: link.sourceId, targetId: link.targetId, sourceEndpointId: link.sourceEndpointId, targetEndpointId: link.targetEndpointId }));4.3 数据回显的顺序问题回显是最容易出bug的地方因为顺序错了就是满屏错乱。我的回显流程先根据节点数据生成所有节点divVue渲染。等DOM渲染完成后nextTick再初始化jsPlumb实例。给所有节点添加draggable。给所有节点addEndpoint。遍历linkData用connect方法重建连线。步骤2和4之间一定要await nextTick()否则getInstance执行的时候节点还没有渲染到DOM上jsPlumb找不到节点id什么都不会发生。这里还有一个小坑如果你用v-for渲染节点节点的id里有变量拼接比如node-${dataIndex}那么回显时id必须和保存时一致。我有一次把id格式从node_1改成了node-1结果所有旧数据全部连不上线排查了半天才发现是id不一致。5. 响应式联动与节点拖拽的坑Vue和jsPlumb结合的另一个难点是响应式。Vue数据变了DOM会更新但jsPlumb不知道。反过来jsPlumb把节点拖走了Vue的data还不知道。5.1 拖拽事件同步数据节点拖拽结束后需要把新坐标写回Vue的data里否则一刷新或者做任何会导致重新渲染的操作节点就会回到原位。instance.bind(dragend, (info) { const node nodeList.value.find(n n.id info.el.id); if (!node) return; const pos instance.getOffset(info.el.id); node.x pos.left; node.y pos.top; });注意dragend回调里info.el是什么在jsPlumb 2.x里这个el是原生DOM元素所以info.el.id可以直接获取节点id。5.2 Vue响应式数据驱动的节点增删画布右侧的节点列表里用户点击“添加节点”需要往nodeList里push一个新对象。这个时候Vue会自动渲染出新的DOM但jsPlumb并不知道有新节点。所以在mutation之后的nextTick里要手动对新节点做一遍初始化function addNode(type) { const newId node_${Date.now()}; nodeList.value.push({ id: newId, type, x: 100 Math.random() * 300, y: 100 Math.random() * 200, label: type }); nextTick(() { instance.draggable(newId, dragOptions); setupEndpoints(newId); }); }删除节点更复杂因为牵扯到连线的清理。删除一个节点时它上面的所有连线应该同时被删除否则会留下悬挂的线。我的做法function removeNode(nodeId) { // 删除与这个节点相关的连线 const removedLinks linkList.value.filter( link link.sourceId nodeId || link.targetId nodeId ); removedLinks.forEach(link { const connection findConnection(link.sourceId, link.targetId); if (connection) instance.deleteConnection(connection); }); // 删除节点上的所有端点 instance.removeAllEndpoints(nodeId); // 最后删除节点数据让Vue从DOM中移除 nodeList.value nodeList.value.filter(n n.id ! nodeId); // 同步更新links数组 linkList.value linkList.value.filter( link link.sourceId ! nodeId link.targetId ! nodeId ); }instance.removeAllEndpoints(nodeId)会把节点上挂的所有端点清掉相当于把源和目标都断干净了。有些教程推荐instance.remove(nodeId)这个方法会同时删掉节点DOM和它所有的端点但既然节点DOM是Vue管的我用removeAllEndpoints就够了避免两个框架操作同一个DOM产生冲突。5.3 拖拽与缩放画布如果你需要整张画布的缩放比如做一个缩放百分比的控制条jsPlumb本身不提供内置缩放需要自己用CSS transform对container做缩放同时把缩放比例告诉jsPlumb的拖拽计算。这块我踩过很深的坑。直接用transform缩放container之后节点的拖拽坐标会错位因为jsPlumb计算坐标时用的是原生offset而transform会改变视觉位置。jsPlumb 2.15.6提供了一个setZoom方法但需要配合一些配置使用。我的做法其实是先不做transform缩放而是通过修改节点的left/top坐标实现“假缩放”。具体来说维护一个scale变量节点移动时把真实坐标和显示坐标分开计算。这个方案代码量会大一点但坐标体系完全在Vue的控制下不容易出偏差。如果一定要做transform缩放可以参考jsPlumb官方issue里的方案在drag过程中把event.clientX和event.clientY除以scale再叠加container的边界rect。这里没有银弹都需要针对自己的布局结构调。6. 常见问题与排查技巧实录这块重点讲我实际遇到并且花了很长时间才定位的问题希望你能直接绕开。6.1 连线错位位置偏移量不一致症状节点在画布里显示是A坐标但连线的端点却在另一个位置看起来就像连线没长在节点上。原因jsPlumb初始化的时机太早或者container有CSS动画。jsPlumb在计算锚点位置时依赖DOM的offset如果container或者节点在初始化后发生了位移比如父容器有margin、padding或者transform动画jsPlumb的坐标计算就会对不上。解决方案确保getInstance时container已经渲染完毕并且布局稳定。container不要使用transform动画尤其是刚开始时。如果节点拖拽时连线错位可以在drag之后调用instance.repaint()或者repaintEverything()。instance.bind(drag, () { instance.repaintEverything(); });repaintEverything性能开销不小但在节点数量不多比如一百个以内时完全够用拖拽时线条跟手很关键。6.2 节点DOM更新后jsPlumb拾取的还是旧的DOM这个问题的典型场景是节点内部有一小段文字用户改文字后节点变宽了但连线还是连在原来宽度的位置上。原因jsPlumb缓存了节点的尺寸和端点位置。DOM尺寸变了缓存没更新。解法在文本更新后主动触发重排nextTick(() { instance.recalculateOffsets(containerRef.value); instance.repaintEverything(); });recalculateOffsets会重新计算所有节点的偏移量这是处理尺寸变化后的标准操作。6.3 v-for渲染的节点发现jsPlumb操作的是空对象这个问题20%来自id重复80%来自初始化时机太早。id重复会导致jsPlumb把多个节点当成同一个操作第二个的时候完全错乱。排查方法很笨但有效在初始化之前打印所有节点的id看看有没有重复。const ids nodeList.value.map(n n.id); console.log(new Set(ids).size ids.length);6.4 快速切换页面时控制台报错Uncaught TypeError场景在组件A里创建了jsPlumb实例还没等画布完全加载完毕用户就切换到了组件B。此时组件A的onBeforeUnmount执行了reset但jsPlumb内部的异步回调还在跑访问已经清空的DOM节点就报错了。解决在初始化时加一个指令式的“锁”。let destroyed false; onBeforeUnmount(() { destroyed true; instance.reset(); instance null; }); // 在异步回调、事件回调里统一做判断 instance.bind(connection, (info) { if (destroyed) return; // 业务逻辑 });这个习惯养成之后很多偶发的报错都会消失。6.5 触摸屏设备上节点拖不动jsPlumb 2.x对触摸事件的支持需要额外开启。如果你要兼容平板初始化实例时加上touch支持配置const instance jsPlumb.getInstance({ Container: containerRef.value, DragOptions: { touch: true } });具体属性名在不同版本略有差异2.15.6里DragOptions.touch: true是有效果的。如果还是不行检查一下节点上有没有touch-action: none在有拖拽的容器上一定要加上.flow-container { touch-action: none; }这是非常关键的一条CSS。如果不加移动端浏览器会默认把触摸手势当作页面滚动jsPlumb的拖拽根本抢不过浏览器。6.6 连线数量多之后操作卡顿优化手段我常用三个。第一个是在初始化时设置Connector: [Straight, { gap: 5 }]直线比Bezier曲线计算量小很多节点一多差距非常明显。第二个是批量操作时用batch包裹instance.batch(() { // 批量增加/删除连线 });batch会暂停jsPlumb的自动重绘等所有操作执行完成后再一次性重绘性能提升很显著。第三个是减少不必要的repaint。像拖拽过程中高频触发的事件里不要在回调里做复杂的Vue数据更新否则卡顿的感觉会很明显。6.7 数据回显上百条连线时的渲染时序当你一次connect几十上百根线时jsPlumb的动画状态可能会出现断线或端点闪烁。解决方法是配置初始化参数里把动画临时关掉const instance jsPlumb.getInstance({ Container: containerRef.value, connectionsDetachable: true, reattachConnections: true, endpointHoverEnabled: false });endpointHoverEnabled: false会让节点在hover时不做高亮动画减少大量连线同时渲染的GPU压力。回显完成后如果需要再动态开启。7. 性能数据与最终体会做这个可视化流程项目的时候我专门测过不同节点数量下的性能表现。单个jsPlumb实例下50个节点、40条连线正常拖拽毫无压力到150个节点、130条连线时拖拽已经隐约觉得卡初始化耗时大概在900ms左右到300个节点以上vis.js或者G6这类基于canvas的方案会是更好的选择。jsPlumb的强项还是偏中低数量级的流程图节点数量在100以内时它的开发效率和灵活度是最好的。还有一个实测的经验初始化时Container一定要给一个明确的尺寸。如果容器高度是autojsPlumb计算锚点位置时可能拿到0导致线条全部堆在画布左上角。给容器加上min-height: 600px这类固定尺寸能从根本上避免这个诡异问题。最后分享一个小技巧。jsPlumb的连线数据如果存在后端注意在保存前对连线做一次“去重”。用户快速拖拽连线时偶尔会因为鼠标抖动而产生两条几乎重叠的连线肉眼不容易发现但回显的时候会看到两条线叠在一起很碍眼。我的做法是在connection事件里检查是否已经存在同样sourceId和targetId的连线如果存在就直接删除新产生的这条instance.bind(connection, (info) { const duplicate linkList.value.find( link link.sourceId info.sourceId link.targetId info.targetId ); if (duplicate) { instance.deleteConnection(info.connection); return; } // 正常保存逻辑 });这个方法简单但极其好用我后来在所有涉及jsPlumb连线的项目里都保留了这段逻辑。总的使用体验是jsPlumb旧版本的API设计虽然不那么符合现代前端框架的直觉但它在“DOM节点相互连线”这个垂直场景里做得足够深。只要摸清它的生命周期、事件机制和重绘机制跟Vue配合起来完全可以做得流畅又稳定。如果你正在做类似的流程图、拓扑图项目希望这篇文章能帮你少走弯路。
返回列表