“图也要写代码?”——这是我做 diagram-design 项目以来,被问得最多的一句话。这个项目的初衷很简单:把架构图、关系图、流程图、网络拓扑图的绘制,变成一种“受版本管理、可自动布局、能被逻辑驱动”的工程能力,而不是打开画板工具手拖一条线。过去两年我在多个内部工具中反复用同一种思路画图,最终把这套经验收敛成了 diagram-design 这套基础设计,它适合三类人:被多版本架构图维护逼疯的后端开发者、需要在文档里嵌入高质量关系图的技术写作者、以及想做绘图类前端产品的工程师。今天把整个项目从需求到落地拆开聊,干货很多,但不绕弯子。
1. “图也要进仓库”:Diagram-as-Code 解决的真实痛点
1.1 传统拖拽画图的最大痛点
先说说我为什么不做“又一个拖拽画图工具”。你回想一下用传统画图软件维护一张架构图的经历:第一次画很爽,线条拉来拉去,颜色随心配。三个月后系统加了消息队列,你打开原文件,发现为了找服务名和数据库之间的联系,得放大缩小看半天,挪一个框,旁边三条线全飘了,你还得手动把线的折点一个一个调回来。改一次架构图要半小时,图还不一定对得上现网。
这背后核心问题不是“工具不好用”,而是“图没有逻辑结构”。拖拽工具里,方框是散落的矩形,箭头是独立的路径,连接关系仅存在于视觉上,文件里没有任何语义。结果就是图难以维护、难以检索、难以复用,更别提自动重排和版本对比。
1.2 用代码描述图的收益在哪里
diagram-design 走的另一条路:用结构化的数据描述图和图关系,渲染器负责把数据变成视觉元素,布局引擎负责算坐标。这意味着你可以把一张架构图写成一个 JSON 或一段 Python 字典,像代码一样提交到 Git 仓库。团队里任何人改动业务模块,直接改配置字段,重新生成图,提交 MR,Reviewer 看到的不只是“你改了什么代码”,还能看到“你的改动对整体拓扑产生了什么影响”。
这个方案的好处是实打实的:
- 图与代码同源,不存在“代码新图旧”的漂移问题;
- 自动布局取代手工排版,增删节点后重新计算,分钟级恢复整洁;
- 图纸可复用,一套数据模型可以导出 PNG、SVG,或者嵌入 Web 页面;
- 可编程操控,根据机房部署数据动态生成网络拓扑,而不是手工画一张静态图。
换句话说,“图”从一次性消耗品变成了可持续维护的工程资产。你只要稍微调整一下工作习惯,长期收益非常可观。
2. 项目骨架:我把渲染层和数据层拆得死死的
项目如果上来就直接写画图的逻辑,一定会把自己绕进去。diagram-design 的第一步是分模块,核心原则就一条:数据层永远不知道渲染层存在,渲染层永远不能私自改数据。
2.1 数据层:Model 只描述图
我用一个独立的Diagram类来容纳节点、连线和视口元信息,它只描述“图里有什么”,不碰任何“怎么画”的逻辑。基础结构大致长这样:
@dataclass class Diagram: version: str nodes: list[Node] # 节点列表:包含 id、标签、分组等 edges: list[Edge] # 连线列表:包含 source、target、样式引用 groups: list[Group | None] # 分组信息:用于子图边界计算 metadata: dict # 标题、作者、时间戳等 @dataclass class Node: id: str label: str kind: str # 如 service / database / queue style: Style | None = None # 覆盖全局样式的局部样式 fixed_position: Point | None = None @dataclass class Edge: id: str source: str target: str label: str = ""你可能会问一点:为什么连线的source和target不用坐标,而是用节点 id?因为坐标是布局引擎算出来的结果,不是图的固有属性。把坐标直接写进数据结构,今天挪个节点明天就得改所有连线,这个模式在复杂图里就崩了。而引用 id 的做法,让布局引擎调整节点位置的时候,连线自动跟随,不需要额外维护一致性。
数据层就是纯 Python 对象,天然可以序列化成 JSON。这也给协作带来了非常大的灵活性——非程序员也能通过手写一段 YAML,描述出节点和连线关系,剩下的交给项目自动排版。
2.2 渲染层:Canvas 还是 SVG
渲染层我最终选择了 SVG,虽然很多绘图工具优先考虑 Canvas,但做架构图这类场景 SVG 反而合适得多。原因有三:
- SVG 的图元就是 DOM 节点,给某个矩形加事件监听、改样式、做动画,都是浏览器原生能力,不需要自己实现一套图元管理;
- 缩放和坐标变换可以直接用 SVG 的 viewBox 机制,边缘模糊问题比 Canvas 缩放好处理;
- 图规模通常控制在几百个节点以内,SVG 的 DOM 压力在可接受范围,现代浏览器的 SVG 渲染性能其实比想象中好很多。
当然如果未来要支持上万节点的大规模拓扑,Canvas 或 WebGL 都是更好的方向,这套架构里渲染器是可替换的。你只要保持“数据输入 + 图形输出”的接口不变,换渲染后端不会动到业务层。
2.3 核心流程:一次图是怎么画出来的
diagram-design 的对一次完整渲染拆成了四个阶段。build接收原生数据,转换成 Model 对象;layout跑布局算法,给每个节点算出坐标;render根据坐标产出 SVG;decorate上样式和交互。每个阶段依赖前一个阶段的输出,但又保持独立,这样任何一步有 bug 都可以单独调试。
build -> layout -> render -> decorate我实际写下来最大的感触是:把流程拆开之后,每一个函数要处理的问题都变得非常具体,调试的时候也不需要满世界找问题。就拿layout来说,它只需要返回一个dict[id, Point],其他什么都不管,单元测试也好写,性能优化也好做,完全不影响上下游。
3. 布局引擎三件套:子图边界、锚点计算、层级路由
布局是整个项目最值得花时间的地方。图片要好看,本质是节点位置要合理、连线要短且不交叉。这部分我的实现思路是三件套:分层布局、子图边界、锚点路由。
3.1 先分层,再计算:层次化布局的基本思路
对于绝大部分服务架构图和流程图,数据本身是有方向性的——从上游到下游,从调用方到被调用方。所以第一个步骤是把所有节点按依赖关系分层,这非常像拓扑排序:没有前置依赖的放在第一层,只依赖第一层的放第二层,依此类推。
打个比方,这就像安排一条流水线:每道工序必须放在它依赖的工序之后,层数就是工序的批次。分层目的不是直接给出最终坐标,而是减少后续排布的自由度,把“二维问题”先降成“一维层序列问题”。
一个简单的分层逻辑可以这样写:
def assign_layers(diagram: Diagram) -> dict[str, int]: incoming = {node.id: [] for node in diagram.nodes} for edge in diagram.edges: incoming[edge.target].append(edge.source) layers = {} remaining = [n.id for n in diagram.nodes if not incoming[n.id]] current = 0 while remaining: next_remaining = [] for nid in remaining: layers[nid] = current for node in diagram.nodes: if node.id == nid: for edge in diagram.edges: if edge.source == nid and edge.target not in layers: incoming[edge.target].remove(nid) if not incoming[edge.target]: next_remaining.append(edge.target) remaining = next_remaining current += 1 return layers这段代码最终得到的结果是每个节点的“层号”。有了层号,横向坐标就有了大方向,剩下要解决的是纵向排序和连线交叉最小化。
3.2 子图边界:如何让分组集团不散架
架构图里非常常见的场景是:订单服务下有三个子模块,支付服务下有两个子模块,这些子模块之间是内聚的,画图时应该聚拢在一起。如果布局引擎不认识“分组”这个概念,这些子模块会被随意打散,图面看着就会很乱。
我的做法是给布局引擎额外传入分组的边界约束:同一租内的节点,y 方向尽量连续,租与租之间留出固定间距。具体算的时候,先按租金作为第一排序键,再按租内拓扑顺序作为第二排序键,这样一个租的节点不会跑到另一个租的中间去。
这背后的本质,是在“图的美观”和“逻辑关系”之间做取舍。没有分组约束的算法可以做到连线更短、线交叉更少,但画出来的图在语义上是乱的,反而不如带一点约束的稍差几何效果,更符合人的理解习惯。
3.3 锚点计算与层级间的连线路由
分层和定坐标之后,另一个隐藏问题浮出水面:连线从节点的哪个位置出发?很多绘图工具直接连矩形中心点,导致连线穿过其他图元。简单有效的改进方案是:连线从出边的方向侧发出,进入下一层节点时从对应方向侧进入。
diagram-design 里我做了一组“锚点选择”逻辑。节点四个边各留一个接线区,渲染之前先查一下当前边连接的是哪个邻居节点,取两者中心的相对方向选择出口和入口。这样大部分连线就是一条直线或一个直角折线,不需要额外做绕障。如果图中真的出现了需要绕障的情况,我建议你克制一点,不要在一版里去实现完整的全局路径规划,先让用户手动指定两个路由途经点,绝大多数时候都能解决问题。
快速总结锚点选择的优先顺序:
- 如果目标在下层,优先底部出口;
- 如果目标在上层,优先顶部出口;
- 如果目标在同一层,优先左右侧出口;
- 如果节点宽度明显大于高度,优先左右侧,减少线条穿过矩形正面的概率。
4. 交互编辑的隐性成本:偏移坐标、命中测试与导出缩放
有人会觉得,既然都“代码生成图”了,那就让用户纯写代码,不需要交互。真实用下来不行。架构评审的时候,你指着屏幕说“这里应该加一条调用链”,结果还得开文本编辑器改完再刷新,整个讨论节奏就断了。所以 diagram-design 还是需要支持轻量交互编辑,交互的坑比自动布局还隐蔽。
4.1 偏移坐标系与缩放补偿:点击位置为什么不准
第一个坑是缩放后点不准。画面有 pan 和 zoom,鼠标事件返回的是屏幕坐标,如果直接拿屏幕坐标去找节点,会发现怎么点都不中。我加了一个全局 viewport 状态,把所有鼠标事件先做一次坐标变换:
def screen_to_world(client_x: float, client_y: float) -> Point: # viewport 包含 translate_x / translate_y / zoom world_x = (client_x - viewport.translate_x) / viewport.zoom world_y = (client_y - viewport.translate_y) / viewport.zoom return Point(world_x, world_y)这个变换顺序不能反:先减偏移,再除缩放。我曾经写反一次,在 zoom=0.5 的时候点哪都差一倍的距离,排查了半天才意识到是变换没做对。这个函数是所有交互的基础,后面接的选中、拖拽、右键菜单全都要经过它。
4.2 命中测试:不要遍历所有图元
交互还需要快速判断鼠标落在了哪个节点或连线上。最笨的办法是遍历所有图形做几何包含判断,几百个节点时还行,图形多了就卡。折中的方案是做两件事:
- 维护一套空间索引,只查当前 viewport 可见范围内的候选集合;
- 先做矩形粗筛,再做精确判断,避免每条连接线都做“点到折线距离”计算。
精确判断连接线的时候,有个小技巧:不要只探测单像素线,给连线加一个“虚拟线宽”,比如 8 像素,这样用户不必精准地抓到那条 1px 细线。这个细节对体验提升非常明显,视觉上是 1 像素的线,实际上可点击范围是它的 8 倍。
4.3 导出图片:打印尺寸和模糊问题
另一个容易翻车的是导出 PNG。SVG 在浏览器里显示正常,导出时如果不按目标设备的分辨率做超采样,在 Retina 屏上或者文档插图里就会发虚。我当时就踩了一次,导出的 1920*1080 架构图在 PPT 里一拉大就糊了。
处理方案很机械,但在工程上很实用:导出前把 SVG 的 width 和 height 按 scale 放大,再让绘图上下文等比缩放;导出后把外部容器的 CSS 尺寸恢复原值。这样不会影响屏幕显示,导出的位图又足够细腻。如果有条件,优先导出 SVG 而不是位图,放大缩小永远不糊,这也是我推荐大家做架构图时优先考虑的交付格式。
5. 性能优化:分层渲染、增量更新与可见区裁剪
新上手做绘图工具的人,一开始对节点数量没概念,画到五百个节点、一千条连线的时候,页面开始明显卡顿,这才开始焦虑。性能优化的方向其实很清晰,核心思路不是“让单次渲染更快”,而是“尽量少渲染、渲染可见的东西”。
5.1 分层渲染:固定层、交互层、标注层
diagram-design 把 SVG 内部拆成了好几个<g>组:最底下一层画连线,第二层画节点形状和文本,最上面一层只画选中框、拖拽手柄和 hover 效果。听起来很常规,但真正做到位之后,交互层本身非常轻量,重绘只发生在需要变化的那一层,不会动不动就把整张图重绘一遍。
拿拖动节点举例:拖动时只需要重绘交互层和相关连线,节点层本身位置变动我得更新对应 transform 属性,但并没有重新执行完整的 render 流程。分层带来的另外一个好处是 z-index 关系非常稳定,不会出现连线盖住节点文本这种问题。
5.2 增量更新与防抖合并
不是每一次数据变化都需要立刻渲染。文本框输入里连续敲几个字符,如果每敲一个字符就重排版,浏览器压力很大。我的方案是给重布局渲染加防抖,大概 150~200 毫秒。也就是说,用户停笔超过 150 毫秒,才触发重新布局。
如果数据变化幅度比较大,还可以做“保留现有位置,只增量修改新增节点”的策略。这个策略看起来效果不如完全重新布局,但胜在稳定:在已经调好样式的大图上,用户只是加了一个节点,你会希望整个图都动一遍吗?多数时候不想。以稳定性优先,把“完全重新布局”留给用户手动触发,体验会更好。
5.3 可见区裁剪:看不见的就不画
当图特别大的时候,大量图元在屏幕外,根本看不到,渲染它们纯属浪费。我加了一个可见区裁剪逻辑:拿到 viewport 的四个角坐标,在布局阶段结束之后、渲染阶段开始之前,先把完全落在裁剪区外的节点和连线过滤掉,只有与裁剪区相交或可见的图元才被渲染。
这个逻辑对性能的改善非常显著。我之前测试过一个 2000 节点的大图,缩放到只显示局部 10% 的区域,渲染时间从几百毫秒下降到几十毫秒。对架构图这类应用来说,你很少有需要“一眼看全 2000 节点”的场景,反而“快速定位到某一块子图”才是刚需。
6. 从“能画”到“好用”:主题系统、模板复用与只读分享
画图工具做到“能画”只是及格,真正让人觉得好用的是主题、模板和分享链路。
6.1 主题系统:把视觉偏好从数据里剥离
diagram-design 支持一套轻量主题机制。主题是一份包含颜色、字体、间距、连线样式的配置,和 Model 数据解耦。渲染时节点和连线会从主题里按kind(服务、数据库、队列等)查找对应样式,没有指定就落到默认样式。
这里有个看起来很玄但很重要的点:主题配置里尽量使用“语义名称”,不要直接写死颜色值。比如用accent表示主角色,用danger表示错误链路,而不是在每一处写#ff4d4f。因为换主题的时候,你不希望把所有颜色值一个个找出来改,而是换一整个调色板就能改变全局观感。这套语义化思路和 CSS 变量、设计令牌的做法完全一致。
给一个主题简化的例子:
{ "themeName": "light", "palette": { "bg": "#ffffff", "primary": "#2563eb", "line": "#94a3b8", "danger": "#dc2626" }, "nodeStyle": { "service": { "fill": "#eff6ff", "stroke": "#2563eb" }, "database": { "fill": "#f8fafc", "stroke": "#0f172a" }, "queue": { "fill": "#fef3c7", "stroke": "#d97706" } } }6.2 模板复用:把常见架构沉淀成配置仓库
做了一段时间后我意识到,很多图的结构高度相似——微服务架构无非就是网关、服务、数据库、缓存、消息队列这几种角色来回排列。与其每次从零开始写节点,不如做一套模板仓库,把常见架构的骨架预先定义好。
模板不是静态文件,而是“生成器函数”的返回值。比如microservice_template(service_names: list[str])会接受服务列表,自动生成一组带层次关系的节点和连线,得到的是一个可以直接交给布局引擎渲染的 Diagram 对象。这让团队新成员画图门槛大幅降低:不需要理解渲染细节,只要填自己服务的名字,架构图就出来了。
6.3 只读分享:交互式而不是图片式
最后聊分享。最初导出 PNG 分享到群里,大家看图说话还行,但想近距离看细节,顶多放大,没法查看某个节点是谁、有什么依赖。后来我改成了导出“自包含 HTML”,把 SVG 数据和少量 JS 交互逻辑一起打包,接收方用浏览器打开就能平移、缩放、点击节点查看详情,不需要安装任何依赖。
这个方案的启发其实很实际:当图是“数据”而不是“图片”时,分送到别人手里的也可以是一份可交互的数据,而不是一张像素图。当前我还想给自包含 HTML 加时间轴,把架构的演进过程做成可拖动的历史视图,架构评审和交接的时候会非常直观。
7. 项目落地时的一些经验与心得
最后说几点从项目里踩出来的实际经验,希望能帮你少走弯路。
第一,架构图的数据模型一定要保持最小化。能推出来的信息,比如连线端点、所属层级,就不要存进原始数据里,独立“坐标”和“数据”,不然以后加自动布局能力会后悔。
第二,布局算法先跑通最朴素的版本,再一点一点优化。一开始就追求复杂算法容易把自己绕晕,而且业务场景里绝大多数图根本没有特别严重的交叉问题,先让图看起来有序,就赢了 80%。
第三,交互开发的顺序建议是:选中、拖拽、缩放、连线、右键菜单。选中和拖拽是所有交互的基础,没有它们,后面的东西都无从谈起。右键菜单是各个交互里最容易被忽略但影响非常大的一个,“能改样式”是用户最常期待的能力。
diagram-design 到现在已经覆盖了我在团队内部大部分架构图、时序图、拓扑图的绘制场景。这个东西的边界我还在不断扩展,但核心思路一直没变:图是一种可以直接被代码管理、被逻辑驱动、被团队维护的资产,而不是某个文件里永远只能靠手工修修补补的“死图片”。如果你也在做或者打算做类似方向,欢迎拿去参考,也欢迎一起聊聊里面那些还没解决好的布局细节。