自研在线图形编辑器:数据模型、坐标变换与SVG/Canvas选型
diagram-design 这个名字看起来很朴素但真正动手把它做成一个能用的应用时你会发现它是一个典型的单个功能都很简单合在一起立刻复杂十倍的项目。我把它定位成一个运行在浏览器里的在线图表设计器用户通过拖拽节点、拉出连线可以画流程图、系统架构图、组织结构图这类关系型图形并且支持缩放、编辑、撤销、导出图片与保存 JSON 工程文件。这篇博文适合三类人打算自研图形编辑器的前端开发者、正在 SVG 和 Canvas 之间反复摇摆的选型困难户以及被现成绘图工具限制过交互细节的同行。我会把从数据模型、渲染方案、拖拽连线到导出落地的全过程拆开讲并附上实际踩坑记录。1. 项目本体与设计目标拆解1.1 标题背后到底藏了哪些需求diagram-design 如果只看字面意思很容易理解成画图但真正做的人会发现它的核心不是画而是表达关系。流程图表达的是先后顺序架构图表达的是模块之间的调用关系组织结构图表达的是汇报关系。所以设计重心必须落在两件事上节点本身的信息承载以及节点之间边的连接方式。我一开始把需求想得偏美术向花了很多时间打磨节点圆角、配色和阴影结果真正常被问到的反而是三个基础问题节点能不能拖到任意位置、连线能不能始终跟着节点走、画完能不能导出到别处用。这三件事没有一件是画得好看能解决的它们全部指向底层数据模型和交互设计。从这个角度拆解diagram-design 的核心循环其实只有四步在画布上放置带语义的节点在节点之间创建带方向的边移动节点时边自动保持连接关系把整张图序列化保存或导出。这个循环一旦打通剩下的样式、主题、多选都是锦上添花。所以我给自己定了一个原则先打通循环再谈视觉效果。1.2 功能边界怎么划才不过度设计第一次做这类项目的人很容易陷入功能越多越好的幻觉。我一开始也列了满满一张纸多选、对齐辅助线、主题切换、背景网格、快捷键、迷你导航……列完之后发现如果按这个范围做下去三个月都不一定收得了尾。我最后把需求切成两层。第一层是 MVP必须包含两种基础节点矩形和圆形节点文本编辑单节点拖拽移动节点与节点之间拉线连线支持直线和正交折线画布平移与缩放删除节点和连线撤销与重做导出 SVG / PNG导入导出 JSON。第二层是后续扩展比如多选框选、对齐参考线、主题模板、自由绘制、多人协同等全部留到 MVP 跑通之后再做评估。这个划分的直接好处是我一周内就获得了一个可以演示的版本。很多问题只有亲手拖两下才会暴露光靠设计文档是想象不出来的。1.3 核心决策为什么我选择自研而不是接现成方案市面上其实有不少成熟绘图方案有些已经把编辑体验做得很完善直接嵌入也能用。但它们的通病是你想修改一个交互细节往往要绕很大一圈甚至被迫接受别人设计好的插件机制。diagram-design 本质上是一次对图形编辑器核心机制的拆解练习所以我选择了自研。自研的代价很大相当于重写那些别人已经解决过的问题坐标变换、命中检测、事件竞争、连线路由算法。但好处同样直接——每一层都掌握在自己手里。这里可以打个比方买精装房和自建房的区别。精装房拎包入住很香但要改承重墙就没门自建房前期麻烦但结构全清楚后面加任何功能都心里有底。另外这类编辑器往往需要和具体业务深度集成比如从后端数据自动生成图表、按业务规则动态更新样式。这种场景下自研反而比改造现成方案更高效。当然如果只是做内部工具且工期极紧使用成熟方案也是完全合理的选择关键还是评估定制深度。2. 核心数据结构与渲染机制设计2.1 一份图表文档的本质点、线、画布在写任何 UI 之前我先把文档模型定义清楚了。所谓文档模型就是一张 diagram 在内存里到底长什么样。这一步不能省我刚开始起手时直接去写 SVG 标签结果做到一半发现保存功能没法做因为节点坐标全散落在 DOM 属性里根本没有一份统一的模型数据。我用 TypeScript 定义了一套最小模型大致结构如下interface NodeData { id: string; type: rect | circle; x: number; y: number; width: number; height: number; text: string; style?: Recordstring, string; } interface PortRef { nodeId: string; side: left | right | top | bottom; } interface EdgeData { id: string; source: PortRef; target: PortRef; type: line | orthogonal; } interface DiagramDocument { nodes: NodeData[]; edges: EdgeData[]; }设计这个模型时最关键的决定是连线的两端不存固定坐标而是引用某个节点上的某个锚点。也就是说一条线记住的是从哪个节点哪个边出发连到哪个节点哪个边而不是从 100,200 这个点到 400,300 这个点。为什么要这样因为节点的位置时刻在变。如果边把坐标写死了每次拖动节点都得同步更新所有边稍有不慎就会断线。采用锚点引用后连线路径可以在渲染阶段动态计算从源端口到目标端口实时生成这样节点怎么移动边都不会飘。2.2 坐标系和视口变换一切交互的地基图形编辑器逃不开两套坐标系模型坐标和屏幕坐标。模型坐标是图文档自己的坐标系和画布缩放、平移无关屏幕坐标是用户鼠标实际呆的地方。中间必经的一步是换算换算错误会引发各种诡异现象最典型的就是缩放后线条和节点对不上。我维护了一个 viewport 对象interface Viewport { scale: number; tx: number; ty: number; }模型坐标转换成屏幕坐标公式如下function modelToScreen(p: Point, v: Viewport) { return { x: p.x * v.scale v.tx, y: p.y * v.scale v.ty }; } function screenToModel(p: Point, v: Viewport) { return { x: (p.x - v.tx) / v.scale, y: (p.y - v.ty) / v.scale }; }这里有一条铁律所有持久化数据必须存在模型坐标里。屏幕坐标只是当前视口下的临时投影渲染的时候再实时换算。这样无论用户怎么缩放平移底层数据都是稳定的。网格吸附也在这层做。比如拖动结束时把节点坐标按网格对齐const size 10; const align (v: number) Math.round(v / size) * size; node.x align(node.x); node.y align(node.y);这种吸附修改的是模型坐标因此不会因为缩放级别不同出现看起来对齐了、实际坐标没对齐的问题。2.3 渲染层选型SVG 和 Canvas 该怎么取舍图形编辑器最常见的渲染选型之争就是 SVG 还是 Canvas。我的结论是对这种编辑器场景SVG 是更合理的默认选择。下面的表格列出了关键对比维度SVGCanvas元素粒度每个节点/边是独立 DOM 元素整张画布是一块位图事件命中天然支持点击元素就能感知需要自己根据坐标做命中检测修改局部直接改某个元素的属性即可每次局部变化通常要重绘较大区域节点规模几百个以内流畅几千上万个也能撑住序列化元素属性天然可读需要手动维护模型再导出样式控制CSS 可细粒度设置基本靠绘制代码diagram-design 这类项目的节点数量通常不会爆炸式增长几百个节点已经是相当复杂的架构图了。这个量级下SVG 的灵活性和开发效率远超 Canvas。尤其在做拖拽、连线、命中检测时SVG 自带的 DOM 事件系统能省掉大量手写几何计算。我这里选择的是SVG 渲染主体 单独做一个位图背景层来画网格。网格变化频繁放 Canvas 里就不会触发 SVG 的样式重算。这样既不牺牲开发效率也把高频重绘的开销隔离开。3. 实操过程从零搭一个可用的 Diagram 编辑器3.1 工程搭建与模块划分我采用了 TypeScript Vite 作为基础工程渲染层直接用原生 SVG 操作。没有引入前端框架是因为这类交互密集型的编辑器在响应 pointer 事件、更新坐标时需要非常直接地控制 DOM少一层框架抽象反而更好排查问题。工程目录这样划分diagram-design/ src/ core/ model.ts // 数据模型 viewport.ts // 视口变换 history.ts // 撤销重做 render/ renderer.ts // SVG 渲染 orthogonal.ts // 折线路由 interaction/ drag.ts // 节点拖拽 connect.ts // 连线交互 utils/ geometry.ts // 矩形相交、点命中检测 app.ts模块划分的原则是core 层不带任何 DOM 操作只负责数据和计算render 层只负责把模型变成 SVGinteraction 层负责监听事件并修改模型。这三层相互独立后面加新功能时不容易一改全崩。3.2 节点拖拽与选中事件绑定是最大的坑拖拽的逻辑听起来很简单按下节点、移动时改坐标、松开时结束。但实际写起来有几个容易翻车的细节。我的实现思路是let dragging: { nodeId: string; offsetX: number; offsetY: number } | null null; svg.addEventListener(pointerdown, (e) { const pos screenToModel(clientPos(e)); const node hitTestNode(pos); if (node) { dragging { nodeId: node.id, offsetX: pos.x - node.x, offsetY: pos.y - node.y }; } }); window.addEventListener(pointermove, (e) { if (!dragging) return; const pos screenToModel(clientPos(e)); const node getNode(dragging.nodeId); node.x pos.x - dragging.offsetX; node.y pos.y - dragging.offsetY; updateNodeGraphic(node); // 只更新当前节点的 SVG 属性 }); window.addEventListener(pointerup, () { dragging null; });这里最重要的坑是不要把 pointermove 监听绑定在节点元素上而要绑定到 window。因为鼠标拖动的速度很可能超过节点本身的移动速度一旦光标暂时离开节点元素事件就会丢失拖拽就会卡住。用 window 监听能保证整个拖动过程中事件不会断。另一个细节是 offsetX 的保存。如果不记录按下时光标和节点左上角的偏移直接让节点坐标等于光标坐标节点会瞬间跳到光标中心体验非常突兀。记录偏移是一个很小的步骤但对交互手感影响极大。坐标换算我直接用 SVG 的矩阵求逆实现避免自己处理 CSS 尺寸和 viewBox 之间的比例关系function clientPos(e: PointerEvent): Point { const ctm svg.getScreenCTM(); if (!ctm) return { x: 0, y: 0 }; const pt svg.createSVGPoint(); pt.x e.clientX; pt.y e.clientY; return pt.matrixTransform(ctm.inverse()); }3.3 连线交互与锚点判定画线是 diagram 编辑器的灵魂功能。我的交互逻辑分成三步从某个端口拉出一条临时线在拖动过程中临时线跟随光标松开时判断目标是否落在某个端口附近如果命中就生成正式边。这里需要在节点上定义端口位置。矩形节点的四个边中点可以作为天然端口我统一计算function portPosition(node: NodeData, side: PortSide) { const cx node.x node.width / 2; const cy node.y node.height / 2; const offset 4; switch (side) { case left: return { x: node.x - offset, y: cy }; case right: return { x: node.x node.width offset, y: cy }; case top: return { x: cx, y: node.y - offset }; case bottom: return { x: cx, y: node.y node.height offset }; } }拉线时先判断光标是否按到了端口附近function hitPort(pos: Point): PortRef | null { const threshold 8; for (const node of doc.nodes) { const candidates: PortSide[] [left, right, top, bottom]; for (const side of candidates) { const p portPosition(node, side); if (Math.hypot(pos.x - p.x, pos.y - p.y) threshold) { return { nodeId: node.id, side }; } } } return null; }临时线跟着光标走的时候同样要走一遍坐标换算否则缩放之后线会跟鼠标错位。这个交互做完之后整张图才第一次活了起来。3.4 正交折线路由的够用实现连线如果全部用直线节点多了以后会非常乱互相穿过所以正交折线是刚需。完整的正交路由算法可以写一篇论文但项目里用够用实现就很好。我的策略是根据源端点和目标端点的相对位置选择先水平走还是先垂直走。如果两点横向距离更大就水平—垂直—水平否则垂直—水平—垂直。function orthogonalPath(sx: number, sy: number, tx: number, ty: number) { const dx Math.abs(tx - sx); const dy Math.abs(ty - sy); if (dx dy) { const mx (sx tx) / 2; return M ${sx},${sy} H ${mx} V ${ty} H ${tx}; } else { const my (sy ty) / 2; return M ${sx},${sy} V ${my} H ${tx} V ${ty}; } }起点和终点不要用端口本身而要向外延展一小段距离否则折线会直接穿进节点内部很难看。我在 portPosition 里已经加了 4 像素偏移实际渲染时再根据连接方向加一段 12 像素的引出线视觉上会舒服很多。对于更复杂的避障比如绕过其他节点可以先不处理。我实测下来绝大多数图表场景里两端点位置合理的 H-V-H 路径已经够用。3.5 画布平移缩放与网格吸附画布缩放我希望以光标位置作为缩放中心这样鼠标放在哪里哪里就保持不动体验接近大多数地图应用。核心做法是缩放前算出光标对应的模型坐标改变 scale 之后调整 tx 和 ty 让这个模型坐标仍然落在光标下。function zoomAt(px: number, py: number, delta: number) { const factor delta 0 ? 1.1 : 1 / 1.1; const before screenToModel({ x: px, y: py }, viewport); viewport.scale * factor; const after screenToModel({ x: px, y: py }, viewport); viewport.tx (after.x - before.x) * viewport.scale; viewport.ty (after.y - before.y) * viewport.scale; render(); }这里的坑是记住要先通过旧 viewport 求出光标下的模型点再修改 scale最后反推平移量。顺序反了的话画面会跳走。平移则比较简单通常按住画布空白区域拖拽即可。我在 pointerdown 时判断没有命中任何节点、也没有命中端口就进入 pan 模式记录初始 clientX/clientY 和初始 viewport.tx/ty然后随 pointermove 累加差值。网格吸附做得克制一些。我只在节点拖动过程中把坐标对齐到 10 像素网格而且只吸附 x 和 y 的最终值不吸附中间的临时位置。这样可以避免拖动时有一顿一顿的难受手感只在松手时对齐。3.6 撤销重做与 JSON 序列化撤销重做如果不提前设计后期加会非常痛苦。我采用的是最直接的快照方案每次产生语义操作拖拽结束、增删节点、增加连线、编辑文本时把整个文档序列化为 JSON 压栈。function snapshot() { history.push(JSON.stringify(doc)); if (history.length 100) history.shift(); future.length 0; } function undo() { const prev history.pop(); if (!prev) return; future.push(JSON.stringify(doc)); doc JSON.parse(prev); render(); } function redo() { const next future.pop(); if (!next) return; history.push(JSON.stringify(doc)); doc JSON.parse(next); render(); }快照方案听着笨但胜在实现简单、不会出现漏记录某类操作的 bug。一百步以内的图表编辑操作JSON 序列化的开销完全可以接受。唯一要注意的是不要在 pointermove 这种高频事件里做快照而要在 pointerup 或者确认变更的时机做一次否则内存会迅速膨胀。序列化和反序列化是整个项目最早实现的能力之一。得益于核心模型是纯数据对象JSON.stringify(doc) 就能完整保存整张图加载时 JSON.parse 再重新 render 即可。3.7 导出 SVG 和 PNG导出 SVG 比较简单把当前渲染用的 SVG 元素序列化即可const clone svg.cloneNode(true) as SVGSVGElement; // 把 viewBox 属性设置为完整内容区域 const svgString new XMLSerializer().serializeToString(clone); const blob new Blob([svgString], { type: image/svgxml });导出 PNG 需要把 SVG 绘制到 Canvas 上const image new Image(); const url URL.createObjectURL(blob); image.onload () { const canvas document.createElement(canvas); canvas.width image.naturalWidth; canvas.height image.naturalHeight; const ctx canvas.getContext(2d)!; ctx.fillStyle #ffffff; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(image, 0, 0); canvas.toBlob((pngBlob) { // 下载 pngBlob }); }; image.src url;这里有个隐蔽的坑如果 SVG 内容里带有外部字体或图片Canvas 绘制时可能因为跨域或字体没加载而丢掉样式。我最后的处理是把所有文本都转成系统字体字号直接换算为像素导出前强制完成图片预加载保证导出的 PNG 与画布一致。4. 常见问题与排查技巧实录4.1 节点多了之后拖拽明显卡顿现象节点数量超过一百个后拖拽一个节点明显掉帧。排查后发现原因是每次移动都重新遍历全部节点并逐个更新它们的 SVG 属性。也就是说动一个节点却把所有节点都重绘了一遍。解决办法是分层更新拖拽过程中只更新被拖拽节点的 transform 属性最多再更新与之相连的边路径其余节点完全不碰。等拖拽结束、快照完成后再做一次整体刷新。另一个优化是给高频的坐标更新套上requestAnimationFrame合并同一帧内的多次重复赋值。4.2 缩放以后线条和节点位置对不上现象放大或缩小之后节点和边的端点出现肉眼可见的错位明明画的时候是连着的。排查了半天根因是某次实现边路径时在事件回调里直接把屏幕坐标写进了边的固定属性。这样缩放一变屏幕坐标和模型坐标的比例不一致自然就对不上。解决方式就是前面强调的凡是进入模型的数据必须通过screenToModel先转换成模型坐标再存渲染时再通过modelToScreen转换。我后来在模型层加了一个约定——所有持久化数据禁止出现屏幕坐标系下的数值代码 review 时重点盯这一点。4.3 线跟着节点移动但总是飘现象拖动源节点时边的端点跟着走了但没有从端口位置出发而是从节点左上角或者某个奇怪位置出发。原因是在创建边时我把端口引用简化成了 nodeId没有保存是哪一侧的端口。当节点位置变化后系统只能从节点中心或者默认位置重新计算端点所以线看起来是悬空的。修复方式就是完整保存PortRef包含 nodeId 和 side渲染时从portPosition(node, side)计算起点终点。这个教训让我意识到数据模型多保留一点语义信息后面修复问题会省非常多的时间。4.4 典型问题速查表症状可能原因处理方式拖动节点会跳一下没有记录按下时光标到节点的偏移保存 offsetX/offsetY移动时减掉偏移拖动过程中事件丢失pointermove 绑到节点而不是 window统一绑定到 window节点只负责命中缩放后位置错乱把屏幕坐标存进了模型数据所有持久数据一律模型坐标边跟不上节点边保存固定坐标而不是端口引用边的端点实时从 PortRef 计算撤销一次撤销了多次操作在 pointermove 里做了快照只在 pointerup 或语义变更时快照导出的 PNG 样式缺失外部资源未加载或跨域内联样式、预加载图片、字体转系统字体折线穿过节点内部起点终点直接用了节点坐标端点从端口外侧向外延伸后再路由5. 做完之后我的一些体会和扩展方向5.1 亲测下来的项目节奏经验做完这个项目我最强烈的感受是图形编辑器里数据结构比交互技巧重要得多。很多让用户抓狂的 bug比如线飘、位置错乱、撤销异常本质都是数据模型设计不合理导致的。反过来只要模型定义得干净交互层再折腾也乱不到哪里去。还有一个节奏上的建议一定要尽早做一个可拖拽、可连线的最小闭环哪怕界面丑到没法看。真实拖动时的坐标换算、事件竞争只有亲自上手才能感知拖了第一次之后你才会理解为什么需要 screenToModel为什么 offset 必须存为什么端口要引用 side。这些感觉靠读文档是读不出来的。5.2 想继续做深可以从哪些方向入手如果继续把这个项目往下做我认为最有价值的方向有三个。第一个是框选和参考线对齐这能把编辑体验从能用提升到好用但会引入多节点间的几何运算需要额外处理。第二个是更完整的节点扩展体系比如支持图片节点、表格节点、内嵌组合节点核心是让 NodeData 成为一个可扩展的联合类型。第三个是基于这份纯 JSON 模型做更多业务联动比如让后端数据映射到节点、自动布局、动态样式等这些能力只有自研时才有可能顺畅实现。我自己在这轮做完之后最大的变化是再看任何绘图类产品时不再只会感叹功能好多而是会自动拆解它的节点模型、连接模型、坐标变换方式。diagram-design 这个名字看起来平平无奇但把它拆透之后你基本就摸到了图形类前端应用最核心的那根脉搏。