diagram-design 这个项目我前后折腾了将近三个月核心就干一件事让没有设计功底的工程师也能快速画出一张结构清楚、拿得出手的架构图、流程图或者拓扑图。市面上现成的绘图工具不少但真拿到团队内部用的时候总会遇到各种磕绊——节点一多布局就乱、导出的图片放到文档里发虚、图的数据没法跟现有系统打通。所以我才决定自己动手做一个轻量级的图设计工具这个项目代号就叫 diagram-design。如果你是对绘图工具设计感兴趣的前后端同学或者正好在调研要不要自研一套图表编辑器这篇文章值得看一看。它不讲那种大而全的商业绘图软件怎么做而是聚焦在“最小可用图设计器”的完整实现路径上数据结构怎么定义、自动布局怎么选、SVG 和 Canvas 怎么取舍、导出图片有哪些坑以及高频问题的排查思路。废话不多说直接进正题。1. 项目定位diagram-design 到底解决什么问题1.1 先想清楚你要画的是什么图开始写代码之前我最先花时间做的不是建工程而是把“图”这件事掰开揉碎想清楚。很多人一听到“图设计”第一反应就是“画框 箭头”但实际落地时才发现不同种类的图对布局、交互、数据模型的要求完全不一样。简单分一下日常最常见的大概有三类流程图 / 时序图强调先后顺序节点之间的关系有方向性基本是“有向无环图”。适合分层布局也就是把节点按层级从左到右或从上到下排开。架构图 / 拓扑图强调组成和连接关系可能是星形、树形也可能有部分环。这类图最好看整体层次子模块、分组和跨组依赖都很常见。思维导图 / 聚类图强调归属和相似度通常从中心往外扩展树形或力导向布局更合适。我把这个项目的主要场景定在前两类也就是“结构化关系图”。因为团队内部画得最多的就是架构方案图和接口调用链路图。想明白这一点之后后面所有设计都有了基准布局策略优先支持分层渲染要能承接几百到上千个节点导出要清晰可用。1.2 为什么我坚持自研而不是直接用现成编辑器肯定有人会问明明有那么多成熟的绘图软件为什么还要自己写我当时也反复纠结过。最后的判断依据是三条数据模型是不是黑盒。现成工具多数把图数据存在自己的格式里想跟内部系统做联动、做权限、做版本对比都很别扭。自研可以把数据模型定义成 JSON直接落库、直接出接口。能不能平滑嵌入业务。内部场景需要的是“打开页面就有图”而不是“打开一个独立软件再导入导出”。自研可以把图设计器当成一个组件嵌进管理后台、方案评审系统里。样式的可控程度。自研之后节点的圆角、描边、颜色、连线的拐点规则都能通过代码统一控制不会出现“十个人画的图十种风格”的问题。当然自研不等于闭门造车。渲染层和布局层完全可以借助社区里的开源方案真正需要自己写的是业务数据模型、交互逻辑和渲染编排。我最后保留的核心能力很克制节点、连线、分组、自动布局、手动微调、导出图片。这套最小集合已经能覆盖内部 80% 的绘图需求。2. 数据与交互模型把一张图拆成三样东西2.1 节点图上最小的信息单元图设计的第一步不是写代码而是定数据结构。节点是图上最基础的单元我把节点模型设计成长这样{ id: node_1234, type: service, label: 订单服务, x: 480, y: 320, width: 160, height: 56, style: { fill: #ffffff, stroke: #1890ff, borderRadius: 8 }, data: { owner: team-a, version: v2.1 } }这里有两个容易被忽略的设计。第一个是type字段。节点不应该是自由画布上随便摆放的矩形而应该是“有类型的实体”。我预设了 service、database、external、group 等几种类型每种类型有默认的配色和形状。这样做的好处是用户不用每次重新调样式整体画出来天然整齐。如果你想画菱形判断节点、圆角框流程节点只需要新增一个 type而不是让用户自由捏形状。第二个是data字段。业务数据一定要和坐标、样式分开存放。坐标负责“图长得怎么样”data 负责“这个节点关联什么业务信息”。后面做搜索、联动高亮、版本对比的时候这个字段的价值会成倍放大。2.2 边关系怎么表达才不乱边比节点更容易被低估。很多刚上手的人觉得边就是“从 A 到 B 画一根线”但图一复杂线的管理就成了灾难。我设计的边模型长这样{ id: edge_001, source: node_1234, target: node_5678, sourcePort: output, targetPort: input, label: 调用, type: orthogonal, style: { stroke: #888888, lineWidth: 1 } }这里最想说的一点是端口port一定要在一开始就留好。端口就是节点上“出线”和“入线”的锚点。如果一开始不做端口只允许从节点中心连线后期一旦需要“从右边出去、从左边进来”就得把所有数据结构推翻重来。我见过好几个项目就是栽在这个地方。边的样式也值得斟酌。直线最简单但节点一多就会交叉曲线好看但算拐点逻辑复杂。我最终默认用正交折线也就是横平竖直的连线方式。这种线在架构图里最“技术流”也最容易跟自动布局配合。为了控制成本第一版我没有做拐点避障而是通过调整端口方向和布局顺序来减少交叉。2.3 画布全局坐标与视图坐标要分开这个事看起来是基础但真正做到位的项目不多。简单说模型里存的不应该是“节点在屏幕上显示在哪”而是“节点在整个图的坐标系里位于哪”。用户看到的画面其实是摄像机camera在一个无限大的画布上截取的一部分。摄像机有偏移量x, y和缩放比例zoom。鼠标点击屏幕上的位置要换算成模型坐标公式类似const modelX (screenX - canvasCenterX) / zoom camera.x; const modelY (screenY - canvasCenterY) / zoom camera.y;为什么一定要分开因为用户会缩放、平移画面。如果你在拖拽节点时直接改鼠标屏幕坐标一旦 zoom 变化所有节点的位置全乱套。正确的做法是拖拽时把屏幕坐标实时换算成模型坐标再把模型坐标写回节点数据。我还在这个环节吃了不少亏后面第 6 章会专门展开说拖拽漂移问题的排查过程。这里先提一句视图变换是整个图设计器的地基这里想清楚后面能省一个星期的调试时间。3. 布局与渲染方案自动布局和绘图引擎怎么选3.1 自动布局分层布局为什么在架构图里最常用画图最费精力的不是画而是排版。节点往哪放、层间距多大、怎么减少连线交叉这些靠人工拖很痛苦所以一定要引入自动布局。我核心用的是分层布局算法适用场景非常明确有向无环图、调用链路、分层架构。它的基本思路是找出所有入度为 0 的节点作为第一层。移除这些节点再找新的入度为 0 的节点作为第二层。重复这个过程直到所有节点都被分配层级。在每一层内部调整节点左右顺序尽量少交点连线。听起来不复杂但工程实现会遇到很多细节比如环状依赖怎么处理、同层节点怎么排序、边的标签放在哪里。我建议不要自己造轮子直接接开源的布局引擎。把节点和边列表喂进去它返回每个节点的坐标这个过程可以封装成一个独立模块。接布局引擎时一定记住布局引擎给的是“建议位置”不是“最终结果”。用户画完图之后动手微调过的地方下次自动布局时不应该全部打乱。我的方案是给每个节点加一个manualOverride标记一旦用户手动拖过就跳过该节点的自动定位。3.2 SVG 与 Canvas一张图到底用哪种画法渲染层的选型决定了图设计器的数据承载上限和交互体验也是我前期犹豫最久的问题。简单做了个对比维度SVGCanvas节点数量级几百到一两千也还行几千甚至上万没压力交互实现每个元素都是真实 DOM事件好绑需要自己算命中检测交互逻辑要手写样式定制直接用 CSS 和样式属性很灵活所有图形靠绘图代码控制导出清晰度矢量天然高清需要手动控制分辨率开发成本相对低相对高我最后选了 SVG 作为主渲染方案核心原因是交互成本低。图设计器最重要的操作是拖拽、锚点连线和点击选中SVG 元素天然支持这些事件的绑定。对内部工具来说三五百个节点是常态SVG 完全扛得住。如果哪天真的需要几千节点的大图再考虑局部 Canvas 渲染也不迟。3.3 渲染与数据绑定常见坑用 SVG 不等于可以随便操作。我第一版犯的错误就是“视图直接改数据”也就是拖拽节点时直接在 DOM 上改坐标而没有同步到数据模型。当时看起来没问题但一旦做撤销、重做、自动布局整个状态就全乱了。正确思路是“数据驱动视图”用户操作修改数据模型。数据模型触发变更事件。渲染层根据新数据更新视图。绑定的时候还要注意更新粒度。图里几百个节点如果一个小改动就重绘整张图拖动时帧率会明显下降。至少要做两级优化按需重绘只有被修改的节点更新位置属性其他节点不动。批量更新自动布局执行完再统一刷新而不是每算一个节点刷新一次。自定义节点也会带来渲染层复杂度。我专门设计了一个“节点插槽”机制节点内部除了标题还可以放状态角标、锚点热区、缩略标签。渲染时先把基础矩形画好再根据 type 决定要不要画额外的插槽内容。4. 核心交互实现实操从画布到拖拽连线4.1 画布的基础交互拖拽平移与框选图设计器的交互核心是“手上操作所见即所得”。第一优先级是拖拽平移、拖拽节点、框选。拖拽流程遵循一个经典事件序列mousedown 记录起点mousemove 计算偏移并刷新视图mouseup 收尾并提交数据变更。这里最关键的是区分“事件目标”按在空白画布上是平移按在节点上则是选中并拖拽节点。canvas.addEventListener(mousedown, (e) { const target e.target; if (target.classList.contains(node-rect)) { startNodeDrag(e); // 拖节点 } else if (target.classList.contains(canvas-bg)) { startCanvasPan(e); // 拖画布 } });拖拽节点时我们需要把鼠标移动的屏幕距离除以 zoom再更新节点模型坐标function onMouseMove(e) { if (draggingNode) { const dx (e.clientX - lastX) / camera.zoom; const dy (e.clientY - lastY) / camera.zoom; draggingNode.x dx; draggingNode.y dy; updateNodeView(draggingNode); lastX e.clientX; lastY e.clientY; } }框选稍微复杂一点核心是把鼠标按下和抬起两个屏幕点都换算成模型坐标然后算一个矩形再遍历节点做碰撞检测。框选矩形本身可以 overlay 在一个透明的 SVG 顶层上不影响画布局。4.2 连线交互从一个锚点拉出一条边连线的交互大概是整个项目里最容易让用户困惑的功能。第一版我做得太“程序员思维”用户必须先选“连线模式”才能连。后来模仿了主流绘图工具的做法把鼠标悬停在节点边缘时自动高亮锚点按住锚点往外拖就能拉出一条临时线松手放到目标锚点上边就创建成功。连线过程中要注意几个业务规则禁止自环不允许从节点连到自身。禁止重复连同一条边在源目标和端口组合完全一致时自动忽略。环检测如果业务要求图必须是无环的需要在创建边时做一次拓扑校验。临时连线的绘制很有意思它不受布局约束纯粹跟随鼠标位置用一条虚线表示松手后校验通过才转换成正式的边。4.3 自动布局的接入流程从原始图到坐标落位接入自动布局的核心流程其实比想象中简单关键是要把“输入转换”和“结果合并”做对。我在项目里封装了一个布局服务流程大概是1. 把图数据整理成布局引擎的输入格式节点 id、宽度高度、边的起止 2. 调用布局引擎计算坐标 3. 把返回的坐标写回数据模型跳过 manualOverride 的节点 4. 触发一次整体重绘这里有一个容易踩坑的点布局引擎算出来的坐标通常是相对坐标而且原点可能在左上角也可能是中心点。如果不做归一化图可能会跑到画布角落外面。我的做法是拿到布局结果后统一计算包围盒然后把整张图平移到画布中心再按当前 zoom 进行缩放适配。布局引擎的参数也是要调的。核心参数是水平间距和垂直间距。我实测下来服务型架构图默认横向间距 80、纵向间距 40 比较合适如果节点太多再逐步放大间距避免连线压着节点文字。4.4 导出高清图的处理细节图设计器如果只能看不能导出相当于白做一半。流程图要贴进方案文档、架构图要发到 IM 群里评审都是刚需。SVG 渲染的导出思路非常直接把 SVG 序列化成字符串转成 blob然后用 Image 对象加载绘制到 Canvas 上最后从 Canvas 导出 PNG。const svgData new XMLSerializer().serializeToString(svgEl); const blob new Blob([svgData], { type: image/svgxml }); const url URL.createObjectURL(blob); const img new Image(); img.onload () { const canvas document.createElement(canvas); const scale 2; // 按 2 倍清晰度导出 canvas.width svgEl.clientWidth * scale; canvas.height svgEl.clientHeight * scale; const ctx canvas.getContext(2d); ctx.scale(scale, scale); ctx.drawImage(img, 0, 0); canvas.toBlob(blob download(blob), image/png); }; img.src url;细节上至少有两个坑SVG 里的样式如果依赖外部 CSS序列化后会丢样式。导出前要先把所有计算后的样式内联到元素上。导出时scale至少设成 2不然在 Retina 屏幕上会发虚。如果要打印或投屏建议设成 3代价只是文件大一点。5. 序列化、版本兼容与文档打通5.1 图数据的序列化与版本兼容图设计器永远绕不开“存文件”这件事。我一开始想得比较简单把整个图的 JSON 直接存到后端读取的时候原样拉回来。直到有一次改了节点结构老数据全部解析失败才知道版本兼容有多重要。所以我在 JSON 里加了一个version字段并实现了统一的迁移入口{ version: 2, diagram: { nodes: [], edges: [], groups: [] } }每次数据结构有破坏性变更就写一个migrate_v1_to_v2()函数在加载时按版本号依次迁移。这个习惯强烈建议提早养成否则项目上线三个月后你就会体会到“所有历史图都打不开”的酸爽。5.2 导出图片与文档系统的流通导出 PNG 只是基础操作。把图真正用起来还要考虑它和文档系统的关系。我们的做法是让图设计器生成图片后同时保留一份“图数据 JSON”。图片用于展示和阅读JSON 用于后续重新编辑。更进一步我们做了一个“轻量互转”的能力把图数据转换成语义接近的文本描述格式这样可以直接贴进 Markdown 文档里做代码块展示也方便做版本 diff。这个功能虽然不是核心但团队评审方案时非常受用因为一张大图看不过来文本版本可以搜索、可以对比、可以精准引用。6. 高频问题排查与调试经验6.1 拖拽漂移坐标系搞混的经典症状这是图设计器开发里最常见也最容易懵的问题。症状是节点拖到一半鼠标不动了但节点还在慢慢“飘”或者拖拽的时候节点跟鼠标之间始终保持一个固定偏移越放大越明显。根因基本都可以锁定在坐标系混用。最常见的有两种鼠标事件监听在某个子元素上但外层画布做了 CSS transform 缩放导致坐标没经过 transform 换算。把屏幕坐标直接写进节点模型而没有除以 zoom。排查方法很简单在拖拽处理函数里打一条日志打印屏幕坐标、模型坐标和 camera 的 zoom对着算一遍就清楚了。6.2 节点重叠布局参数没吃透自动布局跑完节点堆在一起看起来像是布局引擎没生效。其实绝大多数情况下是布局引擎跑完之后坐标没有经过“层级间距”归一化。我排查过一次印象很深原因是布局引擎返回的坐标是相对原点而我的节点宽度是 160但横向间距算的是 80导致所有下一层节点都盖在上一层节点上面。解决方案是把布局引擎的间距参数和节点实际宽高对齐。横向间距至少等于节点宽度加上固定间隙纵向同理。如果你用的是在线图可视化库还要注意布局引擎的rankdir参数我习惯用TB也就是从上到下更贴近架构图阅读习惯。6.3 大数据量渲染卡顿渲染策略要分层当节点数超过一千SVG 的性能会明显下降尤其是一刷新就全量重绘的场景。卡顿原因不只是节点多而是很多节点的样式、坐标、连线的路径都在更新。在大图场景下我采取的优化手段有三个增加视口裁剪只渲染当前可视区域内的节点移到屏幕外的节点不绘制。降低重绘频率拖拽平移时用 requestAnimationFrame 合并渲染帧而不是每移动 1px 就画一次。边路径简化正交折线如果拐点太多可以压缩中间拐点只保留关键转折。说实话到这一步已经超出了内部工具的需求。如果团队只是画一两百节点的架构图SVG 按需更新就足够不需要过早优化。6.4 排查速查表一张表落地为了方便项目组的人快速定位问题我把高频问题整理成了速查表现象可能原因排查顺序解决参考拖拽时节点偏移或漂移坐标没除以 zoom / 事件目标错先看 camera 状态再看事件绑定统一视图变换函数自动布局后节点重叠间距参数小于节点尺寸检查布局参数和包围盒对齐间距与宽高导出图片样式丢失外部 CSS 未内联导出前检查 computedStyle样式序列化内联图一放大变模糊导出 scale 太小查 scale 系数改 scale2/3边连接不上锚点命中检测半径太小检查锚点热区扩大热区半径到 8px老数据打开报错数据结构变更未写迁移查 version 字段补充迁移函数这个项目做下来我最大的体会是图设计器的难点从来不在“画图”而在“数据模型的干净程度”和“坐标变换的严谨程度”。很多问题看起来是渲染问题追到底都是数据结构或坐标系没理顺。diagram-design 目前已经作为我们团队内部日常画架构图的主力工具在用后面我还想继续优化连线的正交避障以及把自动布局和手动微调的结合做得更智能。如果你也在折腾类似的东西希望这篇文章能帮你少踩几个坑。