从标题“diagram-design”说起,这是我自己折腾了大半年的一个项目代号。简单说,这是一个基于 Web 的图表设计工具,能拖拽节点、连线、框选、缩放、自动布局,也能导出 JSON、SVG、PNG,最终目标是让用户像搭积木一样快速画出架构图、流程图、ER 图甚至思维导图。
做这个项目的直接原因很简单:团队里画架构图一直靠国外的在线白板工具,数据没法进内网,权限控制也绑在别人家的账号体系上。后来我干脆自己写一个,代号就叫 diagram-design,从零实现了一个轻量级的图编辑器核心。这篇文章不发源码,只把关键设计决策、数据结构、渲染方案、交互坑点和性能优化思路拆开讲。如果你正准备做白板工具、流程编辑器、拓扑图编辑器,或者只是想在项目里嵌一个定制的图形设计模块,这篇应该能帮你少踩不少坑。
1. 项目设计与需求拆解
1.1 为什么做 Diagram Design 工具
市面上的图表工具其实分成两大类。一类是展示型的,比如 ECharts、D3.js,它们擅长把数据渲染成图,但不太擅长让用户自由拖拽编辑。另一类是编辑器型的,比如 draw.io、Excalidraw,它们编辑能力强,但要么体积大、要么定制困难、要么数据格式和业务系统难以打通。
我当时遇到的需求很具体:要给一个运维平台做一个“服务拓扑编辑页”,用户可以手动拖出一个服务节点,连线表示调用关系,还要能从接口自动导入一份拓扑数据,编辑完再导回去。用现成编辑器的话,数据模型得来回适配,自定义节点样式也很别扭。自己写一个,反而能完全掌控数据格式、交互细节和视觉表现。
所以这个项目的定位不是“再造一个 draw.io”,而是做一个嵌在业务系统里的图表设计模块。核心能力只需要四件事:
- 自由拖拽、缩放、选中、框选、删除、复制
- 节点与节点之间连线,连线支持直角折线或曲线
- 画布缩放与平移,支持鼠标和触控板
- 导入导出结构化 JSON,配合业务数据做双向同步
想清楚边界之后,很多决策就变得非常简单。不需要做多人协同,不需要做云存储,不需要做 AI 自动布局,甚至不需要做复杂的撤销重做栈(后来发现做还是要做的,但这属于后话)。
1.2 功能边界与技术难点拆解
功能边界定下来之后,真正的技术难点其实集中在三块:
第一是数据结构设计。图编辑器一切操作都发生在 Graph 模型上,节点、边、分组、端口、画布视口,这些数据模型如果设计得不好,后面每加一个功能都像在打补丁。
第二是交互与渲染的分离。拖拽、缩放、框选这些操作,本质上是修改数据,但渲染层需要实时响应。如果每次操作都整图重绘,几千个节点的时候性能就会崩。
第三是坐标换算的准确性。画布平移缩放之后,鼠标的屏幕坐标和画布的逻辑坐标之间有一个换算关系。这个搞错了,拖拽会“飘”,框选会错位,连线对不准端口。
这三个问题解决掉,一个图编辑器的骨架就搭起来了。剩下的都是往骨架上填肉:样式、主题、交互细节、导出格式、快捷键、右键菜单。
2. 技术选型:不是选最好的,而是选最合适的
2.1 渲染方案对比与选型
讲技术选型之前,先把结论放出来:我最终选了SVG 渲染节点和连线,Canvas 渲染网格背景,两者叠加使用。
原因很简单,图编辑器这个场景里,SVG 的优点过于匹配需求:
- SVG 的 DOM 元素天然支持事件绑定,命中检测不需要自己做几何计算
- 节点和边的样式可以用 CSS 控制,写主题非常方便
- 文本渲染效果好,支持换行、富文本、国际化排版
- 导出为独立 SVG 文件几乎零成本
- 对中等规模的图(几百到一千节点)性能完全够用
缺点也很明显:节点数量过万时,SVG DOM 数量太多,首屏渲染和局部更新都会吃力。但实际业务场景里,一个服务拓扑图能有几百个节点就算很多了,上万节点的图更适合用 Canvas 或 WebGL 做纯展示,而不是做编辑。
选 Canvas 渲染网格背景,是因为网格线和坐标刻度不需要响应任何点击事件,也不需要单独更新。用 Canvas 画一次静态图,然后把它当作 CSS 背景铺在 SVG 层的下面,性能比 SVG 画几千个网格矩形高出几个数量级。
选型的时候认真对比过 WebGL,它适合超大图和频繁全量重绘的场景。但 WebGL 的开发成本高,文本渲染、命中检测、缩放平滑度处理起来都麻烦。对一个内部工具来说,完全没有必要为了“看起来很酷”去承担三倍开发成本。
2.2 前端框架与状态管理
框架这块没有太多悬念。项目基于 Vue 3 开发,状态管理用了轻量的响应式对象,没有引入太重的外部状态库。原因我得解释一下,因为很多人会问我这个问题。
图编辑器的状态和普通表单状态不一样。普通表单状态是“某个字段的值”,图编辑器状态是“整张图”,它包含成百上千个节点和边,每个节点有自己的坐标、尺寸、层级,边上有折线点、端口信息。如果把这些全部塞进 Redux 或 Pinia 并严格遵循不可变更新,每次拖拽一个节点都要深拷贝整棵树,几百个节点时性能就会开始难受。
我的做法是:Graph 数据本身用带 Setter 的普通对象维护,渲染层通过一个轻量的渲染调度器依赖收集,知道“哪个节点移动了”“哪条边需要更新”,只精确定位到变化的部分做 DOM patch。这样拖拽过程中,其他节点完全不受影响。
3. 数据建模:一切交互的前提
3.1 节点与边的核心数据结构
设计数据结构时,我参考了常见图编辑器的做法,并结合业务需求做了简化。核心结构如下:
interface GraphModel { id: string; version: string; nodes: Record<string, NodeModel>; edges: Record<string, EdgeModel>; viewport: ViewportModel; } interface NodeModel { id: string; type: string; // 节点类型:service、database、group、text... position: { x: number; y: number }; size: { width: number; height: number }; zIndex?: number; parentId?: string; // 属于哪个分组 data?: Record<string, any>; // 业务数据 ports?: PortModel[]; // 连接点 } interface EdgeModel { id: string; source: string; // 源节点 ID target: string; // 目标节点 ID sourcePort?: string; // 源端口 ID targetPort?: string; // 目标端口 ID waypoints?: Point[]; // 折线控制点 style?: EdgeStyle; } interface ViewportModel { zoom: number; offset: { x: number; y: number }; }所有节点放在一个Record<string, NodeModel>里,以 ID 为键,而不是数组。这个选择对后续所有操作都有利:删除节点、查找节点、更新节点都是 O(1) 复杂度,拖拽时修改某个节点不会触发其他节点的响应式更新,连线时通过 ID 直接找到两端节点的位置信息。
边不直接存起点和终点的坐标,而是存 source 和 target 的节点 ID。每次渲染时,边的几何路径根据两端节点的当前位置动态计算。这样做的好处是,拖动节点时边会自动跟随,不需要手动维护边的坐标变化。
3.2 分组与嵌套的实现
分组是图编辑器的常见需求。架构图里经常要把一组服务画在一起,然后整体移动。我在节点模型里加了一个parentId字段,节点可以隶属于某个分组节点。
分组节点本身也是一个 NodeModel,只是 type 为 “group”。渲染时分组节点是一个矩形背景框,所有 child 节点渲染在它的上面,移动分组时它的所有子节点坐标跟着偏移,删除分组时会先递归删除所有子节点。关系边界要处理好,尤其是一个节点能否拖出分组、拖出之后parentId是否更新、能否嵌套分组。
我一开始没有做嵌套上限,结果用户创建了五层嵌套,递归遍历和渲染都出现了奇怪的偏移问题。后来把嵌套深度限制为三层,并在 UI 上给出提示,实际情况基本够用。
3.3 自动布局:简单又实用的分层算法
很多图编辑器都带“自动布局”功能,但大多数用户不会真的依赖自动布局来画图。真正有价值的是两个场景:一是从接口导入数据后,给所有节点一个初始位置;二是用户一堵墙乱堆了几十个节点,希望能一键整理。这两种场景不需要力导向算法那么复杂,一个基于拓扑排序的分层布局就能解决。
我的实现思路是:
- 根据边的方向,从没有入边的节点出发,做一次拓扑排序
- 把节点按照拓扑层级分配到“列”(横向布局)或“行”(纵向布局)
- 同一层级的节点,按次序均匀排列,间距固定
- 如果一个节点有多个上游和多个下游,取下游层级的平均位置作为它的垂直中心
核心代码大概是这个样子:
function layoutByTopology(graph: GraphModel): LayoutResult { const inDegree: Record<string, number> = {}; const adjList: Record<string, string[]> = {}; // 初始化入度和邻接表,找源头节点 const queue = Object.keys(graph.nodes).filter(id => (inDegree[id] || 0) === 0); const levels: Record<string, number> = {}; // BFS 分层 while (queue.length) { const id = queue.shift(); for (const neighbor of adjList[id] || []) { inDegree[neighbor]--; if (inDegree[neighbor] === 0) { levels[neighbor] = levels[id] + 1; queue.push(neighbor); } } } // 按层级分配坐标 const grouped: Record<number, string[]> = {}; for (const id of Object.keys(levels)) { const level = levels[id]; (grouped[level] ||= []).push(id); } const layout: Record<string, Point> = {}; const levelHeight = 120; const nodeGap = 40; Object.keys(grouped).forEach((level, li) => { const ids = grouped[level]; const totalWidth = ids.reduce((sum, id) => sum + graph.nodes[id].size.width + nodeGap, 0); let cursorX = -totalWidth / 2; // 居中布局 ids.forEach((id) => { layout[id] = { x: cursorX, y: li * levelHeight }; cursorX += graph.nodes[id].size.width + nodeGap; }); }); return layout; }自动布局不需要做到完美,它只需要提供一个合理的初始位置,用户在此基础上做微调。过分追求完美的布局算法,反而会牺牲手工调整的灵活度。
4. 交互细节:拖、连、选、缩背后的几何与坐标换算
4.1 屏幕坐标与画布坐标的换算
画布平移和缩放是图编辑器最基础的能力,但很多初次实现的人都会卡在坐标换算这个问题上。这里要理清两个坐标系:
- 屏幕坐标系:鼠标事件给出的 clientX/clientY,相对于浏览器窗口
- 画布坐标系:节点 model 里存的 position,是图数据真正的坐标
两者之间的桥梁是viewport = { zoom, offset }。换算公式是:
canvasX = (screenX - offset.x) / zoom canvasY = (screenY - offset.y) / zoom反过来就是:
screenX = canvasX * zoom + offset.x screenY = canvasY * zoom + offset.y所有的鼠标交互,第一步都是先把屏幕坐标换算成画布坐标,然后再做后续的几何计算。这个习惯必须养成,否则后面拖拽、框选、连线都会出问题。
4.2 实现拖拽:按下、移动、抬起的完整链路
拖拽节点是最基础的交互。实现逻辑并不复杂,但细节决定手感。
用户按下鼠标时,记录三个值:按下时的屏幕坐标、按下时节点在画布中的坐标、按下时画布的缩放倍率。鼠标移动时,计算当前屏幕坐标与按下时屏幕坐标的差值,然后把差值除以 zoom,加回到节点的初始坐标上,得到节点当前位置。
let dragStartScreen: { x: number; y: number }; let dragStartNodePos: { x: number; y: number }; let zoomAtStart: number; onMousedown(e, node) { dragStartScreen = toCanvasPoint(e.clientX, e.clientY); dragStartNodePos = { ...node.position }; zoomAtStart = graph.viewport.zoom; } onMousemove(e) { if (!draggingNodeId) return; const currentPoint = toCanvasPoint(e.clientX, e.clientY); const dx = currentPoint.x - dragStartScreen.x; const dy = currentPoint.y - dragStartScreen.y; targetNode.position.x = dragStartNodePos.x + dx; targetNode.position.y = dragStartNodePos.y + dy; }这里有个细节:toCanvasPoint内部要除以 zoom,但拖拽位移本身也受 zoom 影响。所以代码里如果toCanvasPoint已经除过 zoom,那dx就直接是画布坐标系的差值,直接加到节点初始位置上即可,不需要再除一次。很多新手在这里会重复除以 zoom,导致放大后拖拽速度极慢,缩小后拖拽速度极快。
按下到移动之间建议加一个阈值判断,比如移动超过 3 像素才认为进入拖拽状态。否则用户只是想点击选中一个节点,稍微抖一下鼠标就会被误判成拖拽,体验很差。
4.3 连线操作与端口命中
连线是图编辑器里另一个高频交互。用户从一个节点的端口出发,拖出一条“橡皮筋线”,松开鼠标时如果掉在另一个节点的端口上,就创建一条边。
实现要点有两处:
第一,连线的起始端口要记录的是节点 ID 和端口 ID,而不是坐标。因为端口坐标会随着节点移动而改变,存坐标就是存快照,后面更新会很麻烦。
第二,端口命中检测不需要遍历所有节点。先把所有可见节点在画布坐标系里的包围盒算出来,然后用鼠标当前位置做点与矩形的包含检测。如果图上有几百个节点,直接遍历也很快,没必要用空间索引。如果图很大,再考虑四叉树。
连线过程中边要实时跟随鼠标更新。这里用 SVG 画一条 path 或 polyline,路径的起点是源端口位置,终点是鼠标当前位置。只有当鼠标抬起且命中目标端口时,才把临时路径转成一条正式的 EdgeModel。
还有一个容易被忽视的点:连接线要区分“允许连接”和“禁止连接”。比如在架构图里,服务节点之间允许连线,但文本节点不能作为连线的起点或终点。命中检测时会先判断目标节点是否符合可连接条件,不符合就显示红色禁止样式,符合就高亮端口。
4.4 滚轮缩放与锚点保持
滚轮缩放的功能看起来简单,但很容易做成“缩放时鼠标下的内容跑到别的地方去”。这个问题本质上是缩放锚点没处理好。
正确的逻辑是:放大或缩小时,鼠标所在的画布坐标点要保持不动。也就是缩放后,鼠标屏幕坐标对应的画布坐标和缩放前一致。
实现方式是先记录缩放前鼠标对应的画布坐标点,应用新的 zoom 之后,调整 offset 让这个点仍然映射到鼠标当前屏幕位置。
function onWheelZoom(e: WheelEvent) { const beforeCanvasPoint = toCanvasPoint(e.clientX, e.clientY); const nextZoom = clamp(graph.viewport.zoom * (e.deltaY < 0 ? 1.1 : 0.9), 0.2, 3); graph.viewport.zoom = nextZoom; graph.viewport.offset.x = e.clientX - beforeCanvasPoint.x * nextZoom; graph.viewport.offset.y = e.clientY - beforeCanvasPoint.y * nextZoom; }这个公式不复杂,但我调试了很久才完全理解。offset 的意思其实是“画布原点的屏幕坐标”。如果把画布原点放在屏幕左上角,那 offset 就是鼠标位置减去画布点坐标与缩放倍率的乘积。这样缩放手感完全自然,鼠标指向哪里,哪里就保持不动。
按住空格或鼠标中键拖动画布,逻辑也是一样的。按中键时记住初始 offset 和鼠标初始位置,移动时直接更新 offset 就行。
4.5 框选的核心算法
框选本质上是一个矩形相交检测问题。按下并拖出一块选区,凡是节点包围盒与选区矩形相交的节点都算选中。判断两个矩形是否相交的算法非常简单:
function intersect(r1: Rect, r2: Rect): boolean { return !( r1.x + r1.width < r2.x || r2.x + r2.width < r1.x || r1.y + r1.height < r2.y || r2.y + r2.height < r1.y ); }框选过程中,选区矩形是用鼠标当前位置画出来的,但节点包围盒要换算到画布坐标系。不同之处在于,节点包围盒已经是画布坐标,所以要把选区矩形从屏幕坐标换算成画布坐标再比较。如果直接用屏幕坐标和画布坐标去比较,缩放后就完全对不上。
框选完成之后还有一个细节:按住 Shift 再加框选,是追加选中还是替换选中?我实现的是追加,因为替换可以靠单独点击空白处清空。细节虽小,但会影响多选操作的效率。
5. 渲染机制与性能优化
5.1 分层渲染思路
整个画布渲染层分成两层:
- 底层是一个 Canvas,专门画网格背景、缩放比例尺、指引线
- 上层是一个 SVG,承载所有节点和连线
为什么分层而不是全部用 SVG 或全部用 Canvas?因为网格不需要交互,用一次性的 Canvas 绘制最高效。节点和连线需要事件、需要单独更新样式,用 SVG 的元素树最方便。
分层之后还有一个额外的好处:缩放时网格背景 Canvas 可以做低分辨率重建,暂时不重绘也能保证视觉连贯性;节点 SVG 则保持在原有分辨率级别,不会在缩放过程中出现模糊。
5.2 局部更新机制,不做无谓的整图重绘
刚开始实现时,每次拖拽节点我都把整个 SVG 重新渲染一遍。几百个节点时就感觉到了卡顿,因为每次 mousemove 都触发几十上百个元素的 reflow。
后来改成“状态变更 + 精确更新”的方式:每个 NodeModel 和 EdgeModel 在创建时绑定一个唯一的 vNode,响应式系统只更新那个变化了的节点。比如拖动 A 节点,只有 A 节点的 position 变化,它关联的边(起点或终点是 A 的边)需要更新路径,其他节点完全不动。
Vue 的响应式系统天然适合做这件事。因为节点数据是通过响应式对象管理的,修改node.position.x会精准触发依赖这个属性的渲染函数。但如果数据本身是用不可变方式更新的,每个新对象都会被当作全新节点,导致整个列表 diff,性能就会很差。
这里我也试过用带 immer 风格的状态管理,效果不算好。图编辑器领域,可变数据模型 + 响应式依赖追踪,性能上限更高,代码逻辑也更直接。前提是你对自己内部的响应式机制足够熟悉。
5.3 大数据量下的降级方案
如果节点数量超过一千,纯 SVG 就有些吃力。我做的降级方案是:节点超过阈值时,渲染模式从“每个节点一个 DOM 元素”切换成“Canvas 绘制所有节点”。
切换后保留 SVG 层作为交互层,但只放置透明占位元素,用于命中检测和事件。真正的视觉内容全部画在 Canvas 上。这个方案的关键点在于两类数据要保持同步:SVG 占位元素的坐标和 Canvas 绘制内容都来自同一个数据源,每次变化同时更新两边。
这个降级模式下,交互的“精准度”不如纯 SVG,因为 Canvas 上画的视觉内容无法直接绑定事件,只能依赖透明占位元素。但只要占位元素的位置和大小与视觉内容一致,用户是无感知的。
5.4 文本测量与动态尺寸
节点里通常都有文字标题和描述。如果节点尺寸固定,文字超长就需要截断或省略号。如果节点尺寸自适应,就需要计算文本的实际渲染宽度来设定节点宽度。
JavaScript 里用来测量文本宽度的方法有几种:Canvas 的measureText、Range.prototype.getBoundingClientRect、以及 SVG 里的getComputedTextLength。实测下来,Canvas 的measureText最快,但不同字体渲染时和浏览器实际排版略有差异;getComputedTextLength最准但需要元素先挂载到 DOM。
我的做法是用measureText做预估,设置一定余量。中英文混排、emoji、等宽字体这几种情况分别加了不同系数。中文按字宽计算,英文按测量结果计算,emoji 宽度统一按汉字宽度处理。这样误差通常控制在 2px 以内,视觉上看不出来。
6. 导入导出与视觉体系
6.1 导出 JSON、SVG、PNG
导出 JSON 是最简单的,直接把 GraphModel 序列化即可。但要注意存版本号,因为后续数据结构升级时,要能识别老版本文件并做迁移。我吃过一次亏,早期没有版本号,后来改了节点数据格式,老文件全部打不开了。
导出 SVG 时,我是把画布内容用 SVG 重新构造一遍,网格背景不需要导出,节点和边按当前主题色生成 SVG 标签,然后用 Blob 下载。文本内容转义要小心,标题里如果包含<script>之类的字符,不转义就会导致导出的 SVG 在浏览器里打开时可能执行脚本。我加了一道xmlEscape处理所有文本节点。
导出 PNG 是把 SVG 转成图片再绘制到 Canvas 上。流程是:把 SVG 字符串转成Blob,再通过URL.createObjectURL生成一个链接,用Image对象加载这个链接,加载完成后绘制到 Canvas,最后canvas.toBlob导出 PNG。有一个坑是跨域字体,SVG 里用了自定义字体的话,转成图片时字体可能加载失败,导出效果会退化。解决方法是导出前先确认字体已嵌入,或者用统一的系统字体栈。
6.2 主题、样式与视觉一致性
工具本身是为业务服务的,视觉要能和业务品牌统一。我用设计变量集中管理节点颜色、边框、阴影、圆角、字体大小、连线宽度、端口颜色。换主题只改一套变量,所有节点和边的样式都会跟着变,不用在代码里一个个改。
配色上我建议不要超过四种主色。节点用中性色背景,不同类型的节点用不同的边框色或角标色区分。连线用灰色,只在选中或 hover 时高亮为主题色。箭头大小要跟着连线宽度走,否则缩放之后箭头和线宽比例失调,视觉上很别扭。
折线连线的拐角半径也要统一。直角折线在拐弯处如果硬切,放大后会看到明显的锯齿感。我给拐角加了一个 8px 的圆角过渡,视觉上柔和很多。
7. 常见问题与排查技巧实录
7.1 拖拽“飘”了,节点跟不上鼠标
现象是放大后拖拽节点,节点移动速度比鼠标慢,缩小后节点比鼠标快。原因就是第 4.2 节提到的重复除以 zoom。排查步骤很简单:分别把 zoom 设为 1、2、0.5,观察拖拽位移和鼠标位移的比例关系,就能确定是哪一步多乘或多除了。
7.2 滚轮缩放后节点位置跳变
现象是按住 Ctrl 滚轮缩放,鼠标下的节点会突然跳到别的位置。原因是 offset 更新错误。检查的时候,打印缩放前后鼠标点的画布坐标,要求两个坐标相等。如果不相等,就是锚点公式写错了。还有一个容易忽略的点:缩放倍率要做上下限限制,否则用户无限放大,坐标数值会溢出。
7.3 连线绑定到了错误端口
早期实现时,连线创建时只记录了源和目标节点 ID,没有记录端口 ID。当节点有多个端口时,程序默认用第一个端口,结果用户明明是从第二个端口拉出去的线,重算路径后却连到了第一个端口。解决方法是把sourcePort和targetPort作为 EdgeModel 的必填字段,创建时就把端口 ID 固定下来,之后所有路径计算都用这个端口。
7.4 框选时节点明明在选区里却没选中
最简单的排查方法是先确认坐标是否统一。框选矩形是屏幕坐标,节点包围盒是画布坐标,如果直接比较就会出错。把矩形换算成画布坐标再比较,问题就消失了。还有一个隐藏问题:节点包围盒默认是相对分组内部坐标的,如果节点有parentId,比较时要把它在分组内的坐标加上分组左上角的偏移,换算到全局画布坐标。
7.5 导出图片时文字丢失或变方块
这个坑主要出现在自定义字体上。浏览器加载了自定义字体,但在 Canvas 绘制时字体尚未加载完成,绘制出来的还是默认字体。导出前先document.fonts.load()等待字体加载,或者导出时把文本用文本路径的方式生成,后者更可靠但复杂度高。如果字体只是普通的中文宋体、黑体,系统字体栈一般不会出问题。
写在后面的一些体会
做图编辑器最花时间的不是画节点、连线的功能本身,而是把交互做得“不别扭”。坐标换算、缩放锚点、框选判定、端口命中,每一项单独拎出来都不难,但组合到一起,任何一个环节出错,手感都会很奇怪。我调试滚轮缩放锚点花了两天,当时看代码怎么都觉得没问题,最后发现自己在一个工具函数里多了一次坐标变换,删掉就好了。这种问题,只能靠耐心和系统性的打印日志去排查。
如果让我重来一次,数据结构仍然会采用节点和边都用 ID 索引的方式,这个决策让我省掉了很多麻烦。另外,可视化相关的计算几何问题,千万别自己硬造轮子,把 rect 相交、点在矩形内、线段交点这些基础能力写好,后面所有功能都是在这上面叠的。
diagram-design 这个项目后续我还在扩展,比如加更多的自动布局算法、导出成 Markdown 流程图代码、还有节点间数据的动态绑定。如果你也在做类似的东西,欢迎交流。