計(jì)器:拖拽連線(xiàn)與序列化實(shí)戰(zhàn))
簡(jiǎn)介這是一份面向Web前端開(kāi)發(fā)者與工作流設(shè)計(jì)人員的jsPlumbjQuery流程圖設(shè)計(jì)實(shí)例資源適合希望快速掌握可視化連線(xiàn)、節(jié)點(diǎn)拖拽與動(dòng)態(tài)流程圖搭建的初中級(jí)開(kāi)發(fā)者。壓縮包共216個(gè)文件約984KB以148張png截圖、24個(gè)js腳本、20個(gè)gif動(dòng)圖為主輔以css樣式、config配置及少量cs、aspx等源碼文件完整呈現(xiàn)了一個(gè)可運(yùn)行的流程圖演示項(xiàng)目。資源圍繞jsPlumb的端點(diǎn)創(chuàng)建、連接器樣式、事件監(jiān)聽(tīng)與jQuery的DOM操作展開(kāi)包含Bezier曲線(xiàn)、箭頭覆蓋物、拖放交互等典型實(shí)現(xiàn)并附帶flowchartDemo-master示例工程便于讀者對(duì)照源碼理解節(jié)點(diǎn)連接、連接創(chuàng)建與刪除事件、自定義樣式及動(dòng)畫(huà)擴(kuò)展等關(guān)鍵技巧。目前已有725人學(xué)習(xí)下載適合作為流程圖組件開(kāi)發(fā)、業(yè)務(wù)拓?fù)鋱D搭建的入門(mén)參考與二次開(kāi)發(fā)基礎(chǔ)。1. 從一張審批單說(shuō)起為什么 jsPlumb jQuery 仍是流程圖設(shè)計(jì)的高性?xún)r(jià)比方案很多做后臺(tái)系統(tǒng)的同學(xué)都遇到過(guò)這樣的需求業(yè)務(wù)方要一個(gè)能拖拽節(jié)點(diǎn)、連線(xiàn)、保存拓?fù)潢P(guān)系的流程圖設(shè)計(jì)器用來(lái)配置審批流、任務(wù)依賴(lài)或者數(shù)據(jù)管道。第一反應(yīng)往往是上 React Flow 或者 G6但真到落地時(shí)才發(fā)現(xiàn)老項(xiàng)目是 jQuery 技術(shù)棧引入一整套前端框架的成本比想象中高得多。這時(shí)候 jsPlumb jQuery 的組合就顯出價(jià)值了——它不要求你重構(gòu)整個(gè)頁(yè)面只要在現(xiàn)有 DOM 上掛載連線(xiàn)能力幾百行代碼就能跑出一個(gè)可用的流程圖設(shè)計(jì)實(shí)例。這套方案特別適合課程設(shè)計(jì)、畢設(shè)里需要「可視化配置」模塊的場(chǎng)景也適合維護(hù)存量 jQuery 項(xiàng)目的工程師快速交付一個(gè)流程圖編輯器。核心要解決的問(wèn)題就三個(gè)節(jié)點(diǎn)可拖拽、連線(xiàn)可建立與刪除、拓?fù)浣Y(jié)構(gòu)可序列化保存。2. 需求拆解與技術(shù)選型為什么是 jsPlumb 而不是 Canvas 自繪2.1 功能邊界先劃清楚在動(dòng)手寫(xiě)代碼之前我一般會(huì)先把流程圖設(shè)計(jì)器的功能邊界列出來(lái)避免做到一半發(fā)現(xiàn)漏了關(guān)鍵能力。這個(gè)實(shí)例需要覆蓋的核心功能包括左側(cè)物料區(qū)提供三種節(jié)點(diǎn)類(lèi)型開(kāi)始、處理、結(jié)束拖拽到畫(huà)布生成節(jié)點(diǎn)節(jié)點(diǎn)在畫(huà)布內(nèi)可自由拖動(dòng)拖動(dòng)時(shí)連線(xiàn)跟隨移動(dòng)節(jié)點(diǎn)邊緣有連接錨點(diǎn)從一個(gè)節(jié)點(diǎn)拖到另一個(gè)節(jié)點(diǎn)建立連線(xiàn)連線(xiàn)可選中并刪除節(jié)點(diǎn)刪除時(shí)關(guān)聯(lián)連線(xiàn)一并清除畫(huà)布支持保存為 JSON 結(jié)構(gòu)包含節(jié)點(diǎn)坐標(biāo)、類(lèi)型、ID 和連線(xiàn)關(guān)系支持從 JSON 反向渲染用于編輯已有流程這些功能看起來(lái)簡(jiǎn)單但每一項(xiàng)都有坑。比如節(jié)點(diǎn)拖動(dòng)時(shí)連線(xiàn)跟隨如果用 Canvas 自繪需要自己算貝塞爾曲線(xiàn)控制點(diǎn)用 jsPlumb 的話(huà)它內(nèi)部已經(jīng)處理了重繪邏輯你只需要調(diào)用repaint方法。2.2 技術(shù)選型對(duì)比Canvas 自繪 vs SVG vs jsPlumb選型這件事我的習(xí)慣是先看團(tuán)隊(duì)技術(shù)棧和交付周期。如果項(xiàng)目本身就是 jQuery 體系硬上 React Flow 意味著要引入構(gòu)建工具、狀態(tài)管理、組件通信一整套東西對(duì)于一個(gè)小型流程圖模塊來(lái)說(shuō)屬于過(guò)度設(shè)計(jì)。下面這張表是我在幾個(gè)方案之間做的對(duì)比方案開(kāi)發(fā)成本與 jQuery 兼容性連線(xiàn)交互序列化難度Canvas 自繪高好需自己實(shí)現(xiàn)中SVG 手寫(xiě)中高好需自己實(shí)現(xiàn)中jsPlumb jQuery低極好內(nèi)置低React Flow中差內(nèi)置低jsPlumb 的本質(zhì)是在 DOM 元素上疊加一層 SVG 或 Canvas 來(lái)繪制連線(xiàn)節(jié)點(diǎn)本身還是普通 div所以和 jQuery 的拖拽、事件綁定天然契合。它的社區(qū)版功能已經(jīng)覆蓋了流程圖設(shè)計(jì)器 90% 的需求包括錨點(diǎn)、端點(diǎn)、連接器樣式、拖拽重繪等。2.3 依賴(lài)引入與初始化配置引入 jsPlumb 有兩種方式直接 script 標(biāo)簽引入或者通過(guò) npm 安裝。對(duì)于 jQuery 項(xiàng)目我通常直接用 script 標(biāo)簽避免構(gòu)建工具的折騰。!-- 先引入 jQuery再引入 jsPlumb -- script srchttps://cdn.jsdelivr.net/npm/jquery3.7.1/dist/jquery.min.js/script script srchttps://cdn.jsdelivr.net/npm/jsplumb2.15.6/dist/jsplumb.min.js/script初始化 jsPlumb 實(shí)例時(shí)有幾個(gè)關(guān)鍵配置項(xiàng)需要提前設(shè)好否則后面改起來(lái)很麻煩// 初始化 jsPlumb 實(shí)例綁定到畫(huà)布容器 var instance jsPlumb.getInstance({ Container: flowCanvas, // 畫(huà)布容器 ID所有連線(xiàn)繪制在這個(gè)容器內(nèi) Connector: [Bezier, { curviness: 50 }], // 連線(xiàn)樣式貝塞爾曲線(xiàn)彎曲度 50 Endpoint: [Dot, { radius: 4 }], // 端點(diǎn)樣式圓點(diǎn)半徑 4px Anchor: Continuous, // 錨點(diǎn)自動(dòng)選擇最近邊緣 PaintStyle: { stroke: #456, strokeWidth: 2 }, // 連線(xiàn)顏色和粗細(xì) HoverPaintStyle: { stroke: #2a7, strokeWidth: 3 }, // 懸停高亮 ConnectionOverlays: [ // 連線(xiàn)上的疊加元素 [Arrow, { location: 1, width: 10, length: 10 }] // 箭頭 ] });這里Container必須指定否則連線(xiàn)會(huì)掛到 body 上滾動(dòng)頁(yè)面時(shí)連線(xiàn)位置會(huì)錯(cuò)亂。Anchor: Continuous讓 jsPlumb 自動(dòng)選擇節(jié)點(diǎn)最近的邊緣作為連接點(diǎn)省去手動(dòng)指定錨點(diǎn)位置的麻煩。ConnectionOverlays里的箭頭配置讓連線(xiàn)有方向感審批流這類(lèi)場(chǎng)景必須要有。3. 總體架構(gòu)設(shè)計(jì)四個(gè)模塊撐起一個(gè)流程圖編輯器3.1 模塊劃分與職責(zé)整個(gè)流程圖設(shè)計(jì)器我拆成四個(gè)模塊每個(gè)模塊職責(zé)單一方便調(diào)試和替換物料區(qū)模塊負(fù)責(zé)渲染可拖拽的節(jié)點(diǎn)模板處理拖拽起始事件把節(jié)點(diǎn)類(lèi)型傳遞給畫(huà)布畫(huà)布模塊負(fù)責(zé)接收拖拽生成節(jié)點(diǎn)、管理節(jié)點(diǎn) DOM、初始化 jsPlumb 端點(diǎn)連線(xiàn)管理模塊負(fù)責(zé)建立連線(xiàn)、刪除連線(xiàn)、校驗(yàn)連線(xiàn)合法性比如開(kāi)始節(jié)點(diǎn)不能有入邊數(shù)據(jù)序列化模塊負(fù)責(zé)把畫(huà)布狀態(tài)導(dǎo)出為 JSON以及從 JSON 恢復(fù)畫(huà)布這四個(gè)模塊之間通過(guò)自定義事件通信而不是直接互相調(diào)用。比如物料區(qū)拖拽結(jié)束時(shí)觸發(fā)node:drop事件畫(huà)布模塊監(jiān)聽(tīng)這個(gè)事件并創(chuàng)建節(jié)點(diǎn)。這樣做的好處是模塊可以獨(dú)立測(cè)試也方便后續(xù)替換某個(gè)模塊的實(shí)現(xiàn)。3.2 節(jié)點(diǎn)數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)節(jié)點(diǎn)和連線(xiàn)的數(shù)據(jù)結(jié)構(gòu)直接決定了序列化的難易程度。我一般用下面這種扁平結(jié)構(gòu)// 節(jié)點(diǎn)對(duì)象 { id: node_1, // 唯一標(biāo)識(shí)用時(shí)間戳或遞增數(shù)字 type: process, // 節(jié)點(diǎn)類(lèi)型start / process / end label: 審批, // 顯示文本 x: 300, // 相對(duì)畫(huà)布的 X 坐標(biāo) y: 150 // 相對(duì)畫(huà)布的 Y 坐標(biāo) } // 連線(xiàn)對(duì)象 { id: conn_1, // 連線(xiàn)唯一標(biāo)識(shí) source: node_1, // 起點(diǎn)節(jié)點(diǎn) ID target: node_2, // 終點(diǎn)節(jié)點(diǎn) ID sourceAnchor: [0.5, 1], // 起點(diǎn)錨點(diǎn)位置可選用于精確恢復(fù) targetAnchor: [0.5, 0] // 終點(diǎn)錨點(diǎn)位置 }保存時(shí)把節(jié)點(diǎn)數(shù)組和連線(xiàn)數(shù)組一起序列化恢復(fù)時(shí)先創(chuàng)建節(jié)點(diǎn) DOM再根據(jù)連線(xiàn)數(shù)據(jù)調(diào)用instance.connect重建連線(xiàn)。這里有個(gè)細(xì)節(jié)恢復(fù)連線(xiàn)時(shí)如果錨點(diǎn)位置不保存jsPlumb 會(huì)重新計(jì)算可能導(dǎo)致連線(xiàn)走向和保存前不一致。所以我在連線(xiàn)對(duì)象里額外存了錨點(diǎn)坐標(biāo)。3.3 畫(huà)布與物料區(qū)的 DOM 結(jié)構(gòu)DOM 結(jié)構(gòu)盡量簡(jiǎn)單避免深層嵌套導(dǎo)致拖拽計(jì)算復(fù)雜div classflow-editor !-- 左側(cè)物料區(qū) -- div classpalette div classpalette-item>// 物料區(qū)拖拽開(kāi)始把節(jié)點(diǎn)類(lèi)型寫(xiě)入 dataTransfer $(.palette-item).on(dragstart, function (e) { e.originalEvent.dataTransfer.setData(nodeType, $(this).data(type)); }); // 畫(huà)布允許放置 $(#flowCanvas).on(dragover, function (e) { e.preventDefault(); // 必須阻止默認(rèn)行為才能觸發(fā) drop }); // 畫(huà)布放置時(shí)創(chuàng)建節(jié)點(diǎn) $(#flowCanvas).on(drop, function (e) { e.preventDefault(); var type e.originalEvent.dataTransfer.getData(nodeType); if (!type) return; // 計(jì)算節(jié)點(diǎn)在畫(huà)布內(nèi)的坐標(biāo) var offset $(this).offset(); var x e.originalEvent.clientX - offset.left - 40; // 減去節(jié)點(diǎn)半寬讓節(jié)點(diǎn)居中 var y e.originalEvent.clientY - offset.top - 20; createNode(type, x, y); });createNode函數(shù)負(fù)責(zé)生成節(jié)點(diǎn) DOM 并注冊(cè) jsPlumb 端點(diǎn)var nodeIdCounter 0; function createNode(type, x, y) { var id node_ (nodeIdCounter); var label { start: 開(kāi)始, process: 處理, end: 結(jié)束 }[type]; // 創(chuàng)建節(jié)點(diǎn) DOM var $node $(div classflow-node/div) .attr(id, id) .addClass(node- type) .css({ left: x, top: y }) .text(label); $(#flowCanvas).append($node); // 注冊(cè) jsPlumb 端點(diǎn)源端和目標(biāo)端都注冊(cè)允許雙向連線(xiàn) instance.addEndpoint(id, { uuid: id _source, anchor: Continuous, isSource: true, isTarget: true, maxConnections: -1 // -1 表示不限制連接數(shù) }); // 節(jié)點(diǎn)自身可拖動(dòng) instance.draggable(id, { containment: parent, // 限制在畫(huà)布內(nèi)拖動(dòng) grid: [10, 10] // 拖動(dòng)時(shí)按 10px 網(wǎng)格吸附對(duì)齊更整齊 }); return id; }這里instance.draggable是 jsPlumb 提供的拖拽方法它內(nèi)部用 jQuery UI 的 draggable 實(shí)現(xiàn)但不需要你額外引入 jQuery UI。containment: parent確保節(jié)點(diǎn)不會(huì)被拖出畫(huà)布grid讓節(jié)點(diǎn)對(duì)齊到網(wǎng)格視覺(jué)上更規(guī)整。4.2 連線(xiàn)建立與刪除連線(xiàn)建立是 jsPlumb 自動(dòng)處理的你只需要在初始化時(shí)配置好端點(diǎn)。但刪除連線(xiàn)需要手動(dòng)綁定事件// 連線(xiàn)建立后觸發(fā)可以在這里做合法性校驗(yàn) instance.bind(connection, function (info) { var sourceType $(# info.sourceId).data(type); var targetType $(# info.targetId).data(type); // 開(kāi)始節(jié)點(diǎn)不能有入邊結(jié)束節(jié)點(diǎn)不能有出邊 if (targetType start || sourceType end) { instance.deleteConnection(info.connection); alert(開(kāi)始節(jié)點(diǎn)不能作為終點(diǎn)結(jié)束節(jié)點(diǎn)不能作為起點(diǎn)); return; } // 保存連線(xiàn)數(shù)據(jù)到內(nèi)存 connectionList.push({ id: conn_ info.connection.id, source: info.sourceId, target: info.targetId }); }); // 點(diǎn)擊連線(xiàn)選中按 Delete 鍵刪除 instance.bind(click, function (conn, originalEvent) { if (confirm(確定刪除這條連線(xiàn)嗎)) { instance.deleteConnection(conn); // 從內(nèi)存中移除 connectionList connectionList.filter(function (c) { return c.id ! conn_ conn.id; }); } });connection事件的info參數(shù)包含sourceId、targetId和connection對(duì)象。合法性校驗(yàn)放在這里最合適因?yàn)榇藭r(shí)連線(xiàn)已經(jīng)建立但還未持久化刪除操作對(duì)用戶(hù)來(lái)說(shuō)是無(wú)感的。click事件綁定在 instance 上點(diǎn)擊連線(xiàn)時(shí)觸發(fā)用confirm確認(rèn)刪除避免誤操作。4.3 序列化與反序列化保存功能把節(jié)點(diǎn)和連線(xiàn)數(shù)據(jù)導(dǎo)出為 JSONfunction saveFlow() { var nodes []; $(#flowCanvas .flow-node).each(function () { var $node $(this); nodes.push({ id: $node.attr(id), type: $node.data(type), label: $node.text(), x: parseInt($node.css(left)), y: parseInt($node.css(top)) }); }); var data { nodes: nodes, connections: connectionList }; // 輸出到控制臺(tái)或發(fā)送到后端 console.log(JSON.stringify(data, null, 2)); return data; }恢復(fù)時(shí)先清空畫(huà)布再按順序重建function loadFlow(data) { // 清空畫(huà)布和 jsPlumb 實(shí)例 instance.reset(); $(#flowCanvas).empty(); connectionList []; nodeIdCounter 0; // 先創(chuàng)建所有節(jié)點(diǎn) data.nodes.forEach(function (node) { var id createNode(node.type, node.x, node.y); // 恢復(fù)節(jié)點(diǎn) ID 和標(biāo)簽 $(# id).attr(id, node.id).text(node.label); // 更新 nodeIdCounter避免后續(xù)創(chuàng)建節(jié)點(diǎn) ID 沖突 var num parseInt(node.id.replace(node_, )); if (num nodeIdCounter) nodeIdCounter num; }); // 再重建連線(xiàn) data.connections.forEach(function (conn) { instance.connect({ source: conn.source, target: conn.target, anchors: [conn.sourceAnchor || Continuous, conn.targetAnchor || Continuous] }); connectionList.push(conn); }); }這里有個(gè)容易翻車(chē)的點(diǎn)instance.reset()會(huì)清除所有端點(diǎn)和連線(xiàn)但不會(huì)清除 DOM 節(jié)點(diǎn)所以需要手動(dòng)empty()。另外節(jié)點(diǎn) ID 恢復(fù)后nodeIdCounter要同步更新否則下次創(chuàng)建節(jié)點(diǎn)時(shí) ID 會(huì)重復(fù)導(dǎo)致 jsPlumb 端點(diǎn)沖突。5. 避坑與常見(jiàn)問(wèn)題排查那些讓我加班到凌晨的細(xì)節(jié)5.1 連線(xiàn)位置偏移或錯(cuò)亂現(xiàn)象拖動(dòng)節(jié)點(diǎn)后連線(xiàn)不跟隨或者連線(xiàn)整體偏移到畫(huà)布外。原因畫(huà)布容器沒(méi)有設(shè)置position: relativejsPlumb 的連線(xiàn)層以 body 為定位基準(zhǔn)?;蛘弋?huà)布有滾動(dòng)條時(shí)沒(méi)有調(diào)用instance.repaintEverything()。解決給畫(huà)布容器加position: relative并在畫(huà)布滾動(dòng)或窗口 resize 時(shí)調(diào)用instance.repaintEverything()。如果畫(huà)布內(nèi)有滾動(dòng)還需要在滾動(dòng)事件里重新計(jì)算偏移。5.2 節(jié)點(diǎn)拖動(dòng)時(shí)連線(xiàn)閃爍現(xiàn)象拖動(dòng)節(jié)點(diǎn)過(guò)程中連線(xiàn)頻繁重繪導(dǎo)致閃爍。原因jsPlumb 默認(rèn)在拖動(dòng)時(shí)實(shí)時(shí)重繪如果節(jié)點(diǎn)數(shù)量多性能跟不上。解決在instance.draggable的配置里加drag: function () { instance.repaintEverything(); }或者降低重繪頻率。更徹底的辦法是拖動(dòng)時(shí)隱藏連線(xiàn)拖動(dòng)結(jié)束后再顯示但體驗(yàn)會(huì)打折扣。5.3 序列化后恢復(fù)連線(xiàn)錨點(diǎn)位置不對(duì)現(xiàn)象保存前連線(xiàn)從節(jié)點(diǎn)右側(cè)出發(fā)恢復(fù)后變成從底部出發(fā)。原因Anchor: Continuous是動(dòng)態(tài)計(jì)算的恢復(fù)時(shí)節(jié)點(diǎn)位置和保存時(shí)一致但 jsPlumb 內(nèi)部狀態(tài)不同導(dǎo)致錨點(diǎn)重新計(jì)算。解決在連線(xiàn)數(shù)據(jù)里保存sourceAnchor和targetAnchor的具體坐標(biāo)恢復(fù)時(shí)用anchors參數(shù)指定而不是用Continuous。5.4 刪除節(jié)點(diǎn)后連線(xiàn)殘留現(xiàn)象刪除節(jié)點(diǎn) DOM 后連線(xiàn)還在畫(huà)布上。原因只刪了 DOM沒(méi)有調(diào)用instance.remove清除 jsPlumb 內(nèi)部記錄。解決刪除節(jié)點(diǎn)時(shí)先調(diào)用instance.remove(nodeId)再移除 DOM。instance.remove會(huì)自動(dòng)刪除該節(jié)點(diǎn)關(guān)聯(lián)的所有連線(xiàn)和端點(diǎn)。5.5 多個(gè) jsPlumb 實(shí)例沖突現(xiàn)象頁(yè)面上有多個(gè)畫(huà)布時(shí)連線(xiàn)跑到另一個(gè)畫(huà)布上。原因jsPlumb 默認(rèn)是單例模式多次getInstance返回同一個(gè)實(shí)例。解決用jsPlumb.getInstance創(chuàng)建獨(dú)立實(shí)例并給每個(gè)實(shí)例指定不同的Container。如果確實(shí)需要多個(gè)實(shí)例確保它們的容器 ID 不重復(fù)。6. 驗(yàn)收與答辯怎么證明你的流程圖設(shè)計(jì)器是合格的驗(yàn)收這個(gè)流程圖設(shè)計(jì)器我一般從五個(gè)維度來(lái)檢查每個(gè)維度都有具體的操作步驟和預(yù)期結(jié)果驗(yàn)收項(xiàng)操作步驟預(yù)期結(jié)果節(jié)點(diǎn)生成從物料區(qū)拖拽三種節(jié)點(diǎn)到畫(huà)布節(jié)點(diǎn)出現(xiàn)在鼠標(biāo)釋放位置樣式正確節(jié)點(diǎn)拖動(dòng)拖動(dòng)任意節(jié)點(diǎn)到畫(huà)布另一位置節(jié)點(diǎn)跟隨鼠標(biāo)連線(xiàn)實(shí)時(shí)重繪不超出畫(huà)布連線(xiàn)建立從一個(gè)節(jié)點(diǎn)端點(diǎn)拖到另一個(gè)節(jié)點(diǎn)生成帶箭頭的貝塞爾曲線(xiàn)方向正確連線(xiàn)刪除點(diǎn)擊連線(xiàn)確認(rèn)刪除連線(xiàn)消失節(jié)點(diǎn)不受影響序列化點(diǎn)擊保存刷新頁(yè)面后加載 JSON節(jié)點(diǎn)位置、類(lèi)型、連線(xiàn)關(guān)系完全恢復(fù)答辯時(shí)評(píng)委最常問(wèn)的三個(gè)問(wèn)題是為什么選 jsPlumb 而不是其他方案、連線(xiàn)合法性怎么校驗(yàn)、數(shù)據(jù)怎么持久化。第一個(gè)問(wèn)題用第 2 章的選型對(duì)比表回答第二個(gè)問(wèn)題現(xiàn)場(chǎng)演示「開(kāi)始節(jié)點(diǎn)不能有入邊」的校驗(yàn)邏輯第三個(gè)問(wèn)題展示 JSON 結(jié)構(gòu)并說(shuō)明可以存到后端數(shù)據(jù)庫(kù)。我自己的習(xí)慣是每次交付前都會(huì)把上面這張表跑一遍尤其是序列化恢復(fù)這一項(xiàng)因?yàn)樗钦麄€(gè)方案里最容易出問(wèn)題的地方。有一次我自信滿(mǎn)滿(mǎn)地演示結(jié)果恢復(fù)后連線(xiàn)全跑到畫(huà)布外面了原因就是忘了在loadFlow里調(diào)用instance.repaintEverything()。從那以后我每次保存和加載都強(qiáng)制走一遍完整流程確認(rèn)連線(xiàn)位置和保存前一致才敢提交。希望這套方案能幫到你少走一些我踩過(guò)的彎路。本文還有配套的精品資源點(diǎn)擊獲取