首页 / 资讯中心 / 文章详情

自研图表设计器:从TS+SVG到数据模型与交互实现的技术复盘

自研图表设计器:从TS+SVG到数据模型与交互实现的技术复盘 ★ FEATURED ARTICLE
半年前我启动了一个叫 diagram-design 的小项目目标很单纯做一个可嵌入到任意业务系统里的图表设计器用来画架构图、流程图、网络拓扑图而不是再被绑死在某个在线画图平台上。这个项目从零开始不依赖任何重量级图形框架纯 TypeScript SVG 实现最终产物是一个 npm 包业务方可以拿来即用也能通过暴露的事件和 API 做二次开发。做完之后我觉得这套东西挺值得沉淀所以今天把技术选型、数据结构设计、交互实现的完整思路以及我踩过的坑一次性写出来。1. 为什么坚持自研图表设计器1.1 现成方案让人又爱又恨市面上其实不缺现成的开源图表编辑器但真正要落到业务里的时候你会发现几个绕不开的问题。第一是样式定制困难。别人的界面默认风格和公司的设计语言往往格格不入你要花大量时间去覆盖样式改来改去最后还是觉得别扭。第二是不好嵌入。很多现成方案是一个完整应用不是一个可复用组件集成进来以后整个页面结构都被它带跑偏想塞进一个功能模块里非常费劲。第三是数据模型不透明。业务数据要映射成它内部的格式得写一大堆胶水代码产品需求一变胶水代码跟着全崩。我自己在一次交付里被这种集成方式折腾了快两个星期才最终决定自己写一个足够轻量、足够可控的图编辑器内核。这里想提醒一下想自研的人如果只是临时用一次直接找个现成方案拖进去最快但如果你有三个以上项目都要用到图表编辑能力自研一个通用内核是值得的边际成本会随着项目数量增加快速摊薄。1.2 项目边界与目标定义任何自研工程最怕一开始就想着大而全。diagram-design 从第一天起就定了四条铁律只做 2D 图表不做自由绘图不做思维导图核心是“节点-连线”模型支持多类型节点、正交/曲线边对外暴露序列化数据结构和事件UI 可以自定义必须能嵌入主流的视图框架页面以及原生页面不依赖特定框架绑定。之所以这样限定是因为在实际业务里90% 的场景就是三种架构图、流程图、拓扑图。它们都可以抽象成节点和连线的图模型只要把这一层做透了剩下的都是边缘需求。这个边界划定帮我挡掉了至少一半的无效沟通也让核心渲染和交互能集中精力做扎实。比如有人提“我要能画泳道图”其实泳道图就是分组容器加节点有人提“我要能用笔自由画线”这就属于自由绘图范围我会直接拒绝。边界清晰之后每一次功能迭代都能快速判断“该不该做、做的话放在哪一层”避免项目变成一个四不像的大杂烩。1.3 技术栈选型背后的取舍这个项目最终选了 TypeScript SVG没有用 Canvas也没有上 WebGL。每个选择都有明确原因。TypeScript 几乎是必然的。图数据结构复杂节点、边、样式、数据属性类型各异没有类型系统的话后面重构会非常痛苦。我在早期版本用过纯 JavaScript 写等到属性面板和序列化功能加进来以后改一个字段名就要满世界找引用后来狠下心重写了一遍才体会到类型系统在这种数据密集型项目里的价值。SVG 的选择则取决于目标场景。节点数量在几百到一两千的时候SVG 性能完全够用而且每个图元都是 DOM事件绑定、样式控制、局部更新都非常直接调试时像看 HTML 一样查元素就行。Canvas 的问题在于命中检测要自己算、重绘要自己控制、文本排版要自己模拟一套组合拳下来工程量直接翻倍。对比维度SVGCanvas实现难度低DOM 即图元高需要自行管理命中与重绘事件绑定每个图元天然支持事件需要坐标换算后手动判断图层大量图元上千节点后 DOM 开销上升高几千节点也能保持流畅导出可直接序列化 SVG 字符串导出 PNG 需要额外绘制调试成本浏览器 DevTools 可直接查看结构黑盒调试依赖 log 和可视化辅助如果你的目标场景是几千上万节点同时展示Canvas 或者 WebGL 会更合适但那就是另一个故事了。对多数业务系统来说SVG 的平衡点是最舒服的。2. 整体架构与数据模型设计2.1 图数据的核心结构设计数据模型时我参照了图论里最基本的“顶点和边”的概念。节点统一叫 Node连线统一定义为 Edge。Node 需要承载的类型包括矩形节点、圆角节点、分组容器、备注文本等。我没有为每种节点单独定义一套数据结构而是用一个统一的接口加一个 type 字段这样渲染器、序列化、属性面板可以共用同一套代码。interface DiagramNode { id: string; type: rect | round-rect | group | note; x: number; y: number; width: number; height: number; style?: Recordstring, unknown; data?: Recordstring, unknown; }Edge 稍微复杂一点因为不仅要记录从哪个节点到哪个节点还要记录具体的连接锚点、路径点以及线的类型。interface DiagramEdge { id: string; source: string; target: string; sourceAnchor: top | bottom | left | right; targetAnchor: top | bottom | left | right; route: { kind: straight | orthogonal | bezier; points: Point[] }; style?: Recordstring, unknown; }之所以不把路径点保存在 Node 里是因为 Edge 是独立可序列化的对象路由计算只需要读 Edge 和关联节点的位置就能独立完成这样后续做撤销重做和多人协同同步都会轻松很多。如果你把路径点埋在 Node 内部每次节点移动都要去遍历寻找到底有哪些边引用了它维护成本立刻上来了。2.2 为什么用纯函数处理图数据这个项目里有一个设计原则贯穿始终对图数据的修改尽量做成纯函数。所谓纯函数就是输入一个旧的图数据加上操作参数返回一个新的图数据不改动原始对象。比如移动节点不是直接修改node.x而是调用moveNode(graph, nodeId, dx, dy)生成一份新的 graph。这个习惯带来的好处比想象中大得多。第一撤销重做只需要保存历史 graph 快照不需要记录复杂的操作序列第二状态变化可以通过结构性比较来追踪哪些节点变了、哪些边变了一目了然第三如果后续做协作文档把操作日志同步给对方就能重放整个状态。代价是每次操作都需要做一次对象的浅拷贝内存占用会高一些。实际测试下来几百个节点的一次移动操作生成一份新 graph 的耗时在毫秒级完全可接受。比起手动同步多个对象状态带来的 bug这笔开销非常划算。2.3 渲染层的分层设计SVG 画布不是把所有元素堆在一起的。我分了五个层每个层有明确职责gridLayer网格背景edgeLayer所有连线nodeLayer所有节点selectionLayer选中框、锚点、拖拽中的新建图元overlayLayer吸附提示线、坐标提示、测量辅助分层的核心原因其实是 z-index 管理。SVG 的绘制顺序就是文档顺序如果不分层节点拖到最上层时连线要么被盖住、要么反过来盖住节点非常难看。有了层之后往某个层里添加子元素之前先移除这个层里的旧内容其他层完全不受影响。另外层和层之间的渲染是隔离的比如网格层即使节点再复杂也不需要跟着刷新性能上也是个隐性优化。我在设计初期没有划分这么细后来发现选中框经常被节点盖住才下决心重构出五个层属于用一次踩坑换来的经验。3. 交互层拖拽、缩放、连线是怎么实现的3.1 视口变换与坐标换算图表编辑器几乎都有一个基础能力画布平移和缩放。diagram-design 用viewport对象来保存比例尺scale和偏移量offsetX/offsetY所有渲染坐标都是先经过世界坐标换算成屏幕坐标再绘制。屏幕坐标 世界坐标 * scale offset鼠标事件拿到的是屏幕坐标要换算回世界坐标世界坐标 (屏幕坐标 - offset) / scale这个公式看起来简单但坑特别多。尤其是鼠标滚轮缩放时很多初学者直接把scale改掉结果发现画面缩放后鼠标指向的位置跑了。正确的做法是以鼠标所在位置为缩放中心先算出鼠标下的世界坐标缩放后再把这个世界坐标映射回同一个屏幕点。offsetX mouseX - (mouseX - offsetX) * (newScale / oldScale)我当时第一次实现就漏了这一步交互手感非常差后来才补上。这里强烈建议所有坐标换算统一收敛到一个函数里不要在业务代码里到处手动算。我封装了一个getWorldPoint(e)入口所有鼠标事件都从这里拿世界坐标之后再也没出过坐标错位的问题。3.2 节点拖拽与连线联动拖拽节点是最常用的操作实现链路是mousedown命中的节点记录为当前拖拽对象mousemove更新节点 x/y并触发 graph 更新所有与这个节点关联的 Edge 重新计算路由最后通过requestAnimationFrame批量渲染。这里要小心性能问题。如果每个mousemove都触发全量重渲染一是浪费二是在 SVG 里会频繁调度 DOM 更新节点多了会肉眼可见的卡。我的做法是mousemove只更新数据和脏标记在 rAF 回调里统一渲染一次。实测在 500 个节点以内拖拽都是丝滑的。连线联动还有一个细节边不需要立刻重算路由可以在节点停止移动后再重算但这样中间会出现线不跟手的情况。更好的方案是拖拽过程中使用“轻量路径更新”只平移旧路径的端点不重新做完整路由计算等mouseup后再做一次精细路由。这样视觉上跟手性能也稳得住。3.3 连线路由算法从直线到正交折线连线有三种形态直线、正交折线、贝塞尔曲线。直线最简单两个锚点之间画一条线段就行正交折线是架构图和流程图中最常用的也是实现起来最麻烦的。正交折线的思路是根据源节点锚点方向比如 right确定出线方向根据目标锚点方向比如 left确定入线方向在两个方向之间插入中间点通常是一条水平线加一条垂直线或者反过来如果两个节点大概在同一水平线还需要做偏移避免线和节点重叠。我参考了常见的简化做法先做“曼哈顿路由”的简化版找源节点中心点和目标节点中心点之间的水平/垂直中点再连线。虽然简单但胜在稳定。更复杂的障碍物避让算法属于专门的路径搜索话题目前这个项目只做了分组容器内的简单边界检测。贝塞尔曲线则适用于拓扑图、网络图看起来更柔和。控制点取锚点方向向外延伸一段距离这段距离取两个节点中心距离的一半即可。这样画出来的曲线很自然不会因为节点一远一近而出现诡异的扭曲。连线类型适用场景实现复杂度说明straight节点较少、关系简单最低两端点直接连线orthogonal架构图、流程图中需要计算中间折点避免穿节点bezier拓扑图、网络图中高控制点计算需要调试曲线才自然3.4 锚点吸附与对齐辅助吸附功能是让用户觉得“这工具好用”的关键之一。diagram-design 里每个节点提供 8 个吸附点上下左右四个边中点和四个顶点。当拖拽节点靠近其他节点的吸附点时如果距离小于阈值默认 6px就把当前节点坐标修正为吸附点对应的偏移位置并绘制一根临时辅助线。这个阈值的选取有讲究。阈值太大容易误吸用户会感觉节点被“粘住”太小又不容易触发。6px 是我试下来比较舒服的档位同时它也会乘上 scale 做缩放适配缩小画布时阈值不会跟着变小否则在小缩放比例下吸附几乎触发不了。对齐辅助线的实现也比较直接在 overlayLayer 上画一条虚线从目标节点边缘延伸到画布边缘。它只提供视觉参考不强制用户对齐体验更自由。后来我还加了按住空格临时“关闭吸附”的功能给需要像素级微调的用户留一条后路。4. 撤销重做、序列化与导出4.1 撤销重做的实现取舍我在项目里没有用命令模式而是用了快照式。原因很直接图数据是一个比较紧凑的 JSON 结构一个普通图可能就几十到几百 KB快照一次成本并不高而命令模式需要为每一种操作都写反向操作工程量巨大而且很容易和业务方的自定义操作冲突。实现方式是维护两个栈undoStack保存历史 graph 快照redoStack保存被撤销的快照。每次图数据变更除了拖拽过程中的中间态都 push 一份快照限制栈最大容量为 100。这里特别值得说的是拖拽过程中的节流策略拖拽产生的中间状态不上栈只在mouseup时记录最终结果。否则拖拽一次就会产生几十个快照内存吃不住。我一开始没做这个优化连续拖拽十几次以后浏览器肉眼可见地变慢后来加了“拖拽完成后入栈”的规则才解决。4.2 序列化设计序列化的目标有两个一个是持久化到后端另一个是复制粘贴。所以我对数据格式做了严格约束只允许 JSON 可序列化的对象不允许函数、Date、Map 等类型出现在 graph 里。Node 的 style、data 和 Edge 的 style 都是普通对象这样JSON.stringify就能直接输出完整文件。这里还有一个重要经验version 字段一定要加。图表数据格式会随着功能迭代不断变化没有 version 字段旧数据要么没法读要么读出来一团糟。我在 v1 发布后已经做了两次数据迁移逻辑都靠这个字段定位和处理。序列化还有一个作用是为复制粘贴服务。用户复制一块区域时我直接把这块区域的节点和边序列化成 JSON 放进剪贴板粘贴时再反序列化并重新生成 id。这个方案又简单又通用不需要额外处理内部格式。4.3 导出 SVG 和 PNG 的细节导出 SVG 我最初以为只要把画布 innerHTML 拿出来拼一个 svg 标签就行后来发现远远不够。SVG 里如果用了外部字体、CSS 变量、filter 特效导出的文件在别的软件里可能完全变形。稳妥做法是构建一个独立的 SVG 字符串把需要的样式以内联 style 或内联属性写进去而不是直接复制 DOM。导出 PNG 我采用的方法是把 SVG 字符串用 Blob 生成 object URL然后用 Image 加载再绘制到 canvas最后toDataURL。这里有两个常见的坑图片模糊canvas 的尺寸必须乘以devicePixelRatio否则在高分屏上导出会模糊背景透明如果不先填充白色背景导出 PNG 是透明的业务方往往不希望这样所以我在绘制前先fillRect白色背景。这两个坑我都是上线之后才被测试发现的。导出这个功能看起来简单实际要细心的地方特别多强烈建议在项目早期就写进验收文档里。5. 常见问题与排查实录5.1 拖拽节点后连线纹丝不动我遇到过节点拖到新位置但连线还是原来的路径。排查后发现问题出在mousemove里只更新了 node 的 x/y没有联动触发 Edge 的重算。解决方案是把“所有关联边重算”放到节点变更的公共函数里而不是散落在各个交互处理函数中。改成统一入口之后这个问题再没出现过。这种问题的排查思路其实很简单先确认数据变了没有再确认渲染逻辑读的是不是最新数据。很多时候交互 bug 都是“数据和视图脱节”造成的。5.2 缩放时鼠标位置漂移这个问题在上面提过根本原因是滚轮缩放没有以鼠标点为缩放中心。最初我以为只是数学问题后来发现在不同浏览器下事件对象的offsetX/offsetY在 SVG 内还有兼容性问题所以我统一用getBoundingClientRect换算坐标不再直接用offsetX/offsetY。坐标换算这部分非常容易出诡异 bug而且每个交互都可能踩一遍。我建议把世界坐标换算封装成一个纯函数所有事件处理都走同一个入口排查时只需检查这一个函数。5.3 撤销重做导致数据不一致用快照式撤销理论上很简单把栈顶快照料替换当前 graph 就行。但真正出问题的是撤销后连线锚点数据没有同步更新。因为 Edge 里记录了sourceAnchor/targetAnchor如果用户在拖动过程中改了锚点方向而快照是拖拽前保存的撤销后锚点方向会回到旧值这时候 Edge 的路径点却还是新的就会出现线和节点错位。后来我每次 push 快照之前都对当前 graph 做一次完整深拷贝并且在渲染前统一根据节点位置和锚点计算路径不再信任缓存的路径点。这个改动让每次渲染多算了一点但彻底解决了数据一致性问题。5.4 导出 PNG 模糊和透明PNG 模糊的原因前面说了是devicePixelRatio。透明问题也常见建议在导出功能里加一个配置项允许业务方决定要不要白底。另外还有一个细节如果 SVG 里用到了外部图片或字体导出时由于跨域或者字体没加载完图片绘制到 canvas 会变成空白。稳妥的解决办法是要求外部资源全部走内联 base64或者在导出前等待字体加载完成。这种问题偶尔复现一次排查成本很高所以我把导出前的资源检查写成了公共函数每次都先确认资源状态再执行导出。5.5 节点一多就卡顿300 个节点以内SVG 没什么压力到 1000 个节点以后如果不做性能优化卡顿会非常明显。我做了三个优化视口裁剪只渲染可见区域内的节点和边画布外的不渲染rAF 合并渲染同一帧内的多次数据更新只渲染一次样式分离把不会变化的样式用 CSS 类一次定义不要每次渲染都用setAttribute写入。优化手段原理落地效果视口裁剪减少 DOM 数量大图滚动明显变快rAF 合并减少重绘次数拖拽帧率稳定CSS 类复用减少属性写入首屏渲染提速这三招做完以后1000 个节点的图在普通电脑上拖动可以达到 40 到 50 帧基本满足日常使用。如果还要更高就得考虑虚拟滚动或者更极端的 Canvas 化但那是下一阶段的事了。6. 后续可以扩展的方向6.1 自动布局手动画图虽然灵活但遇到几十上百个节点需要规整排列时手动一个个挪就太痛苦了。自动布局有两个方向树形布局和力导向布局。树形布局适合层次清晰的结构目前实现比较基础从根节点开始按层深分配 x/y加上子节点间距计算效果可以接受力导向布局更适合网络拓扑通过模拟节点之间的斥力和边的引力迭代收敛后自动展开成一个比较美观的形状。力导向布局我还在试验中计算量比较大需要放到 Web Worker 里算避免阻塞 UI 线程。自动布局和手动拖拽最好做成可切换模式自动布局生成初稿用户再手动微调这是目前比较务实的产品交互。6.2 协同编辑多人协同编辑是很多业务方的需求。如果数据模型和操作日志设计得足够干净同步起来就相对简单每个操作记成一条指令广播给其他人应用指令时通过 version 字段做冲突处理。这部分我还没有完全落地但已经预留了操作日志的抽象层后续做起来会有比较清晰的路径。要做协同序列化格式稳定是第一前提。如果数据格式还在频繁变化同步协议也跟着反复改项目会陷入泥潭。所以我在正式版本发布后一直尽量保持序列化格式的向后兼容。6.3 AI 辅助生成图表这个方向我觉得很有意思。用户输入一段需求描述AI 返回节点和边的 JSON 数据前端直接渲染成图表。与其做复杂的自然语言到图形界面的转换不如先做模板化生成预设一批常见场景的模板AI 负责填充模板里的节点和连线数据。这样实现简单效果也稳定。目前我已经验证了 JSON 输出这块的可行性下一步打算把数据质检也加进去避免 AI 生成出无法渲染的脏数据。比如节点 id 重复、边引用了不存在的节点、坐标超出画布范围等这些都可以用纯函数做校验。如果这个项目只让我留一条经验我会说图表编辑器的核心不是渲染而是数据。所有渲染、导出、协作都只是数据的一种投影方式。先把数据模型想清楚图元类型定义好再去做交互和界面整个项目的天花板会高很多。这也是我在 diagram-design 里体会最深的一点希望这篇复盘能给你做类似项目时提供一些参考。
阅读完成 · 觉得有帮助?
咨询建站