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