简介:这是一份面向Web前端开发者与流程设计初学者的jsPlumb+jQuery流程图设计实例资源,重点解决在网页中快速搭建可拖拽、可连线交互式流程图的问题,适合具备一定JavaScript与DOM基础、希望掌握可视化连接器用法的读者参考。压缩包共216个文件,约984KB,以148张png图片、24个js脚本、20个gif动图为主,辅以css样式、config配置及少量htm、aspx页面文件,图片与动图多用于展示节点样式和交互效果,脚本则承载连接器与端点逻辑。资源围绕jsPlumb初始化、端点创建、连接建立、事件监听及样式扩展等核心环节展开,并附带flowchartDemo-master示例工程,读者可据此理解拖放布局、Bezier曲线连接、箭头覆盖物与自定义样式的实现方式,进而将流程图与业务数据结合,完成动态更新与交互优化。目前已有724人学习下载,适合作为流程图模块开发与二次定制的实践参考。
1. jsPlumb+jquery 流程图设计实例:为什么老技术栈反而更适合做内部工具
上周帮一个做后端的朋友改他们内部的工单流转配置页,打开一看,一个拖拽连线画流程的界面,用的是 jsPlumb 加 jQuery。我第一反应是这组合有点年头了,但看完代码我改口了——整个页面不到 600 行,没有构建步骤,没有 npm install,双击 HTML 就能跑,业务同事自己改两行 JSON 就能加一个审批节点。这就是 jsPlumb+jquery 流程图设计实例这类需求真正的价值场景:不是做 Figma 那种级别的编辑器,而是给内部系统塞一个「能拖、能连、能存」的轻量流程画布。
jsPlumb 负责的是节点之间的连线、锚点、拖拽重绘,jQuery 负责的是 DOM 操作和事件绑定,两者分工明确。它适合的人群很具体:手上有一个后台管理系统,需要让运营或实施人员自己配置流程走向,团队里没有专门的前端工程化投入,又不想引入 React Flow 这类需要打包链路的方案。这篇文章我会把从零搭一个可用的流程图设计器拆开讲,包括锚点怎么设、连线数据怎么存、删除节点时怎么清理连线这些真正会卡住人的地方。
2. 用 jsPlumb 搭出第一个可拖拽节点画布
2.1 为什么选 jsPlumb 而不是自己写 SVG 连线
自己用 SVG 或 Canvas 画连线,核心难点不在画线,而在「节点移动时连线跟着动」和「连线端点吸附到节点边缘」。这两件事背后是坐标计算、事件监听、重绘时机的组合问题。节点一多,手动维护的坐标映射表就会变成一团乱麻。jsPlumb 把这部分封装成了 Endpoint(端点)和 Anchor(锚点)两个概念,你只需要声明「这个节点从哪几个位置可以连线」,剩下的跟随重绘它自己处理。
另一个现实原因是 jQuery 生态。很多存量后台系统的 DOM 操作、事件委托、AJAX 请求都是 jQuery 写的,引入 jsPlumb 不需要改造现有代码结构,它本身就是基于 DOM 元素 id 工作的。你给一个 div 设了 id,调一句jsPlumb.addEndpoint就能让它变成可连线的节点。这种「零侵入」在存量系统里比任何新框架都值钱。
常见做法是:页面引入 jquery.min.js 和 jsplumb.min.js 两个文件,节点用绝对定位的 div 渲染,容器设position: relative。这里有个容易翻车的点——容器如果设了overflow: auto,节点拖到边缘时连线会错位,因为 jsPlumb 计算的是相对容器的偏移,滚动会打乱这个基准。
2.2 最小可运行骨架:HTML 结构加初始化代码
先看能跑起来的最小结构。节点容器、节点本身、初始化脚本三部分,缺一不可。
<!-- 流程画布容器,必须 relative 定位 --> <div id="flow-canvas" style="position:relative; width:100%; height:600px; border:1px solid #ddd;"> <!-- 节点:绝对定位,id 唯一,jsPlumb 靠 id 识别 --> <div class="flow-node" id="node-start" style="left:60px; top:80px;">开始</div> <div class="flow-node" id="node-approve" style="left:280px; top:80px;">审批</div> <div class="flow-node" id="node-end" style="left:500px; top:80px;">结束</div> </div> <script src="jquery.min.js"></script> <script src="jsplumb.min.js"></script> <script> $(function () { // 全局配置:连线样式、端点样式统一在这里设,避免每个节点重复写 jsPlumb.importDefaults({ Connector: ['Bezier', { curviness: 50 }], // 贝塞尔曲线,弯曲度 50 PaintStyle: { stroke: '#5b9bd5', strokeWidth: 2 }, EndpointStyle: { radius: 5, fill: '#5b9bd5' }, Endpoint: ['Dot', { radius: 5 }], Anchors: ['Right', 'Left'] // 默认从右侧出、左侧进 }); // 让所有 .flow-node 可拖拽 jsPlumb.draggable($('.flow-node')); // 给每个节点加两个端点:右侧输出、左侧输入 $('.flow-node').each(function () { jsPlumb.addEndpoint(this, { anchor: 'Right', isSource: true, isTarget: false, uuid: this.id + '-out' }); jsPlumb.addEndpoint(this, { anchor: 'Left', isSource: false, isTarget: true, uuid: this.id + '-in' }); }); }); </script>这段代码的逻辑说明:importDefaults设的是全局默认值,后面每个端点如果不单独指定就继承这套配置。draggable让节点可以拖动,jsPlumb 会自动在拖动时重绘连线。addEndpoint给每个节点加了两个端点,isSource表示能拉出线,isTarget表示能接入线,uuid是端点的唯一标识,后面存数据要靠它。
参数上最需要调的是curviness,它控制贝塞尔曲线的弯曲程度。值太小线看起来是直的,值太大线会绕得很夸张。我一般设 30 到 80 之间,节点间距大就调大一点。Anchors数组的顺序有讲究,第一个是默认源锚点,第二个是默认目标锚点,写反了会导致连线方向不对。
2.3 锚点位置与连线方向:四个参数决定交互手感
锚点决定了线从节点的哪个边出来。jsPlumb 内置了九个位置:Top、Right、Bottom、Left、TopRight、BottomRight、BottomLeft、TopLeft、Center。实际做流程图,最常用的是 Right 出、Left 进,形成从左到右的阅读流。
但真实业务里经常需要「驳回」这种反向连线,也就是从右边节点连回左边节点。这时候如果目标节点只有 Left 锚点,线会绕一大圈。解决办法是给节点加多个锚点,比如同时加 Left 和 Top,让用户自己选从哪进。
// 给审批节点加三个锚点:右侧输出、左侧输入、顶部输入(用于驳回) var approveNode = document.getElementById('node-approve'); jsPlumb.addEndpoint(approveNode, { anchor: 'Right', isSource: true, isTarget: false, uuid: 'node-approve-out', maxConnections: -1 // -1 表示不限制连出数量 }); jsPlumb.addEndpoint(approveNode, { anchor: 'Left', isSource: false, isTarget: true, uuid: 'node-approve-in' }); jsPlumb.addEndpoint(approveNode, { anchor: 'Top', isSource: false, isTarget: true, uuid: 'node-approve-top-in' });maxConnections这个参数值得单独说。默认值是 1,意味着一个端点只能连一条线。做流程图时,一个审批节点往往要连出多条线(比如通过、驳回、转办),所以源端点必须设成 -1。但目标端点通常保持默认 1 就行,避免多条线挤在同一个入口,视觉上会很乱。
还有一个参数是connectorOverlays,用来在连线上加标签。流程图的连线经常需要标注条件,比如「金额大于 5000」。这个后面存数据时会一起讲。
3. 把连线数据存下来:从内存对象到后端 JSON
3.1 监听 connection 事件,拿到连线的两端
画布上的线是 jsPlumb 内部维护的,页面刷新就没了。要持久化,必须在连线建立和删除时把数据抓出来。jsPlumb 提供了connection和connectionDetached两个事件。
// 连线建立时触发,info.connection 是连线对象 jsPlumb.bind('connection', function (info) { var conn = info.connection; var sourceId = conn.sourceId; // 源节点 id var targetId = conn.targetId; // 目标节点 id var sourceEndpoint = conn.endpoints[0].getUuid(); // 源端点 uuid var targetEndpoint = conn.endpoints[1].getUuid(); // 目标端点 uuid console.log('新增连线:', { from: sourceId, fromPort: sourceEndpoint, to: targetId, toPort: targetEndpoint }); }); // 连线被删除时触发(用户拖拽断开或手动删除) jsPlumb.bind('connectionDetached', function (info) { console.log('删除连线:', info.connection.sourceId, '->', info.connection.targetId); });逻辑说明:info.connection对象里,sourceId和targetId是节点的 DOM id,endpoints数组里存的是两端的端点对象,通过getUuid()拿到之前设的 uuid。为什么要存端点 uuid 而不是只存节点 id?因为一个节点可能有多个输入锚点,只存节点 id 的话,回显时不知道线该连到哪个锚点上。
参数上要注意connection事件的触发时机。它在连线真正建立之后才触发,所以此时conn.endpoints一定是有值的。但如果你在connection事件里做异步请求,要小心用户连续快速连线导致请求乱序,常见做法是加一个防抖或者用一个队列串行发送。
3.2 用 overlay 给连线加条件标签
流程图的线往往带条件,比如「同意」「拒绝」。jsPlumb 的 overlay 机制可以在连线上叠加文字或箭头。
// 在连线中点添加文字标签 var overlay = conn.addOverlay(['Label', { label: '同意', location: 0.5, // 0 到 1,0.5 是中点 cssClass: 'conn-label' }]);对应的 CSS 要自己写,jsPlumb 只负责定位,不负责样式:
.conn-label { background: #fff; padding: 2px 6px; border: 1px solid #5b9bd5; border-radius: 3px; font-size: 12px; color: #333; cursor: pointer; }存数据时,标签内容也要一起存。可以在connection事件里遍历conn.getOverlays()拿到所有 overlay,把 label 文本提取出来。回显时反过来,先建立连线,再根据存的标签文本重新addOverlay。
这里有个血泪经验:overlay 的location参数在贝塞尔曲线下是沿曲线长度比例定位的,不是直线距离比例。曲线弯得厉害时,0.5 的位置可能不在视觉中点。如果对标签位置要求严格,可以改用Absolute类型的 overlay,自己算坐标,但那样节点移动时标签不会自动跟随,需要手动更新,得不偿失。我一般就用 Label 类型,接受轻微的位置偏差。
3.3 回显流程:从 JSON 重建画布的三个步骤
存下来的 JSON 大概长这样:
{ "nodes": [ { "id": "node-start", "label": "开始", "left": 60, "top": 80 }, { "id": "node-approve", "label": "审批", "left": 280, "top": 80 }, { "id": "node-end", "label": "结束", "left": 500, "top": 80 } ], "connections": [ { "from": "node-start", "fromPort": "node-start-out", "to": "node-approve", "toPort": "node-approve-in", "label": "" }, { "from": "node-approve", "fromPort": "node-approve-out", "to": "node-end", "toPort": "node-end-in", "label": "同意" } ] }回显分三步:先渲染节点 DOM,再初始化端点,最后根据 connections 数组建立连线。
function restoreFlow(data) { var $canvas = $('#flow-canvas'); $canvas.empty(); // 第一步:渲染节点 data.nodes.forEach(function (node) { var $node = $('<div class="flow-node"></div>') .attr('id', node.id) .text(node.label) .css({ left: node.left + 'px', top: node.top + 'px' }); $canvas.append($node); }); // 第二步:初始化端点和拖拽 jsPlumb.draggable($('.flow-node')); data.nodes.forEach(function (node) { jsPlumb.addEndpoint(node.id, { anchor: 'Right', isSource: true, uuid: node.id + '-out', maxConnections: -1 }); jsPlumb.addEndpoint(node.id, { anchor: 'Left', isSource: false, isTarget: true, uuid: node.id + '-in' }); }); // 第三步:建立连线 data.connections.forEach(function (conn) { var connection = jsPlumb.connect({ source: conn.fromPort, target: conn.toPort, anchors: ['Right', 'Left'] }); if (conn.label) { connection.addOverlay(['Label', { label: conn.label, location: 0.5, cssClass: 'conn-label' }]); } }); }关键点:jsPlumb.connect的 source 和 target 参数可以传端点 uuid,也可以传节点 id。传 uuid 更精确,因为一个节点可能有多个端点。如果传节点 id,jsPlumb 会自己找第一个匹配的端点,多锚点场景下容易连错。
另一个坑是jsPlumb.draggable必须在节点 DOM 插入之后调用。如果先调 draggable 再 append 节点,拖拽会失效。这个顺序问题在动态加载数据时特别容易踩。
4. 节点增删改与连线清理:避坑与常见问题排查
4.1 删除节点时连线残留:现象、原因与解决
现象:调用$('#node-approve').remove()删掉节点后,画布上还留着几条悬空的线,端点飘在空白处。
原因:jQuery 的remove()只删 DOM,jsPlumb 内部还维护着这个节点的端点和连线对象。DOM 没了,但 jsPlumb 的注册表里还有记录,重绘时就会画出没有宿主的线。
解决:删节点前先调jsPlumb.removeAllEndpoints或jsPlumb.remove。
// 正确做法:先清理 jsPlumb 相关对象,再删 DOM function deleteNode(nodeId) { // removeAllEndpoints 会同时删除该节点上的所有连线和端点 jsPlumb.removeAllEndpoints(nodeId); // 再删 DOM $('#' + nodeId).remove(); }jsPlumb.remove是更彻底的方法,它内部会调removeAllEndpoints再删 DOM,但要求传入的是 DOM 元素或选择器。我一般用removeAllEndpoints加手动remove,控制更细。
4.2 拖拽后连线错位:容器定位与滚动条的坑
现象:节点拖到画布边缘,或者页面出现滚动条后,连线端点对不上节点边框,偏移量随滚动距离增大。
原因:jsPlumb 计算端点位置时用的是offset(),它基于文档坐标系。如果画布容器有overflow: auto且发生了滚动,或者页面本身滚动了,offset()的值和 jsPlumb 内部缓存的基准就不一致了。
解决:画布容器不要设overflow: auto,让它随内容撑开,滚动交给外层页面。如果必须设滚动,在滚动事件里调jsPlumb.repaintEverything()。
// 容器滚动时强制重绘所有连线和端点 $('#flow-canvas').on('scroll', function () { jsPlumb.repaintEverything(); });repaintEverything会遍历所有节点重新计算位置,节点多的时候有性能开销。更好的做法是只重绘受影响的节点,用jsPlumb.repaint(nodeId)。但滚动影响的是全部节点,所以还是得全量重绘。节点超过 50 个时,建议改用虚拟滚动或者分页,不要硬扛。
4.3 端点重复添加:初始化被调两次的排查
现象:节点上出现两个重叠的端点,连线时不知道该连哪个,偶尔连出来的线颜色不对。
原因:初始化代码被执行了两次。常见于 jQuery 的$(function(){})里绑了事件,事件回调里又调了一次初始化;或者用了某些前端路由,页面切换回来时重复执行。
解决:加一个标记位,或者用 jsPlumb 的getEndpoint先判断端点是否已存在。
// 安全添加端点:先检查 uuid 是否已存在 function safeAddEndpoint(nodeId, options) { var existing = jsPlumb.getEndpoint(options.uuid); if (existing) { return existing; // 已存在就不重复添加 } return jsPlumb.addEndpoint(nodeId, options); }jsPlumb.getEndpoint(uuid)返回已注册的端点对象,没有则返回 null。这个检查在单页应用里特别有用,因为组件可能被多次挂载。
4.4 连线数据丢失:connection 事件没绑上的三种情况
现象:画布上连线正常,但保存时 connections 数组是空的。
原因一:jsPlumb.bind('connection')在连线建立之后才执行。如果初始化代码是异步加载的,用户可能在绑定完成前就画了线。解决是把 bind 放在最前面,早于任何 addEndpoint。
原因二:用了jsPlumb.connect手动建线,但没触发connection事件。实际上connect方法默认会触发,但如果传了fireEvent: false就不会。检查有没有这个参数。
原因三:事件绑在了错误的实例上。如果页面里创建了多个 jsPlumb 实例(jsPlumb.getInstance()),bind 必须绑在同一个实例上。全局的jsPlumb.bind只对默认实例有效。
排查方法:在 bind 回调里打一行console.log,画一条线看有没有输出。没有输出就是绑定问题,有输出但数据不对就是取值问题。
4.5 性能拐点:多少节点开始卡
实测数据:在普通办公笔记本上,Chrome 环境,节点数到 80 左右时,拖拽开始有可感知的延迟;到 150 个节点、200 条连线时,拖拽帧率明显下降,repaintEverything一次要 200ms 以上。
优化方向有三个:一是减少端点数量,不需要连线的方向不要加端点;二是关掉不必要的动画,importDefaults里设PaintStyle时不要加过渡效果;三是拖拽时暂停重绘,用jsPlumb.setSuspendDrawing(true)在拖拽开始时挂起,结束时再setSuspendDrawing(false, true)恢复并重绘一次。
// 拖拽开始时挂起重绘,结束时恢复 jsPlumb.bind('drag', function () { jsPlumb.setSuspendDrawing(true); }); jsPlumb.bind('dragEnd', function () { jsPlumb.setSuspendDrawing(false, true); // 第二个参数 true 表示恢复后立即重绘 });这个优化能把拖拽时的重绘次数从每帧一次降到拖拽结束一次,节点多的时候提升很明显。
5. 进阶技巧:让流程图设计器真正能交付给业务用
5.1 连线校验:阻止不合法的流程走向
业务上有些连线是不允许的,比如「结束」节点不能再连出,「开始」节点不能再连入。jsPlumb 提供了beforeDrop拦截。
jsPlumb.bind('beforeDrop', function (info) { var sourceId = info.sourceId; var targetId = info.targetId; // 结束节点不能作为源 if (sourceId === 'node-end') { alert('结束节点不能连出'); return false; // 返回 false 阻止连线建立 } // 开始节点不能作为目标 if (targetId === 'node-start') { alert('开始节点不能连入'); return false; } // 不允许自连 if (sourceId === targetId) { return false; } return true; });beforeDrop的返回值决定连线是否建立。返回 false 时,jsPlumb 会把拖拽的线弹回去。这个钩子里还能拿到connection对象,可以进一步检查是否已存在相同的连线,避免重复连。
5.2 导出为图片:用 html2canvas 做快照
业务方经常需要把流程图贴到文档里。jsPlumb 本身不提供导出,常见做法是用 html2canvas 把画布容器截成图片。
function exportAsImage() { html2canvas(document.getElementById('flow-canvas'), { backgroundColor: '#fff', scale: 2 // 2 倍分辨率,导出的图更清晰 }).then(function (canvas) { var link = document.createElement('a'); link.download = 'flow.png'; link.href = canvas.toDataURL('image/png'); link.click(); }); }注意:html2canvas 对绝对定位和 SVG 的支持有版本差异,jsPlumb 的连线如果是 SVG 渲染的,某些版本截出来会缺线。稳妥做法是导出前先调一次jsPlumb.repaintEverything(),确保所有连线都在最新位置。另外scale: 2会让导出时间翻倍,节点多的时候要加 loading 提示。
5.3 从设计器到执行引擎:数据格式的约定
画布存下来的 JSON 只是「图」,要让它驱动实际流程,还需要一个执行引擎。我的习惯是在 connections 里加两个字段:condition和action。
{ "from": "node-approve", "fromPort": "node-approve-out", "to": "node-end", "toPort": "node-end-in", "label": "同意", "condition": "approveResult === 'pass'", "action": "updateStatus('approved')" }condition是流转条件,action是流转时执行的动作。后端拿到这份数据后,可以把它翻译成状态机配置或者工作流引擎的 DSL。前端设计器只负责「画」和「存」,不负责「跑」,这个边界要划清楚。我见过有团队试图在前端直接 eval condition 字符串,那是给自己埋雷,条件判断必须放后端。
5.4 一个我常犯的错误
早期做这类设计器,我总想把交互做得跟专业工具一样顺滑——节点带阴影、连线有动画、拖拽有吸附。结果业务同事用的时候根本不领情,他们只关心三件事:能不能快速加节点、连线会不会连错、保存后刷新还在不在。那些花哨效果反而让页面变卡,低配电脑上拖拽延迟明显。
后来我定了个规矩:先保证数据不丢、操作不误、刷新不崩,再考虑视觉。jsPlumb 加 jQuery 这套组合的优势就在于它足够朴素,没有虚拟 DOM 的抽象开销,出问题直接看 DOM 和事件绑定就能定位。如果你的场景是内部工具、节点规模在百级以内、团队没有重前端投入,这套方案值得试。希望帮到你。
本文还有配套的精品资源,点击获取