據(jù)模型到Canvas渲染的完整實(shí)戰(zhàn))
先從結(jié)論說(shuō)起這類(lèi)名為“diagram-design”的項(xiàng)目核心目標(biāo)從來(lái)不是“畫(huà)出好看的圖”而是“讓圖和背后的數(shù)據(jù)關(guān)系對(duì)得上”。我在實(shí)際動(dòng)手做了幾個(gè)月之后最大的感受是圖表設(shè)計(jì)最難的部分不是渲染而是把數(shù)據(jù)模型、交互狀態(tài)和視覺(jué)表現(xiàn)三者解耦清楚。下面把我的完整設(shè)計(jì)與實(shí)操過(guò)程拆開(kāi)講從數(shù)據(jù)建模到渲染管線再到避坑實(shí)錄都整理出來(lái)。1. 整體設(shè)計(jì)思路先想清楚“圖”到底是什么1.1 核心需求拆解在動(dòng)工之前我先把這個(gè)標(biāo)題翻譯成具體的功能清單。所謂“diagram-design”往細(xì)了說(shuō)無(wú)非是三件事創(chuàng)建和編輯節(jié)點(diǎn)節(jié)點(diǎn)有坐標(biāo)、大小、樣式可以被拖拽、縮放、刪除、復(fù)制。建立連接關(guān)系節(jié)點(diǎn)之間通過(guò)邊連接邊能記錄從哪里出發(fā)、到達(dá)哪里還能在中途拐彎。整體視圖控制畫(huà)布可以縮放、平移局部細(xì)節(jié)和整體結(jié)構(gòu)可以切換查看。這三件事拆完之后我發(fā)現(xiàn)真正的難點(diǎn)在第一件事的前半句“坐標(biāo)、大小、樣式”這些數(shù)據(jù)從哪里來(lái)答案是從用戶的操作中來(lái)但操作只是表象背后必須有一份可以被持久化、被復(fù)制、被程序讀取的數(shù)據(jù)結(jié)構(gòu)。否則你畫(huà)出來(lái)的圖只能看不能存更不能參與業(yè)務(wù)邏輯判斷。我做了一個(gè)取舍所有的圖形元素都用純數(shù)據(jù)描述不把任何視覺(jué)狀態(tài)混進(jìn)去。節(jié)點(diǎn)的位置、顏色、大小是數(shù)據(jù)節(jié)點(diǎn)處于“選中”“高亮”這類(lèi)交互狀態(tài)則不屬于數(shù)據(jù)——它們是運(yùn)行時(shí)的臨時(shí)狀態(tài)。這個(gè)分離規(guī)則在后續(xù)開(kāi)發(fā)里幫我省了大量麻煩。1.2 為什么不自已從零折騰一套復(fù)雜引擎很多人一聽(tīng)到圖表設(shè)計(jì)第一反應(yīng)是去引入現(xiàn)成的開(kāi)源庫(kù)。這個(gè)思路沒(méi)有問(wèn)題但對(duì)于我這種希望深度定制交互、并且要把圖數(shù)據(jù)導(dǎo)入導(dǎo)出到其他系統(tǒng)的場(chǎng)景現(xiàn)成庫(kù)反而會(huì)帶來(lái)幾層限制它的數(shù)據(jù)格式是固定的你的業(yè)務(wù)模型得遷就它它的狀態(tài)管理是內(nèi)置的你想和外部框架聯(lián)動(dòng)得靠事件橋接它的自定義渲染能力再?gòu)?qiáng)也不可能完全覆蓋你想要的視覺(jué)規(guī)范。所以我的判斷是自研一個(gè)輕量級(jí)圖編輯器核心是值得的。這里“輕量級(jí)”三個(gè)字很重要它意味著不需要處理巨型圖、不需要復(fù)雜的動(dòng)畫(huà)引擎、不需要多用戶實(shí)時(shí)協(xié)同。我的目標(biāo)是支撐單畫(huà)布內(nèi)幾百個(gè)節(jié)點(diǎn)、上千條邊的規(guī)模同時(shí)保證交互不掉幀。這個(gè)量級(jí)下完全可以通過(guò)合理設(shè)計(jì)做到。當(dāng)然如果圖規(guī)模到了上萬(wàn)個(gè)節(jié)點(diǎn)或者需要多人同時(shí)編輯那自研的代價(jià)會(huì)急劇上升此時(shí)使用成熟的圖編輯平臺(tái)反而更劃算。取舍的關(guān)鍵指標(biāo)就一個(gè)圖和你的業(yè)務(wù)數(shù)據(jù)之間耦合得有多深。1.3 技術(shù)方案選型我的技術(shù)棧定在純前端主語(yǔ)言用 TypeScript。選擇 TypeScript 的理由非常直接圖數(shù)據(jù)涉及大量類(lèi)型定義節(jié)點(diǎn)類(lèi)型、邊類(lèi)型、端口類(lèi)型在編譯期就定死比靠人腦記憶字段名靠譜得多。數(shù)據(jù)渲染層我選擇了兩套渲染方案并存畫(huà)布Canvas負(fù)責(zé)主場(chǎng)景渲染SVG 負(fù)責(zé)少量裝飾性元素。具體理由在后面的渲染章節(jié)詳細(xì)展開(kāi)這里先給結(jié)論——Canvas 在復(fù)雜場(chǎng)景下性能優(yōu)勢(shì)明顯但文本適配和局部更新不如 SVG 靈活所以兩者各司其職。狀態(tài)管理方面我引入了一個(gè)簡(jiǎn)單的狀態(tài)機(jī)維護(hù)畫(huà)布當(dāng)前的模式空閑、拖拽節(jié)點(diǎn)中、連線中、框選中。每種模式對(duì)應(yīng)的鼠標(biāo)事件處理完全不同用狀態(tài)機(jī)可以避免大量互相打架的 if-else。整體架構(gòu)分成了三層數(shù)據(jù)層節(jié)點(diǎn)、邊、端口的純數(shù)據(jù)模型負(fù)責(zé)增刪改查和序列化。視圖層負(fù)責(zé)將數(shù)據(jù)模型渲染到畫(huà)布并處理視口變換。交互層監(jiān)聽(tīng)鼠標(biāo)、鍵盤(pán)事件將其翻譯成數(shù)據(jù)層的修改操作。這個(gè)分層的核心原則是單向數(shù)據(jù)流用戶操作 → 交互層解析 → 修改數(shù)據(jù)層 → 視圖層訂閱變化并重繪。交互層不直接改畫(huà)布視圖層不反向改數(shù)據(jù)數(shù)據(jù)層不感知任何 UI 細(xì)節(jié)。2. 核心數(shù)據(jù)模型把所有圖形變成可存儲(chǔ)的數(shù)據(jù)2.1 節(jié)點(diǎn)、邊、端口的建模細(xì)節(jié)先說(shuō)我經(jīng)過(guò)幾次重構(gòu)后的最終數(shù)據(jù)結(jié)構(gòu)。一張圖我用一個(gè)對(duì)象表示包含三塊interface Diagram { id: string; nodes: Mapstring, DiagramNode; edges: Mapstring, DiagramEdge; meta: DiagramMeta; } interface DiagramNode { id: string; type: string; x: number; y: number; width: number; height: number; ports: Port[]; // 連接錨點(diǎn) data: Recordstring, unknown; // 業(yè)務(wù)數(shù)據(jù)不渲染但持久化 } interface Port { id: string; position: top | bottom | left | right; offset: number; // 端口在該邊上的相對(duì)位置0~1 } interface DiagramEdge { id: string; sourceNodeId: string; sourcePortId: string; targetNodeId: string; targetPortId: string; points: Point[]; // 折線的中間拐點(diǎn)可為空 data: Recordstring, unknown; }我公開(kāi)過(guò)很多次這個(gè)數(shù)據(jù)結(jié)構(gòu)每次被問(wèn)到最多的都是為什么要單獨(dú)拆出一個(gè)Port概念而不是直接用節(jié)點(diǎn)坐標(biāo)計(jì)算連接點(diǎn)關(guān)鍵在于邊的連接位置必須是穩(wěn)定、可恢復(fù)的。如果一條邊連接的是“節(jié)點(diǎn)A的右側(cè)邊緣中間”那么無(wú)論節(jié)點(diǎn)A怎么移動(dòng)這條邊的連接點(diǎn)都會(huì)跟著右側(cè)邊緣跑。如果你只存了節(jié)點(diǎn)的中心點(diǎn)每次重新計(jì)算連接點(diǎn)就會(huì)受節(jié)點(diǎn)尺寸變化影響——節(jié)點(diǎn)一旦改變寬高整條邊的位置就會(huì)漂移。端口這個(gè)概念的另一個(gè)好處是它天然支持“同節(jié)點(diǎn)多連接錨點(diǎn)”。一個(gè)節(jié)點(diǎn)可能有多個(gè)輸出口每個(gè)輸出口都是獨(dú)立的端口邊只需要記錄端口 ID 即可不需要關(guān)心端口的具體坐標(biāo)。坐標(biāo)由視圖層根據(jù)節(jié)點(diǎn)位置實(shí)時(shí)計(jì)算。2.2 數(shù)據(jù)序列化與格式兼容圖數(shù)據(jù)最終要能被保存到文件、數(shù)據(jù)庫(kù)或者發(fā)送給后端。所以序列化格式的設(shè)計(jì)非常關(guān)鍵。我用的是帶版本號(hào)的 JSON Schema{ version: 1.3.0, diagram: { id: diagram-001, meta: { title: 訂單流程圖 }, nodes: [], edges: [] } }version字段是我踩過(guò)坑之后補(bǔ)上的。起初我在本地開(kāi)發(fā)時(shí)沒(méi)有版本號(hào)后來(lái)數(shù)據(jù)結(jié)構(gòu)調(diào)整舊數(shù)據(jù)全部無(wú)法解析幾千條測(cè)試數(shù)據(jù)直接作廢。加上版本號(hào)之后每次寫(xiě)解析器都先判斷版本再?zèng)Q定走哪條遷移分支數(shù)據(jù)兼容的問(wèn)題就徹底解決了。序列化時(shí)還應(yīng)遵循“可以丟了不致命但保留更好”的字段放最后的原則。核心節(jié)點(diǎn)和邊的 ID、連接關(guān)系是必須的缺了圖就廢了樣式和坐標(biāo)可以恢復(fù)成默認(rèn)值但保留可以提升還原度擴(kuò)展字段全部掛到data對(duì)象里不占主體結(jié)構(gòu)。2.3 撤銷(xiāo)與重做快照策略很關(guān)鍵撤銷(xiāo)和重做是圖編輯器的隱形能力沒(méi)有它任何誤操作都像毀尸現(xiàn)場(chǎng)。我的實(shí)現(xiàn)方式是每次數(shù)據(jù)變更之前記錄上一個(gè)完整快照變更完成后記錄新快照撤銷(xiāo)和重做就是在這兩個(gè)快照之間切換。問(wèn)題在于快照是深拷貝深拷貝的成本很高而很多操作只是一次小修改比如拖動(dòng)節(jié)點(diǎn)拖動(dòng)的過(guò)程中可能產(chǎn)生幾十次中間狀態(tài)。全量快照會(huì)讓內(nèi)存和 CPU 都吃緊。我最終采用了兩層策略拖拽、縮放這類(lèi)連續(xù)操作在開(kāi)始時(shí)存一份快照操作過(guò)程中產(chǎn)生的中間狀態(tài)不保存操作結(jié)束時(shí)再存一份最終快照。這樣一次拖拽永遠(yuǎn)只對(duì)應(yīng)一次撤銷(xiāo)步驟。其他離散操作添加節(jié)點(diǎn)、刪除邊、修改顏色每次操作前存一份快照。這個(gè)方案實(shí)現(xiàn)簡(jiǎn)單卻能把內(nèi)存占用降低一個(gè)數(shù)量級(jí)。我實(shí)測(cè)在 200 個(gè)節(jié)點(diǎn)的畫(huà)布上連續(xù)拖拽 10 秒撤銷(xiāo)棧里只新增了 2 個(gè)快照內(nèi)存波動(dòng)可以忽略。3. 渲染管線的核心細(xì)節(jié)畫(huà)布為什么比想象中復(fù)雜3.1 Canvas 和 SVG 的取舍在圖表設(shè)計(jì)這個(gè)領(lǐng)域Canvas 和 SVG 的爭(zhēng)論從來(lái)沒(méi)有定論但我的選擇標(biāo)準(zhǔn)非常實(shí)用誰(shuí)能在我的場(chǎng)景下用更少的代碼達(dá)到更好的交互體驗(yàn)誰(shuí)就上。SVG 的最大優(yōu)勢(shì)是 DOM 化每個(gè)圖形就是一個(gè) DOM 節(jié)點(diǎn)可以綁定事件、修改樣式、單獨(dú)更新、配 CSS 動(dòng)畫(huà)。對(duì)于節(jié)點(diǎn)數(shù)較少幾十到一百的圖表SVG 的開(kāi)發(fā)效率明顯更高因?yàn)楹芏噤秩炯?xì)節(jié)瀏覽器幫你處理了。但當(dāng)節(jié)點(diǎn)數(shù)上升到幾百邊數(shù)上千DOM 節(jié)點(diǎn)數(shù)量開(kāi)始拖慢頁(yè)面。任何一個(gè)節(jié)點(diǎn)的重繪都可能觸發(fā)瀏覽器的樣式重算和布局更新響應(yīng)延遲會(huì)明顯變大。Canvas 沒(méi)有這個(gè)負(fù)擔(dān)所有圖形都畫(huà)在一塊畫(huà)布上沒(méi)有 DOM 節(jié)點(diǎn)更新時(shí)只需調(diào)用重繪函數(shù)。代價(jià)是你需要自己實(shí)現(xiàn)命中檢測(cè)、事件映射、局部更新等于瀏覽器的 DOM 能力沒(méi)法用了都得自己寫(xiě)。我在項(xiàng)目中的最終策略是主場(chǎng)景用 Canvas 渲染全部節(jié)點(diǎn)和邊保證性能和渲染上限浮動(dòng)的連接預(yù)覽線、我正在拖拽中的節(jié)點(diǎn)輪廓提醒這類(lèi)“臨時(shí)裝飾”用 SVG 疊在畫(huà)布上層因?yàn)檫@類(lèi)元素只存在于交互瞬間量小而靈活SVG 的語(yǔ)法表達(dá)更舒服。3.2 視口變換一張圖兩個(gè)坐標(biāo)系圖編輯器必須支持縮放和平移這是硬需求。實(shí)現(xiàn)縮放平移的核心是理解兩套坐標(biāo)系之間的換算世界坐標(biāo)World Coordinates數(shù)據(jù)模型里的 x、y 是真實(shí)坐標(biāo)節(jié)點(diǎn)之間的距離關(guān)系用世界坐標(biāo)描述它不隨視口變化。屏幕坐標(biāo)Screen Coordinates畫(huà)布上用戶實(shí)際看到的坐標(biāo)等于世界坐標(biāo)經(jīng)過(guò)平移和縮放變換之后的結(jié)果。兩者之間的換算公式簡(jiǎn)單直接// 世界坐標(biāo) → 屏幕坐標(biāo) screenX (worldX - viewportCenterX) * zoom canvasWidth / 2; screenY (worldY - viewportCenterY) * zoom canvasHeight / 2; // 屏幕坐標(biāo) → 世界坐標(biāo)用于鼠標(biāo)定位 worldX (screenX - canvasWidth / 2) / zoom viewportCenterX; worldY (screenY - canvasHeight / 2) / zoom viewportCenterY;看似簡(jiǎn)單但真做起來(lái)有兩個(gè)常見(jiàn)坑。第一個(gè)坑是縮放中心。用戶滾動(dòng)滾輪時(shí)通常希望鼠標(biāo)指向的位置在縮放后保持不動(dòng)也就是“以鼠標(biāo)為中心縮放”。實(shí)現(xiàn)方式是先記錄鼠標(biāo)在縮放前位于哪個(gè)世界坐標(biāo)縮放后把這個(gè)世界坐標(biāo)重新映射回鼠標(biāo)的屏幕位置從而反推出新的視口中心。公式如下const worldPointBeforeZoom screenToWorld(mouseScreenX, mouseScreenY); zoom clamp(zoom * factor, minZoom, maxZoom); // 將世界點(diǎn)重新投影到鼠標(biāo)處反推出視口中心 viewportCenterX worldPointBeforeZoom.x - (mouseScreenX - canvasWidth / 2) / zoom; viewportCenterY worldPointBeforeZoom.y - (mouseScreenY - canvasHeight / 2) / zoom;第二個(gè)坑是縮放邊界。無(wú)限縮放會(huì)讓畫(huà)布漂到看不見(jiàn)的地方或者把場(chǎng)景縮到比一像素還小。我設(shè)置了最小 10%、最大 500% 的縮放范圍同時(shí)在實(shí)現(xiàn)上用clamp函數(shù)把計(jì)算后的 zoom 鎖死。另一個(gè)隱藏問(wèn)題是縮放值如果是浮點(diǎn)數(shù)累計(jì)十幾次滾輪之后精度漂移會(huì)讓視圖抖動(dòng)。我干脆在縮放事件里只保留兩位小數(shù)實(shí)測(cè)后用戶基本感覺(jué)不到。3.3 命中檢測(cè)與框選Canvas 渲染模式下沒(méi)有 DOM 可以點(diǎn)擊所有交互都要自己判斷“鼠標(biāo)落在了哪個(gè)元素上”。最樸素的辦法是遍歷全部節(jié)點(diǎn)逐個(gè)判斷鼠標(biāo)點(diǎn)是否落在節(jié)點(diǎn)矩形內(nèi)。節(jié)點(diǎn)數(shù)量少時(shí)這么做可以接受但節(jié)點(diǎn)一多每次 mousemove 都全量遍歷性能就開(kāi)始露餡。我的優(yōu)化是建立一套空間索引按網(wǎng)格劃分畫(huà)布區(qū)域節(jié)點(diǎn)按所在網(wǎng)格注冊(cè)鼠標(biāo)移動(dòng)時(shí)只檢測(cè)鼠標(biāo)所在網(wǎng)格及相鄰網(wǎng)格內(nèi)的節(jié)點(diǎn)遍歷量直接下降一個(gè)數(shù)量級(jí)。const gridSize 64; const grid new Mapstring, DiagramNode[](); function getGridKey(x: number, y: number): string { return ${Math.floor(x / gridSize)},${Math.floor(y / gridSize)}; }網(wǎng)格的鍵是坐標(biāo)除以格子大小的取整結(jié)果節(jié)點(diǎn)插入時(shí)按其中心點(diǎn)所在的格子歸入對(duì)應(yīng) key。命中檢測(cè)時(shí)只查詢鼠標(biāo)點(diǎn)所在的三個(gè)格子當(dāng)前格、左側(cè)格、上側(cè)格里的節(jié)點(diǎn)做精確矩形判斷??蜻x的做法類(lèi)似拖拽出橡皮筋選框時(shí)遍歷受影響網(wǎng)格里的節(jié)點(diǎn)只要節(jié)點(diǎn)的矩形和選框矩形有交集就視為被選中。這里要注意的是框選時(shí)需要把屏幕坐標(biāo)的選框反算成世界坐標(biāo)再來(lái)判斷因?yàn)楣?jié)點(diǎn)存儲(chǔ)的是世界坐標(biāo)直接拿屏幕坐標(biāo)去比對(duì)會(huì)因?yàn)榭s放平移的干擾而錯(cuò)誤。我在實(shí)際實(shí)現(xiàn)中還發(fā)現(xiàn)一個(gè)細(xì)節(jié)節(jié)點(diǎn)選中狀態(tài)應(yīng)顯示在渲染層之外。選中節(jié)點(diǎn)的邊框、陰影屬于表達(dá)層狀態(tài)直接掛在臨時(shí)狀態(tài)對(duì)象里不會(huì)污染數(shù)據(jù)模型。這樣圖數(shù)據(jù)導(dǎo)出時(shí)不會(huì)包含多余信息重開(kāi)畫(huà)布時(shí)也不需要重建選中狀態(tài)。4. 實(shí)操過(guò)程從空白畫(huà)布到可交互編輯器4.1 環(huán)境準(zhǔn)備與基礎(chǔ)依賴(lài)開(kāi)始編碼之前我先明確依賴(lài)清單。這里列的是我實(shí)際用到的最小集合能少裝的絕不多裝TypeScript類(lèi)型系統(tǒng)核心依賴(lài)Vite開(kāi)發(fā)服務(wù)器和構(gòu)建工具主打一個(gè)快無(wú) UI 框架純 Canvas 場(chǎng)景框架在這場(chǎng)合幫不上什么忙初始化方式非常標(biāo)準(zhǔn)npm create vitelatest diagram-design -- --template vanilla-ts cd diagram-design npm install為什么不用 Vue 或 React因?yàn)?Canvas 渲染的主場(chǎng)景不依賴(lài)框架的虛擬 DOM節(jié)點(diǎn)的增刪改全由數(shù)據(jù)層觸發(fā)重繪框架反而會(huì)引入不必要的橋接邏輯。UI 面板、屬性編輯器這些外圍組件我是寫(xiě)在一個(gè)簡(jiǎn)單的 div 層上的直接用原生事件綁定即可。4.2 核心實(shí)現(xiàn)的逐步推進(jìn)我按下面這張順序表逐步推進(jìn)開(kāi)發(fā)每完成一步都會(huì)做一次手動(dòng)測(cè)試確保沒(méi)有崩壞第一步數(shù)據(jù)模型層先寫(xiě)出DiagramNode、DiagramEdge、Diagram的類(lèi)型定義和增刪改查方法。這個(gè)階段不碰渲染所有操作都只是純數(shù)據(jù)變化。第二步基礎(chǔ)畫(huà)布渲染創(chuàng)建 Canvas 并畫(huà)出一批靜態(tài)節(jié)點(diǎn)和邊驗(yàn)證整個(gè)數(shù)據(jù)到圖形的映射關(guān)系。程序跑通后我加了一個(gè)背景網(wǎng)格線方便觀察坐標(biāo)位置是否正確。第三步鼠標(biāo)事件接入實(shí)現(xiàn)鼠標(biāo)按下、移動(dòng)、釋放的事件監(jiān)聽(tīng)區(qū)分左鍵和右鍵行為。切換畫(huà)布模式為拖拽模式實(shí)現(xiàn)節(jié)點(diǎn)跟隨鼠標(biāo)移動(dòng)。第四步滾輪縮放與拖拽平移把視口變換邏輯接入實(shí)現(xiàn)以鼠標(biāo)為中心的縮放和畫(huà)布拖拽平移。第五步連線創(chuàng)建在兩個(gè)端口之間創(chuàng)建貝塞爾曲線。按下鼠標(biāo)出線移動(dòng)鼠標(biāo)彎曲釋放鼠標(biāo)完成連接。第六步撤銷(xiāo)重做接入快照機(jī)制。每次操作完成后壓棧撤銷(xiāo)時(shí)恢復(fù)上一狀態(tài)。第七步性能優(yōu)化加入網(wǎng)格索引、視口剔除、渲染節(jié)流。每一步之間我都跑了固定測(cè)試用例建 20 個(gè)節(jié)點(diǎn)連 30 條邊然后移動(dòng)、縮放、撤銷(xiāo)、重做確保沒(méi)有回歸。整個(gè)開(kāi)發(fā)周期大概用了三周半每天投入兩到三小時(shí)。時(shí)間的大頭花在連線的交互適配和框選的邊界問(wèn)題上這兩個(gè)點(diǎn)最容易出細(xì)節(jié) bug。4.3 連線交互的完整實(shí)現(xiàn)連線是整個(gè)編輯器中交互邏輯最復(fù)雜的一塊我來(lái)重點(diǎn)展示它的實(shí)現(xiàn)路徑。連線創(chuàng)建屬于一種特殊模式用戶從源端口點(diǎn)擊按下此時(shí)畫(huà)布進(jìn)入connecting模式鼠標(biāo)移動(dòng)時(shí)需要實(shí)時(shí)繪制一條從源端口到鼠標(biāo)位置的“預(yù)覽連線”釋放鼠標(biāo)時(shí)程序判斷目標(biāo)位置是否為合法的目標(biāo)端口合法則正式創(chuàng)建邊不合法則丟棄。預(yù)覽連線的形狀我用的是三階貝塞爾曲線。為什么用貝塞爾而不是直線或者折線因?yàn)樨惾麪柷€能以一條平滑曲線優(yōu)雅地連接兩個(gè)端口且可以根據(jù)兩個(gè)端口的朝向自動(dòng)調(diào)整控制點(diǎn)不需要我在數(shù)據(jù)模型里存儲(chǔ)路徑信息。曲線的控制點(diǎn)確定規(guī)則是源端口朝向是左側(cè)控制點(diǎn)就向左邊延伸目標(biāo)端口朝向是右側(cè)控制點(diǎn)就向右邊延伸。延伸距離取兩端口之間距離的一半這樣曲線彎曲幅度適中不會(huì)過(guò)彎也不會(huì)太直。function buildEdgePath(source: Point, target: Point, sourceDirection: Direction, targetDirection: Direction): Path2D { const dist Math.hypot(target.x - source.x, target.y - source.y); const offset dist * 0.5; const c1 offsetByDirection(source, sourceDirection, offset); const c2 offsetByDirection(target, targetDirection, offset); const path new Path2D(); path.moveTo(source.x, source.y); path.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, target.x, target.y); return path; }目標(biāo)端口的檢測(cè)也需要注意釋放鼠標(biāo)時(shí)取鼠標(biāo)所在坐標(biāo)調(diào)用空間索引找到覆蓋該坐標(biāo)的節(jié)點(diǎn)再判斷該節(jié)點(diǎn)上是否有端口在這個(gè)鼠標(biāo)位置附近。如果命中則創(chuàng)建正式邊如果沒(méi)有命中直接把預(yù)覽連線清掉。創(chuàng)建邊之前還多一步合法性校驗(yàn)一條邊不能連接同一個(gè)端口兩次也不允許從輸出端口連接到另一輸出端口具體取決于端口類(lèi)型定義。這些規(guī)則放在數(shù)據(jù)層渲染層不做判斷這樣可以保證無(wú)論如何操作底層數(shù)據(jù)不會(huì)進(jìn)入非法狀態(tài)。4.4 自動(dòng)布局與對(duì)齊輔助用戶手動(dòng)拖完一堆節(jié)點(diǎn)后圖必然歪歪扭扭。所以我在工具欄里加了兩個(gè)自動(dòng)整理功能層級(jí)布局和垂直/水平對(duì)齊。層級(jí)布局適用于流程類(lèi)圖把節(jié)點(diǎn)按照邊的方向分層同一層的節(jié)點(diǎn)排列成一列或一行層與層之間的距離相等。實(shí)現(xiàn)分三步拓?fù)渑判虼_定每個(gè)節(jié)點(diǎn)的層級(jí)。同一層的節(jié)點(diǎn)按原始橫向順序排列間距均勻。邊的交叉最小化我用的是一個(gè)簡(jiǎn)化的貪心算法逐層調(diào)整節(jié)點(diǎn)的順序讓相鄰層的邊交叉數(shù)量盡量小。垂直對(duì)齊和水平對(duì)齊的邏輯更簡(jiǎn)單選中多個(gè)節(jié)點(diǎn)取一個(gè)基準(zhǔn)坐標(biāo)比如最左側(cè)節(jié)點(diǎn)的 x 或所有選中節(jié)點(diǎn)的均值把其他節(jié)點(diǎn)的對(duì)應(yīng)坐標(biāo)改為基準(zhǔn)值即可。對(duì)齊操作不是一個(gè)“永久狀態(tài)”用戶隨時(shí)可以再做手動(dòng)調(diào)整所以它仍然落成數(shù)據(jù)層的坐標(biāo)修改配合撤銷(xiāo)重做棧一起使用沒(méi)有引入額外復(fù)雜度。4.5 性能優(yōu)化實(shí)戰(zhàn)性能問(wèn)題在節(jié)點(diǎn)數(shù)超過(guò) 300 之后開(kāi)始顯現(xiàn)。第一件導(dǎo)致卡頓的事是每次重繪都清空整個(gè)畫(huà)布并重畫(huà)全部節(jié)點(diǎn)和邊。優(yōu)化手段是視口剔除只繪制當(dāng)前視口可見(jiàn)范圍內(nèi)的元素function isInViewport(node: DiagramNode, viewport: Viewport): boolean { return ( node.x node.width viewport.left node.x viewport.right node.y node.height viewport.top node.y viewport.bottom ); }視口的邊界值直接由視口中心坐標(biāo)和縮放后的窗口尺寸計(jì)算得出。由于世界坐標(biāo)和視口邊界都經(jīng)過(guò)縮放換算這個(gè)判斷非常便宜。第二件導(dǎo)致卡頓的事是鼠標(biāo)移動(dòng)過(guò)程中觸發(fā)密集重繪。我引入了requestAnimationFrame節(jié)流移動(dòng)事件只更新?tīng)顟B(tài)標(biāo)記不直接重繪動(dòng)畫(huà)幀到來(lái)時(shí)檢查標(biāo)記若有變化才重繪。這樣把重繪頻率從 mousemove 的每次觸發(fā)可以高達(dá)幾百次/秒降到了屏幕刷新率60次/秒體驗(yàn)反而更平滑。第三件是字體渲染消耗。Canvas 每幀繪制大量文本字體解析很貴。優(yōu)化方案是把文本渲染單獨(dú)抽出來(lái)啟用ctx.font的緩存只有在字體字符串變化時(shí)才更新。實(shí)際測(cè)試中這個(gè)優(yōu)化帶來(lái)了 20% 左右的渲染耗時(shí)下降。5. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄5.1 高頻問(wèn)題速查表我把開(kāi)發(fā)過(guò)程中遇到的高頻問(wèn)題整理成一張速查表每一個(gè)都附了對(duì)應(yīng)的排查思路?,F(xiàn)象可能原因排查/解決方式拖拽節(jié)點(diǎn)時(shí)圖形跳動(dòng)事件綁定時(shí)誤用了局部坐標(biāo)而非全局坐標(biāo)確認(rèn)鼠標(biāo)事件計(jì)算的坐標(biāo)已通過(guò)getBoundingClientRect去除畫(huà)布偏移滾輪縮放后視圖抖動(dòng)縮放倍數(shù)是浮點(diǎn)累積導(dǎo)致精度漂移縮放后對(duì) zoom 值做四舍五入保留兩位小數(shù)連線總是對(duì)不準(zhǔn)目標(biāo)端口端口坐標(biāo)計(jì)算沒(méi)有考慮節(jié)點(diǎn)拖拽后的位移確保端口坐標(biāo)在每次渲染前按節(jié)點(diǎn)當(dāng)前世界坐標(biāo)重新計(jì)算撤銷(xiāo)一步后整個(gè)圖回到幾秒前快照覆蓋了整個(gè)畫(huà)布拖拽中間態(tài)也被記錄連續(xù)操作只記錄起始和結(jié)束快照中間態(tài)丟棄框選時(shí)選中了看不見(jiàn)的節(jié)點(diǎn)屏幕坐標(biāo)和世界坐標(biāo)混用框選矩形按視口變換反算為世界坐標(biāo)再進(jìn)行相交判斷Canvas 文字發(fā)虛或模糊高 DPI 屏幕下沒(méi)有做像素比適配畫(huà)布實(shí)際尺寸乘以window.devicePixelRatio再設(shè)置 Canvas 物理像素5.2 一個(gè)典型的數(shù)據(jù)恢復(fù) bug 復(fù)盤(pán)我想單獨(dú)講講之前遇到的一個(gè) bug它花了最長(zhǎng)的時(shí)間?,F(xiàn)象是用戶打開(kāi)一個(gè)保存過(guò)的圖形文件所有節(jié)點(diǎn)都在但節(jié)點(diǎn)之間原本存在的邊全部消失了或者邊的兩端都連到了奇怪的端口位置。初步排查時(shí)我懷疑是渲染層沒(méi)畫(huà)出來(lái)但控制臺(tái)輸出邊的數(shù)據(jù)是存在的。繼續(xù)追查發(fā)現(xiàn)邊的數(shù)據(jù)里記錄的sourcePortId和節(jié)點(diǎn)上的ports數(shù)組對(duì)不上——原來(lái)的端口 ID 在節(jié)點(diǎn)移動(dòng)時(shí)被重置了但邊的數(shù)據(jù)沒(méi)有同步更新。根因是節(jié)點(diǎn)有一個(gè)“重置端口”的操作邏輯會(huì)為端口生成新的 UUID但此時(shí)關(guān)聯(lián)邊仍然握著舊端口的 ID。因?yàn)闆](méi)有做級(jí)聯(lián)更新舊端口 ID 查無(wú)此人渲染層自然畫(huà)不出連線。修復(fù)辦法是在重置端口的邏輯里增加一步先遍歷所有關(guān)聯(lián)該節(jié)點(diǎn)的邊把邊的端口 ID 更新為新端口 ID然后再執(zhí)行重置操作。這個(gè)案例給到一個(gè)通用教訓(xùn)圖數(shù)據(jù)模型里凡是要改節(jié)點(diǎn)內(nèi)部標(biāo)識(shí)符ID的操作必須先檢查關(guān)聯(lián)邊的引用關(guān)系。這類(lèi)引用一致性問(wèn)題是圖編輯器的地雷往往不會(huì)當(dāng)場(chǎng)爆而是在用戶重新加載時(shí)爆。5.3 排除性能瓶頸的順序策略當(dāng)畫(huà)布開(kāi)始卡頓我的排查順序是先看是否在拖拽/縮放的動(dòng)畫(huà)幀里做了全量重繪。這是最常見(jiàn)的性能殺手占比超過(guò)一半。再看命中和渲染是否做了無(wú)謂的遍歷。例如每次鼠標(biāo)移動(dòng)觸發(fā)全量遍歷而不是網(wǎng)格索引。然后檢查是否有內(nèi)存泄漏導(dǎo)致的 GC 壓力典型情況是事件監(jiān)聽(tīng)器沒(méi)有解除。最后才看圖形本身的繪制開(kāi)銷(xiāo)比如陰影、漸變這類(lèi)視覺(jué)效果本身會(huì)拖慢渲染。前三個(gè)問(wèn)題都是代碼結(jié)構(gòu)問(wèn)題修復(fù)成本低效果立竿見(jiàn)影。第四個(gè)通常是為了視覺(jué)效果做出的主動(dòng)取舍需要靠視覺(jué)降級(jí)方案比如只在選中的節(jié)點(diǎn)上繪制陰影而不是所有節(jié)點(diǎn)來(lái)平衡。6. 關(guān)于擴(kuò)展方向的一點(diǎn)體會(huì)圖表設(shè)計(jì)做到這里基礎(chǔ)能力已經(jīng)完整了但我梳理下來(lái)發(fā)現(xiàn)還可以往幾個(gè)方向走。一個(gè)是把導(dǎo)出能力增強(qiáng)。目前只能導(dǎo)出 JSON 和圖片快照后續(xù)可以支持導(dǎo)出為 SVG 源文件、Markdown 內(nèi)嵌圖或者直接生成 PDF。導(dǎo)出 SVG 的時(shí)候需要注意Canvas 里的貝塞爾曲線要原樣轉(zhuǎn)成 SVG 的 path 元素文本節(jié)點(diǎn)需要轉(zhuǎn)成 text 元素并且保留字體的 family 和 size 屬性。另一個(gè)方向是多頁(yè)面支持。當(dāng)前的圖是一張單畫(huà)布如果業(yè)務(wù)上需要同時(shí)管理多張圖需要加一層項(xiàng)目對(duì)象把圖組織成樹(shù)形結(jié)構(gòu)支持跨圖復(fù)制節(jié)點(diǎn)和引用關(guān)系。再一個(gè)是協(xié)作能力。多人同時(shí)編輯同一張圖是個(gè)很誘人的擴(kuò)展。但要注意實(shí)時(shí)協(xié)作對(duì)數(shù)據(jù)模型的耦合度要求非常高節(jié)點(diǎn) ID 必須全局唯一、邊引用必須跨端保持一致、操作記錄需要支持 CRDT 或版本向量。我的體會(huì)是如果業(yè)務(wù)沒(méi)有剛需盡量晚點(diǎn)碰這一塊先保證單機(jī)體驗(yàn)足夠扎實(shí)。從實(shí)際交付的角度講單機(jī)圖表編輯器做好已經(jīng)能覆蓋大多數(shù)真實(shí)場(chǎng)景。做復(fù)雜系統(tǒng)的圖設(shè)計(jì)工具第一步永遠(yuǎn)是數(shù)據(jù)模型穩(wěn)不穩(wěn)而不是渲染漂不漂亮。我寫(xiě)的所有代碼里給之后項(xiàng)目復(fù)用最多的也是那套數(shù)據(jù)模型和狀態(tài)管理思路而不是任何一處渲染效果。最后分享一個(gè)實(shí)際調(diào)試中的小技巧節(jié)點(diǎn)多的時(shí)候不要靠人眼看圖找 bug我會(huì)在畫(huà)布里臨時(shí)按 F2 提前一層紋理把節(jié)點(diǎn) ID 直接打印在每個(gè)節(jié)點(diǎn)的左下角。雖然它不會(huì)出現(xiàn)在正式交付版本里但排查邊連錯(cuò)、端口錯(cuò)位、序列化還原不一致這類(lèi)問(wèn)題效率能提升好幾倍。