diagram-design 这个项目名,乍一看像是随便起的仓库名,但真正把它做出来之后我才意识到,它其实是一个典型的"做之前觉得简单、做之后发现全是细节"的方向。这里说的 diagram-design,并不是指用某款绘图软件画一张好看的图,而是指从零搭建一套"图的设计体系":包括画布渲染、节点连线、交互操作、自动布局、数据序列化这一整套链路。这篇文章我会完整复盘这个项目的设计思路、核心实现、踩坑记录和性能优化经验,希望能给正在做类似工具、或者想入行可视化编辑器的朋友一些可落地的参考。
当时决定做这个项目,起因是团队需要一个内部用的架构图绘制工具。市面上的绘图软件各有各的优点,但在数据联动、二次定制和私有化部署这几个维度上都绕不开限制。于是我就开始琢磨:与其在现成工具上打补丁,不如自己维护一套轻量级的 diagram 设计系统,把最核心的能力都握在自己手里。做完之后复盘来看,这个决策对了一半——自研确实带来了极大的灵活性,但也把大量工程细节的压力转嫁给了自己。这篇博文就是把那些细节摊开来聊聊。
1. 项目定位:自研 diagram 设计系统的整体思路
1.1 核心需求解析:到底要解决什么问题
在动手之前,我先把"diagram-design"这件事拆成了四个层次,分别是数据层、渲染层、交互层和持久化层。数据层负责定义图的结构,也就是节点、连线和画布的基本信息;渲染层负责把这些数据画到屏幕上;交互层处理用户的操作,比如拖拽、框选、缩放平移;持久化层则解决保存、导入导出和版本恢复的问题。
这四个层次听起来挺清晰,但实际做下来最大的感受是:它们之间的边界很容易被破坏。比如用户拖动节点时,如果只在渲染层做了位置更新、没有同步回数据层,那后续的保存就会丢状态;反过来,如果所有操作都实时写数据层,性能又会成为瓶颈。所以我在项目一开始就定了一条原则:渲染层只读数据,所有状态变更必须通过统一的数据入口走一遍,再触发视图更新。这个约定在后期避免了很多莫名其妙的 bug。
另外还要明确一点,这个项目不是要做成一个全能绘图套件,而是聚焦在"技术类图表"这个细分场景,比如架构图、流程图、ER 图、调用链路图。这个定位决定了它在很多设计上可以简化:不需要矢量绘画工具,不需要复杂的图层管理,核心就是"框 + 箭头 + 文字标注"这三板斧。目标用户也相对明确,就是内部工程师和产品同学,他们真正需要的是高效表达结构关系,而不是做精美的视觉设计。
1.2 方案选型:自研之外,其实还有三条路
如果只是想"画图",确实没有必要自研。我在启动前仔细比过现成的开源方案,结论大致是这样的:
| 方案类型 | 代表方向 | 适合场景 | 痛点 |
|---|---|---|---|
| 在线协作类工具 | 各类云端白板/绘图产品 | 快速出图、团队协作 | 数据不开放、难以深度定制 |
| 桌面端专业软件 | 传统绘图客户端 | 精细排版、导出需求 | 不适合内嵌到 Web 系统 |
| 开源绘图库 | 基础图形渲染库 | 需要完全掌控代码 | 生态碎片化,全套自建 |
最终选择自研,有三点考虑:第一,后端数据模型是自有的,希望图片状态能和业务数据打通,比如从接口配置自动生成架构图,这是现成工具做不到的;第二,编辑器需要内嵌到内部管理系统中,对交互方式和视觉风格有统一要求;第三,团队有前端图形渲染基础,维护成本是可控的。坦白说,如果这三点里面少了一条,我都不会走自研路线。
技术栈方面,我最终采用的方案是 TypeScript 写核心逻辑,渲染层用 Canvas 2D,UI 组件层用 Vue 来组织。选择 Canvas 而不是 SVG,是因为图表的规模预期是百到千级别的节点,SVG 在节点数量上去之后 DOM 开销会非常大。但这不代表 SVG 更差——如果图表规模小、需要丰富样式自由度和 DOM 事件能力,SVG 反而更合适。这个取舍没有绝对的对错,关键是看你的场景上限在哪里。
2. 数据模型和画布体系:所有图形逻辑的地基
2.1 节点与连线:用最简模型表达复杂结构
我在设计数据模型时,刻意把结构做得尽量简单。核心对象只有三个:画布(Canvas)、节点(Node)、连线(Edge)。
节点的核心字段包括 id、坐标、尺寸、样式组和业务数据。其中业务数据字段设计成了泛型对象,也就是不限制具体内容,可以由上层业务自由扩展。连线的核心字段包括 id、起点节点 id、终点节点 id、路径点数组和样式。路径点数组是实时的实际折线路径,连线根据端口位置算出最终的走向,存储这个路径点数组也是为了避免每次渲染时重复计算。
这个模型的优点在于非常直观,符合人对图结构的直觉认知;缺点则在于一些高级概念需要额外约定,比如分组。为了支持分组,我给节点加了一个可选的 parentId 字段,渲染时会把属于同一父节点的子节点做一次偏移换算。这样分组的本质就变成了"坐标相对化",而不是真正的容器嵌套,实现起来简单,也足够满足内部工具的精度要求。
有一个容易忽略的细节是连线两端的信息不能只存 id。因为节点删除时,如果没有连线的级联处理,界面上就会出现"幽灵连线"。所以我在删除节点的处理函数里,第一步就是遍历所有连线,把所有连接过该节点的连线一并删除,再删节点本体。顺序反过来就会残留脏数据,这一点在代码 review 时值得反复强调。
2.2 Canvas 坐标系与缩放平移的设计
Canvas 渲染的坐标系问题,是所有 diagram 系统绕不开的第一个坑。如果不做缩放平移,节点坐标可以直接映射到像素坐标;一旦加入 viewport 的概念,就必须区分"世界坐标"和"屏幕坐标"。
我的做法是维护一个 viewport 对象,包含 scale、x、y 三个字段。世界坐标点到屏幕坐标的变换公式为:
screenX = worldX * scale + x screenY = worldY * scale + y反向则是把屏幕坐标转回世界坐标,用于鼠标点击时的命中检测:
worldX = (screenX - x) / scale worldY = (screenY - y) / scale这个公式是整个交互系统的基础。所有鼠标事件的坐标都要先经过这个反向变换,得到世界坐标,再去做节点拾取、连线校验等逻辑。如果忘了做这一步,最常见的表现就是:放大之后点不准节点,拖动时图形跟手乱飘。我在项目早期至少因为漏了转换而重构过两次事件处理逻辑,后来干脆封装了统一的 screenToWorld 和 worldToScreen 方法,所有涉及坐标的地方都强制走这两个函数,问题才彻底收敛。
Canvas 的渲染循环我采用的是"脏标记 + 全量重绘"策略。每次数据变更只标记画布为 dirty,然后通过 requestAnimationFrame 统一调度重绘。相比每次操作都立刻 repaint,这种批量处理能把同一帧内多次变更合并成一次绘制,在拖拽节点时效果尤其明显。实际测试下来,在 500 个节点以内,feathering 和帧率都能稳定在可接受范围内。
2.3 序列化格式:一次设计,多处兼容
数据模型的另一个重要职责是序列化。diagram-design 采用 JSON 作为交换格式,整个文件由 version、canvas、nodes、edges 四个顶层字段构成。version 字段从第一天就加上,这是我从实际项目里学到的重要经验——没有版本号的存储格式,将来每次结构变更都是一次迁移灾难。
序列化还承担了"导入导出"的能力:导出时拖出 JSON,导入时拖入解析。为了让外部业务系统能直接生成图表数据,我还设计了一份精简版的数据契约,允许业务方只传节点名称、类型和连线关系,引擎自动做布局计算。这样就把 diagram-design 从一个纯编辑器升级成了一个小型的图渲染引擎,后端出数据、前端出图,整个链路顺畅很多。
3. 交互与渲染实现:从拖拽到自动布局的关键代码逻辑
3.1 拖拽、缩放与连线:交互细节决定工具手感
交互是 diagram 工具最见功力的地方。我用三个核心功能拆解一下实现思路。
拖拽节点。mousedown 时记录按下时的世界坐标和节点原位置,mousemove 时计算偏移量并更新节点坐标,mouseup 时结束拖拽并做一次数据持久化。这里有个细节:拖拽过程中应该只更新渲染数据,不触发保存;等 mouseup 统一保存一次,避免保存太频繁导致性能损耗。
缩放画布。监听 wheel 事件,以鼠标所在位置为锚点进行缩放。缩放比例设定在 0.25 到 2 之间,低于或高于这个范围就不响应,防止用户把画布缩到找不回来。锚点缩放的公式是从 viewport 推导的:
newScale = clamp(oldScale * factor, minScale, maxScale) x = mouseX - (mouseX - x) * (newScale / oldScale) y = mouseY - (mouseY - y) * (newScale / oldScale)连线交互。用户从节点边界的连接点拖出连线,移动到另一个节点上松手即建立关系。连线过程中需要实时预览一条跟随鼠标的橡皮筋线,同时高亮当前可连接的目标节点。为了判断"可连接",我给每个节点定义了若干个 anchor 点,命中检测时只要看鼠标世界坐标落在哪个 anchor 附近即可。
框选功能也值得一提。用 shift + drag 拉出矩形选区,实现方法是遍历所有节点,用矩形相交测试筛选命中节点。批量操作时同时平移多个节点,只需要将每个节点的坐标都加上同一个偏移量,并标记这些节点为批量移动,避免对每个节点单独触发保存。
3.2 自动布局:让 graph 数据秒变结构图
自动布局是 diagram-design 里的一个亮点功能。业务方只需要提供节点和关系,点击"自动布局"按钮,所有节点就会被排列成清晰的结构化图。我采用的是分层布局思路:
第一层,根据节点间的依赖关系计算层级(layers),把图拆成从源到宿的层序。第二层,同一层的节点按输入输出数量排序,减少连线交叉。第三层,层内节点做二次调整,让边尽量短。第四层,设定层垂直间距和节点水平间距,生成绝对坐标。
这个算法实现出来不到三百行代码,效果却不俗。尤其当节点的关系比较规整、环路较少时,布局结果非常接近手工整理的效果。对于环形依赖这种复杂场景,我目前的做法是先检测环并做临时拆环,布局完成后再恢复环的连线。这样环路结构虽然会在布局时被暂时打散,但最终渲染出来的连线关系依然完整。
3.3 撤销重做与状态管理:印象最深的"隐形工程"
如果说布局是锦上添花,那撤销重做就是幕后却绝对绕不开的隐形工程。核心实现采用经典的双栈模式:undo 栈和 redo 栈。每次合法操作都会生成一个快照,推入 undo 栈,并清空 redo 栈。undo 时把当前快照推入 redo 栈,再从 undo 栈弹出快照恢复;redo 反之。
快照粒度很关键。一开始我偷懒,每次操作把整个画布数据深拷贝一份,在百节点规模下还能跑,但到了三百节点以上,明显能感到保存快照时有卡顿。后来针对高频操作做了优化:移动节点时只记录被移动节点的新旧坐标,而不记录整个图表;新增或删除节点时,记录该节点的完整数据加上它在 edges 里的关系索引。这样快照体积直接缩小了一个数量级。
状态管理方面,我采用一个简单的中央 store 模式,所有变更都必须通过 store 的 action 方法加工,store 内维护一份不可变的历史记录。React 或 Vue 的状态管理方案在这个场景下都能胜任,关键是约束好变更方向——任何绕过 store 直接修改节点数据的代码,都是潜在的状态失控隐患。
4. 性能优化、问题排查与真实使用心得
4.1 大数据量卡顿:把每一次重绘都掰开揉碎
性能瓶颈是我在实际使用中最先撞上的墙。当节点数量上升到一千、连线达到两千以上,Canvas 全量重绘开始出现肉眼可见的掉帧。我排查后发现主要瓶颈有三处。
第一处是节点阴影和渐变填充效果太重,每次填充都要重新计算渐变对象,很消耗 CPU。解决办法是预计算一次样式,把渐变、阴影、字体这些不随位置变化的东西缓存起来,重绘时直接复用。
第二处是连线路径的计算。每条连线都涉及 anchor 点换算和折线拐角计算,每帧做全量计算,数据量大了自然扛不住。我的优化方案是为每条连线缓存最终渲染路径,只有连线两端的节点位置发生移动时才重新计算该连线的路径,其他情况下直接读取缓存图形。
第三处是事件监听频率。Canvas 上每个节点的 mousemove 都走同一套命中检测逻辑,节点多了检测必然慢。我把命中检测按区域做了空间划分:整个画布切分成固定大小的格子,每个格子记录包含的节点 id。拾取时只检测鼠标附近几个格子里的节点,而不是全部节点。这个优化在千节点规模下,拾取耗时可从几毫秒降到亚毫秒级别。
做性能优化时我还保留了一个基准测试脚本,每次改动核心渲染逻辑后跑一遍 keyframe 统计帧率,避免优化 A 功能时把 B 功能的能力降下来。这种回归保护在图形项目里非常重要,因为渲染性能受数据形状影响极大,肉眼观察很难定位是数据问题还是代码问题。
4.2 常见问题速查表
在实际开发和使用过程中,我积累了不少问题排查的经验,整理成表格供大家直接对照:
| 现象 | 排查方向 | 解决要点 |
|---|---|---|
| 放大后节点点不准 | 坐标没有做屏幕/世界转换 | 检查所有鼠标事件是否先走反向变换 |
| 拖动节点时图形抖动 | 在 mousemove 里重新获取了旧坐标 | 用"起始坐标+偏移量"模式,而不是绝对坐标 |
| 删除节点后出现幽灵连线 | 删除时未级联处理相关边 | 删除节点前先过滤 edges 数组 |
| 撤销后图形状态错乱 | 快照恢复时机不对,界面上残留缓存 | 快照恢复后强制清空所有渲染缓存并重绘 |
| 缩放到很大时节点飞出视野 | 缩放锚点公式错误 | 检查锚点变换公式中的坐标转换顺序 |
| 导入 JSON 时只能看到空白 | 序列化版本号不匹配 | 解析时先校验 version,做迁移兼容 |
4.3 实际使用中的经验与技巧
最后分享几条我在这类项目里反复验证过的经验。
第一,快捷键体系值得投入时间。diagram 工具用鼠标操作是基础,但真正提升效率的是键盘:Delete 删除、Ctrl+D 复制、Ctrl+方向键微调位置、Alt+拖拽复制节点。我实现完这些快捷键后发现,日常制图速度至少提升了三分之一,而且用户在熟悉快捷键后对工具的评价会明显变高。
第二,导出图片要多一个心眼。Canvas 的 toDataURL 导出经常遇到跨域内容导致画布被污染、导出失败。如果图里有外部图片资源,要先确保图片加载时带上了跨域属性并完成了跨域请求,导出前还要做一个画布状态检查。这个问题在静态本地图片和小型内网部署场景通常不会出现,但一旦碰到,排查起来特别绕人。
第三,不要一开始就做插件系统。我最初想过设计成可插拔架构,让每种图都有自己的专属扩展。后来发现,插件系统意味着接口稳定和迁移成本,早期频繁调整数据结构时,插件体系反而变成负担。更好的做法是先把核心功能做扎实,等核心 API 稳定了,再抽象出扩展点。我是在核心功能全部跑通之后,才着手定义插件接口的,效果比想象中要顺畅得多。
第四,日志体系要提前埋好。图形编辑器的 bug 往往难以复现,因为状态空间太大。我建议从第一天就在 store 层埋一个"操作日志"模块,记录每一步操作类型、操作对象和操作时间。出了问题直接看日志就能还原用户操作路径,定位效率能提升数倍。我在项目后期就用这个日志排查了好几个"玄学 bug",可以说这个模块是最值回票价的隐形投资。
diagram-design 这个项目做下来,最大的收获不是某个技术点本身,而是让我对"一个看似简单的工具背后要付出多少工程细节"有了实打实的体感。如果让我再选一次,我仍会选择自研,但会更早地引入自动布局、更早地设计好日志模块,也会更果断地砍掉那些看似炫酷但需求不明确的功能。做工具类项目,克制可能比激进更重要。希望这篇文章能让你少走一些我走过的弯路,如果你也在做类似的 diagram 方向,欢迎在实践中多多交流。