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: Record<string, 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还是WebGL
3.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; // 用于历史面板显示 }每个命令里保存的是"撤销所需的最小信息"。比如移动节点的命令,保存每个受影响节点的起始坐标和结束坐标即可;变更节点属性的命令,保存旧值和新值即可。这样操作十次、百次,内存增长都非常可控。
有一件事我吃过亏,必须提一下:自动布局必须作为单个命令被加入历史栈,而不能因为布局是"自动"的就绕开撤销系统。否则用户点了一下自动布局不满意,按 Ctrl+Z 却回不到布局前的状态,只能手动重调,那体验简直就是灾难。
另外我也建议在做历史记录时顺带把"历史操作面板"做了,不要只留 Ctrl+Z 快捷键。架构图场景下用户经常需要"回退到三步之前再试试另一种连线方式",有面板会直观很多。面板实现其实就是遍历命令栈,把每个 command.label 渲染成列表,点击某条就撤销它之后的所有操作再重放这条,并不复杂,但非常加分。
写到这里,diagram-design 的核心脉络已经完整了:先定义数据模型,再选渲染方案,然后处理交互层,接着设计布局算法,最后做性能优化和撤销体系。这五块是互相咬合的,数据模型定不好,后面的渲染和交互都会返工;渲染方案不匹配,布局算法再好也体现不出来。我在实际开发中反复体会到一个朴素道理:图工具最值钱的部分不是"画得漂亮",而是让用户能快速把一个复杂系统的关系梳理清楚,并且跟随系统变化持续迭代。这个目标不需要炫技,需要的是每一层都稳扎稳打。