1. 为什么我决定自己写一个图设计工具这事儿得从一次项目周会说开。当时我正在演示系统整体架构打开之前的架构图想加一个新拆分的服务节点结果发现这张图已经和线上真实拓扑对不上了。更尴尬的是在座的同事都知道它过期了只是没人动手改。图一旦靠手工维护更新成本高大家自然就选择先不改反正看了也差不多。那个场景让我认真反思了一件事我们真正需要的不是一张好看的架构图而是一个能跟上系统演进的图设计工具。市面上不是没有成熟的画图产品但从我的需求来看它们各有各的别扭有的偏向自由手绘风格适合画思维导图但画严谨的架构拓扑就力不从心有的功能很强但数据模型是封闭的没法从我们的配置中心、监控系统直接生成图还有的联机协作体验不错可一旦涉及几百上千个节点的大图缩放和拖拽就明显掉帧。我想要的其实是这么个东西它能用数据驱动绘图节点和连线不是画布上孤立的图形而是有业务语义的对象它能支持中等规模图几百到上千个节点的流畅交互它能和团队的自动化流程打通比如从部署清单自动生成架构图、根据监控告警自动标红故障节点。找了一圈没有完全对胃口的于是就有了 diagram-design 这个项目。所谓 diagram-design不是一个专门画漂亮的图的设计工具它本质上是一套图数据模型加渲染引擎加交互层。核心思路是先定义好什么是节点、什么是连线、节点能挂什么属性、布局规则是什么再去实现视图层。我把这个项目的定位说清楚之后就发现很多需求其实是数据模型决定的而不是画布操作决定的。这也是下面要展开的第一条经验。2. 图数据模型把画图变成数据操作2.1 节点、连线、画布的三层结构我最早犯过的错误是直接按画布上的图形去建模。一个矩形、一根线、一个文本标签分别建类结果画倒是能画出来可一旦要做业务联动就非常痛苦。比如想把所有状态为异常的节点变成红色你得遍历所有图形对象去判断它是不是节点图形、有没有绑定业务数据。后来我把数据模型彻底重构了一次才找到顺手的结构。最终采用的是三层分离图层Canvas/Layer、节点Node、连线Edge。图层是容器它只负责坐标空间变换维护自身的缩放比例、平移偏移量、背景网格节点和连线才是真正的业务对象它们不知道自己在屏幕上被渲染成什么样子只知道自己携带的数据。节点的核心字段长这样// 节点数据模型TypeScript风格 interface NodeData { id: string; // 业务唯一ID对应真实服务/组件ID type: string; // 节点类型service / db / gateway / storage ... label: string; // 显示名称 status: string; // 运行状态running / warning / error ... position: { x: number; // 画布坐标注意不是屏幕坐标 y: number; }; size: { width: number; height: number; }; metadata: Recordstring, any; // 业务扩展属性比如所属团队、版本号 }连线数据模型稍微复杂一点因为连线的形态会直接影响布线算法interface EdgeData { id: string; sourceId: string; // 起点节点ID targetId: string; // 终点节点ID style: string; // solid / dashed / dotted label?: string; // 连线上显示的文字如协议、流量方向 anchors?: [string, string]; // 可选指定起点/终点锚点位置 }把数据和图形分开之后很多需求就变成了改数据、等刷新。监控系统推过来一批异常节点直接 update 对应 NodeData 的 status 字段渲染层监听数据变化之后自动重绘完全不需要手动操作某个图形对象。这也是我反复跟团队成员强调的画图工具的本质是图数据的可视化编辑器而不是图形编辑器。2.2 坐标系统与缩放平移模型坐标系统是图工具里最容易被低估的部分。刚做原型时我直接用屏幕像素坐标存节点位置结果缩放功能一加就乱了——鼠标滚轮缩放之后节点要么飞到别处要么拖拽时偏离手指/鼠标。这里我强烈建议采用世界坐标 视图变换的思路。节点数据里永远存画布坐标也叫世界坐标画布坐标与屏幕坐标的换算只发生在渲染和交互层。核心公式很简单// 世界坐标 - 屏幕坐标 screenX worldX * zoom panX; screenY worldY * zoom panY; // 屏幕坐标 - 世界坐标 worldX (screenX - panX) / zoom; worldY (screenY - panY) / zoom;其中 zoom 是画布缩放比例panX/panY 是画布平移量。所有鼠标事件先换算到世界坐标再和节点数据交互这样就不会出现缩放了半天节点数据里的坐标也跟着变的离谱局面。这一点看似基础但我见过不少开源项目在这里吃过亏——把缩放直接作用在节点坐标上导致反复缩放之后图完全没法看。在实际项目中我还会额外做一层坐标规范化。新建节点时坐标不是随意给的而是吸附到网格交点默认网格 20px。这样依赖布局算法生成的位置也会主动对齐网格整个图看起来整齐得多。网格吸附不是视觉问题它直接关系到连线布线的整洁度因为布线算法在规则网格上工作比在散乱坐标上工作简单得多。2.3 样式与数据解耦很多图工具把样式颜色、边框、字体直接写死在图形对象里我不建议这样做。 diagram-design 的做法是每个 NodeData 尽量不存具体样式值而是存一个样式引用styleKey比如styleKey: service-running。具体样式定义统一放在主题表里const THEME { service-running: { fill: #e8f5e9, border: #2e7d32, text: #1b5e20 }, service-warning: { fill: #fff8e1, border: #f9a825, text: #e65100 }, service-error: { fill: #ffebee, border: #c62828, text: #b71c1c } };这样做的好处是显而易见的——全局切换主题只需要换一张表新增一种状态样式也不需要改动节点构造逻辑。更重要的是样式表的存在让自动布局和图元渲染保持正交布局算法不用关心节点长什么样只负责算位置渲染器不用关心节点业务含义只负责按 styleKey 查表画图。职责拆得越干净后面加功能就越轻松。3. 渲染方案选型SVG、Canvas还是WebGL3.1 三种方案的实测差异画图工具的渲染层无非三条路SVG、Canvas、WebGL。我身边有人一上来就选 WebGL理由是性能上限最高结果做到一半发现交互成本极高——每个节点的点击命中要自己算文字渲染要额外处理纹理坐标变换全得手写矩阵。WebGL 不是不能做但对于节点数量在几千以内的图工具属于杀鸡用牛刀开发周期长且收益不明显。SVG 的优势在于 DOM 语义自然。每个节点就是一个g元素自带事件冒泡、CSS 样式、浏览器无障碍支持调试也非常直观。但它的问题是节点多了以后DOM 数量跟着涨一个千级节点的图就有上千个g、path、text元素重排和重绘的开销开始让滚动、缩放变卡。我实测过在普通办公笔记本上SVG 画到五百个节点、八百条连线左右拖动画布已经能感到明显迟滞。Canvas 的取舍刚好相反。它没有 DOM 元素层级绘图指令直接走 GPU 加速的 2D 上下文上千个节点重绘一次大约在十几毫秒到几十毫秒之间完全可接受。代价是事件命中检测要自己写——鼠标点下去的坐标需要自己遍历节点算包围盒。这个成本在图上节点不算多时几乎可以忽略。3.2 最终选择与理由我给 diagram-design 选了Canvas 作为主渲染引擎理由有三个第一目标场景是架构图、拓扑图单图规模几百到一千个节点很常见Canvas 能稳得住超出这个规模已经不是普通编辑器该解决的场景而应该考虑图分片展示。第二Canvas 的事件命中虽然要手写但配合我后面会讲的空间索引命中检测的开销可以做到纳秒级别不值得为这个放弃性能。第三Canvas 可以把图元绘制逻辑高度集中在一个 render 函数里比维护一堆 SVG 节点的事件绑定和状态同步更可控。选了 Canvas 不是没有遗憾。文字渲染是 Canvas 的弱项——大量text没法保留文本的精确选择、复制、朗读能力。所以我在项目里做了一个妥协节点标题和连线标签用 Canvas 绘制但节点详情在点击后通过浮层用真实 DOM 展示。这样一来画布交互保持流畅而需要精细文本操作的场景比如复制节点 ID、查看完整描述交给 DOM 完成。实际用下来这个折中方案非常顺手。3.3 渲染层的具体实现思路Canvas 渲染的核心就是那个 render 函数。它看起来像这样function render(ctx, viewport) { ctx.clearRect(0, 0, viewport.width, viewport.height); // 1. 画背景网格 drawGrid(ctx, viewport); // 2. 画连线先画线再画节点避免线压在节点上面 for (const edge of edges) { drawEdge(ctx, edge); } // 3. 画节点 for (const node of nodes) { drawNode(ctx, node); } // 4. 画选中态 / 拖拽预览 if (selection) drawSelection(ctx, selection); }这里有几个细节值得注意。连线要在节点之前画否则线会压在节点图形上方视觉上像线钻到了盒子上面很干扰观看。节点绘制里状态颜色不是每次都用fillStyle直接赋值而是先查主题缓存减少字符串解析开销。背景网格的绘制也有技巧——网格线数量取决于缩放值和可视区域大小缩放越大网格越疏因此画的时候要先算当前可视区有多少条网格线而不是把整个画布网格都画出来。另外我非常建议给 Canvas 渲染开启一个简单的脏矩形机制。拖拽一个节点时不需要清空整张画布重绘而只重绘这个节点原来所在区域和现在所在区域合并出的矩形。这个优化在节点数较多时效果显著能让拖拽从勉强能动提升到丝般顺滑。当然脏矩形也会引入边界问题比如连线跨过脏区域边缘出现残影这时候宁可保守一点直接把路径经过的区域也并入脏矩形。4. 交互层设计拖拽、连线、缩放中的坐标系换算4.1 拖拽移动节点时需要注意的点交互层最容易翻车的不是画不出效果而是坐标换算不统一。我先说拖拽。拖拽一个节点的标准流程是// pointerdown记录起始点和节点原始位置 onPointerDown(e) { const worldPoint screenToWorld(e.clientX, e.clientY); this.draggingNode hitTest(worldPoint); this.dragStartNodePos { ...this.draggingNode.position }; this.dragStartPoint worldPoint; } // pointermove根据位移更新节点世界坐标 onPointerMove(e) { const worldPoint screenToWorld(e.clientX, e.clientY); const dx worldPoint.x - this.dragStartPoint.x; const dy worldPoint.y - this.dragStartPoint.y; this.draggingNode.position.x this.dragStartNodePos.x dx; this.draggingNode.position.y this.dragStartNodePos.y dy; this.renderDirtyRegion(); } // pointerup结束拖拽触发吸附 onPointerUp(e) { snapToGrid(this.draggingNode); this.draggingNode null; }这里的关键是全程用世界坐标计算位移而不是屏幕坐标。如果在屏幕坐标下计算位移缩放比例不为 1 时鼠标移动了 10 像素节点却要移动 10/zoom 的世界单位乍一看没问题但一旦和缩放交互叠加就非常容易出 bug。我见过某开源项目把拖拽位移直接写成clientX - startClientX再乘以某个系数导致不同缩放级别下拖拽手感完全不一样。还有个小细节拖拽时最好把节点提升到浮动层概念下也就是它不再参与常规的碰撞检测和网格吸附等拖完松手再吸附。否则拖到半路会被网格线吸住每跨一个网格都跳动一下体验很差。4.2 连线锚点与正交布线连线交互是架构图工具的灵魂。用户从节点 A 的右侧拉出一根线拖到节点 B 的左侧松开期望得到的是一条有拐角的正交线而不是一条斜线穿过其他节点。 diagram-design 里把锚点定位和布线分成两个独立问题。锚点定位比较简单。每个节点预定义四个锚点上、右、下、左。连线时根据起点和终点节点的相对方位自动选锚点终点在起点右边起点锚点选右终点锚点选左。如果终点在起点上方起点锚点选上终点锚点选下。这只是一个启发式策略但在大多数架构图上效果都不错。布线阶段我采用的是 A 星寻路加曼哈顿距离的简化版本。网格粒度取 20px避障区域是其他节点的包围盒外扩 10px。看起来复杂其实本质上是function findRoute(start, end, obstacles) { // 只允许水平和垂直移动 // 曼哈顿距离作为启发函数 // 每走一步都检查是否进入障碍物包围盒 // 返回路径点数组 }实测下来对于一个几百节点的图寻路一次的耗时可以控制在毫秒级用户几乎感知不到。要注意的是所有连线锚点、路由计算都必须在世界坐标下做和拖拽一样不能引入屏幕坐标。4.3 缩放平移的用户体验细节缩放平移是架构图工具里做了觉得理所当然不做就想摔键盘的功能。我见过不少工具的缩放只是简单地把画布 scale 一下结果鼠标对准某个节点想放大查看细节放大后那个节点却跑到屏幕外面去了。核心问题是缩放必须以鼠标所在位置为中心进行。正确的缩放逻辑是保持鼠标指向的世界坐标点不变。实现也不复杂// 缩放前记录鼠标位置的 worldPoint const worldPoint screenToWorld(mouseX, mouseY); // 更新 zoom zoom * scaleFactor; // 更新 pan使得 worldPoint 重新映射回原来的屏幕位置 panX mouseX - worldPoint.x * zoom; panY mouseY - worldPoint.y * zoom;这样无论鼠标在哪个位置滚轮缩放那个位置下的内容都会保持在鼠标底下符合直觉。缩放区间我限制在 0.1 到 4 之间低于 0.1 时节点小到没有任何阅读价值高于 4 时会暴露 Canvas 绘制精度问题没必要。平移则比较简单指针按下时记录起点移动时时更新 panX/panY 即可。但需要注意拖拽节点和拖拽画布的手势不能冲突——我采用按下时做命中检测来决定手势类型命中节点则拖节点未命中则拖画布。这个策略简单且足够有效。5. 自动布局从力导向到分层布局的实践经验5.1 为什么需要自动布局手动拖拽摆放节点图足够小的时候还挺有乐趣但一旦超过二三十个节点手工布局就会变成一场灾难——节点重叠、连线交叉、分组混乱修一个节点的位置可能要连带挪动周边十个节点。自动布局不是锦上添花它是让中等规模图真正可用的关键。diagram-design 里我实现并测试过两种布局算法力导向布局和分层布局。5.2 力导向布局的实现与问题力导向布局的思路是把节点看成带电粒子互相排斥连线看成弹簧互相拉近。反复迭代模拟之后系统趋于稳定节点会自然散开连线长度趋于均匀。实现核心很简单function forceDirectedLayout(nodes, edges, iterations 300) { // 初始化速度向量 // 每轮迭代 // 1. 计算所有节点两两之间的斥力与距离成反比 // 2. 计算每条边两端的拉力与距离成正比向自然长度收敛 // 3. 更新速度加阻尼系数 // 4. 更新位置 }我测试下来的结论是力导向布局非常适合展示整体关系节点会自动分成松松紧紧的簇一眼能看出哪些节点高内聚。但它有两个很致命的毛病。第一是结果不稳定每次跑布局出来的形状都不一样同一个图上午和下午画出来完全两个样不利于团队对图形成稳定认知。第二是连线方向感差力导向不知道哪个是上游哪个是下游所以画出来的图看不出数据流方向而架构图恰恰非常在意方向性。5.3 分层布局与正交布线的配合考虑到架构图的语义分层布局更适合作为默认方案。分层布局的核心思想很简单通过拓扑排序找出节点的层级。入度为零的节点放第一层只被第一层节点连到的节点放第二层以此类推。在每一层内按节点的入边来源顺序横向排列尽量保持同层顺序稳定。连线使用正交路由配合上面说的避障寻路避免跨越多个层级产生过多交叉。这样布局出来的架构图有着清晰的横向层次从左到右或从上到下能看出调用链关系再配合正交连线视觉上非常接近人手工精心绘制的架构图。不过分层布局也有它的边界。当图里出现环依赖时拓扑排序会失败——实际微服务架构里环依赖并不罕见。我的处理方式是先检测强连通分量把环上的节点放进同一个虚拟层并固定相对位置再继续跑分层。这样至少保证布局不会因为一个环就整体崩掉。还有一个很实用的小经验别让用户每次点按钮都重新跑全局布局。布局结果应该可以锁定用户手动微调过位置的节点在下一次自动布局时保持不动只有新增的节点参与布局。这个手动位置优先于自动布局的策略虽然听着不那么智能但在实际协作场景里非常受欢迎——人家手动摆好的图不希望被一键打散。6. 性能优化与历史记录大数据量场景下的实战踩坑6.1 千个节点时的性能瓶颈第一次把测试数据加到一千个节点、两千条连线时我的第一版实现直接现了原形每次平移画布都明显掉帧CPU 占用拉到 80% 以上。用浏览器的性能分析工具一查瓶颈非常集中——重绘整帧时逐个节点调用绘图 API加上每个节点都要做阴影、圆角、渐变的复杂绘制一次 render 就要几百毫秒。优化的思路是在少画和简化绘制两个方向同时下手。少画方面只绘制可视区域内的节点可视区域外的不画。一千个节点平铺之后可视区域内通常只有一两百个这个裁剪一下就能省掉大部分绘制。简化绘制方面我砍掉了非必要的渐变和阴影改为纯色填充加一像素描边。视觉上差别不大但绘制耗时有质的下降因为 Canvas 的createLinearGradient和shadowBlur都是非常昂贵的操作。6.2 局部刷新与脏矩形除了裁剪我还引入了脏矩形机制。核心思想是每次操作拖拽、连线、新增节点只重绘受影响区域的画布而不是全量清空重绘。实现起来有三个要素计算本次操作的脏矩形范围通常是操作前和操作后的包围盒合并。渲染时只清空脏矩形区域然后重绘落在该区域内的节点和连线。因为 Canvas 无法只擦掉某一块然后再让背景自动恢复所以还需要把脏矩形覆盖范围的背景网格也重新画一遍。这里有个实际工程中的取舍脏矩形机制能大幅提升拖拽和动画性能但实现复杂度明显增加并且如果后续引入了粒子动画、高亮闪烁这类动态效果脏矩形的边界会变得非常麻烦。我的建议是——如果你第一次做图工具先实现全量重绘等确实遇到性能瓶颈再引入脏矩形。早期项目最大的敌人是复杂度扩散而不是性能。6.3 Undo/Redo快照与命令模式的选择Undo/Redo 是图编辑器的隐形功能平时没人提真用的时候要是没有用户会跑光。我先实现了最朴素的全量快照每次操作完成后把整个图数据深拷贝一份存进历史栈。节点少时没问题但一千个节点时一次操作就要拷贝包含两千条边在内的完整对象图内存开销开始失控。后来我改成了混合方案结构性操作新增/删除节点、批量移动用增量命令记录属性编辑改名、改状态直接记变更字段只有极端操作比如导入整个新图才用快照。命令模式的核心抽象很简单interface Command { do(ctx: EditorContext): void; undo(ctx: EditorContext): void; label: string; // 用于历史面板显示 }每个命令里保存的是撤销所需的最小信息。比如移动节点的命令保存每个受影响节点的起始坐标和结束坐标即可变更节点属性的命令保存旧值和新值即可。这样操作十次、百次内存增长都非常可控。有一件事我吃过亏必须提一下自动布局必须作为单个命令被加入历史栈而不能因为布局是自动的就绕开撤销系统。否则用户点了一下自动布局不满意按 CtrlZ 却回不到布局前的状态只能手动重调那体验简直就是灾难。另外我也建议在做历史记录时顺带把历史操作面板做了不要只留 CtrlZ 快捷键。架构图场景下用户经常需要回退到三步之前再试试另一种连线方式有面板会直观很多。面板实现其实就是遍历命令栈把每个 command.label 渲染成列表点击某条就撤销它之后的所有操作再重放这条并不复杂但非常加分。写到这里diagram-design 的核心脉络已经完整了先定义数据模型再选渲染方案然后处理交互层接着设计布局算法最后做性能优化和撤销体系。这五块是互相咬合的数据模型定不好后面的渲染和交互都会返工渲染方案不匹配布局算法再好也体现不出来。我在实际开发中反复体会到一个朴素道理图工具最值钱的部分不是画得漂亮而是让用户能快速把一个复杂系统的关系梳理清楚并且跟随系统变化持续迭代。这个目标不需要炫技需要的是每一层都稳扎稳打。