做这个项目,是因为团队内部一直缺一个“趁手”的架构图绘制工具。市面上现成的图表软件不是不好用,而是导出格式和内部知识库、文档系统之间总有断层:要么图片无法继续编辑,要么数据拿不回来,更不要说团队内部要对图里的节点做自定义标注和权限控制。于是我用两周多时间从零写了这个纯前端的图表设计工具,代号就叫diagram-design。这篇文章不是广告,是把整个过程中最核心的技术决策、数据模型设计、交互实现细节,以及反复返工才想明白的坑,一次性复盘出来。
它能做什么?简单说:在浏览器里画流程图、架构图、网络拓扑图,支持节点拖拽、锚点连线、缩放平移、框选多选、撤销重做,最终可以导出成 JSON、PNG、SVG。适合谁看?两类人:一是准备做内部工具、低代码平台、可视化编辑器的前端开发者,二是对图形编辑产品底层逻辑感兴趣、想了解这类系统怎么从 0 到 1 搭建的同学。下面都是实操过程中的经验,可以直接拿去用。
1. 先说清楚:图表设计工具到底在解决什么问题
1.1 这类工具的共性能力与本质
开始动手之前,我先画了一张“功能边界图”,把项目控制在能跑通的最小闭环里。图表设计工具再复杂,本质都在解决一件事:维护“对象 + 关系”的完整生命周期。对象是节点,关系是连线,生命周期则包括创建、编辑、布局、保存、导出、回读。
具体拆解下来,核心能力其实就三类:
- 画布能力:无限画布、缩放平移、网格吸附、框选多选。
- 节点能力:不同类型形状(矩形、菱形、圆角矩形、椭圆)、位置尺寸调整、样式自定义。
- 连线能力:起点终点绑定到锚点、自动折线、标签文字、连线样式。
很多看起来很炫的图表产品,无非是在这三层上叠加了主题模板、组件库、协同和分享。如果你只是想自己做一个内部用的工具,完全不需要一开始就碰协同、自动布局、富文本编辑这些高难度模块——先把上面闭环跑通,后续扩展是水到渠成的事。
这个认知帮我省了大量时间。一开始我脑子里的需求列表很长,真拆下来发现核心就十几项,其余全是“锦上添花”。新手做这种项目最忌讳一上来就铺大摊子。
1.2 技术选型:为什么没有套现成方案
我知道你肯定会问:现成的开源图表/白板工具有那么多,为什么还要自己写?每个团队情况不一样,我的理由主要是三点:
- 数据要能嵌入业务系统。我们画出来的架构图,需要保存到带有业务语义的数据模型里,节点上要挂自定义字段,例如负责人、部署环境、健康状态。现成工具的数据存储是通用格式,二次开发成本反而比从零写更高。
- 交互需要深度定制。比如连线经过某个节点时自动避让、点击节点弹出业务抽屉、根据节点状态变更颜色。这些在通用工具里属于“插件”,但在自己的渲染层里只是一个普通的响应函数。
- 渲染精度和导出一致性。项目要求导出的图片必须和画布所见完全一致,字体、间距、颜色分毫不差。自研渲染可以保证“所见即所得”,接别人的引擎反而要处理一层“逼近”。
所以在渲染方案上,我直接在SVG和Canvas之间做了取舍。最终选了 SVG。原因很实际:项目规模不会太大(单张图节点数量一般在 500 以下),而 SVG 每个元素都是 DOM,天然支持事件绑定、点击命中、CSS 样式隔离,调试起来直观方便。
| 维度 | SVG | Canvas |
|---|---|---|
| 元素数量 | 适合中小规模(数百节点内) | 适合大规模(数千节点以上) |
| 交互命中 | 原生 DOM 事件,天然支持 | 需要自己做空间索引与命中检测 |
| 样式控制 | CSS 直接作用,方便主题化 | 全部靠绘制代码维护 |
| 导出还原度 | 高,直接序列化 DOM | 需要额外处理缩放与字体 |
| 开发效率 | 高,心智负担低 | 低,图形上下文管理复杂 |
这个选择的核心逻辑是:不要为了想象中的“极致性能”牺牲开发效率,除非你确定需求就是超大规模画布。
2. 数据模型是整个项目的定海神针
2.1 三张核心数据结构
在写第一行渲染代码前,我先把数据模型定了。这是整个项目里最不能着急的部分,因为后面所有功能——撤销重做、序列化、导出、协同——全部建立在数据结构之上。数据结构设计错了,后期改起来等于重写。
我最终定义了三个最核心的类型,用 TypeScript 描述大概是这样的:
// 节点 interface DiagramNode { id: string; // 全局唯一 type: "rect" | "rounded" | "diamond" | "ellipse"; x: number; // 左上角横坐标(场景坐标) y: number; // 左上角纵坐标 width: number; height: number; style: Record<string, unknown>; // 填充色、边框、阴影等 data: Record<string, unknown>; // 业务自定义数据 } // 连线 interface DiagramEdge { id: string; sourceId: string; // 起点节点 id sourceAnchor: "top" | "right" | "bottom" | "left"; targetId: string; // 终点节点 id targetAnchor: "top" | "right" | "bottom" | "left"; points: Array<{ x: number; y: number }>; // 折线拐点(场景坐标) label?: string; style: Record<string, unknown>; } // 画布视图 interface EditorViewport { scale: number; // 缩放倍率,初始 1 offsetX: number; // 画布水平偏移(屏幕坐标) offsetY: number; }我把节点坐标定位为左上角坐标 + 宽高,而不是中心坐标。这一点看着小,实际影响很大:框选命中、矩形绘制、网格对齐、旋转缩放,全部基于左上角宽高矩形来做,数学计算最直接。如果定位为中心点,每次计算 bounding box 都要多一次换算,而且后续如果要支持分组和旋转,左上角模型在矩阵变换时更直观。
连线为什么要记录points数组而不是只存sourceId/targetId?因为折线拐点是用户经过拖拽、自动布局调整后形成的“已建模结果”。如果只存两端节点,每次渲染都要重算路径,当用户手动调整过拐点位置后,重算会覆盖用户意图。保存拐点,相当于把路径当作一等公民,渲染只是简单地把点连起来。
1.2 锚点与折线路径生成
连线贴到节点上时,起点终点不应该悬空,而是准确吸附在节点的边线上。这就是sourceAnchor、targetAnchor四个方向枚举的作用。计算锚点位置其实是一个简单的矩形边线定位:
- 锚点
right:取(x + width, y + height / 2) - 锚点
bottom:取(x + width / 2, y + height) - 锚点
left、top同理。
有了锚点坐标,再谈折线。最常见的连线场景是两个节点之间拉一条正交折线,以及移动节点时折线跟随刷新。折线生成算法我在项目里用的是最简单实用的“三步法”:
- 根据两个锚点的方向组合,判断主方向。例如从右侧锚点到左侧锚点,水平方向优先。
- 在连线中点处补充中间拐点,组成一个“水平线—垂直线—水平线”的三段结构。
- 如果两端节点距离太近,正交折线会变得很挤,这时退化为带圆角的斜线。
伪代码如下:
function generateOrthogonalPoints(source, target) { const sx = source.x, sy = source.y; const tx = target.x, ty = target.y; // 主方向是水平 const middleX = (sx + tx) / 2; return [ { x: sx, y: sy }, { x: middleX, y: sy }, { x: middleX, y: ty }, { x: tx, y: ty } ]; }这个算法做不了复杂的绕障和避让,但对 90% 的架构图场景已经够用。如果后面需要自动避开其他节点,可以在points基础上做一次“推挤”,把落在其他节点矩形范围内的拐点沿垂直方向平移出去。注意要以保存在 points 里的拐点为准,而不是每次都重新生成,否则用户手动调过的线会在避让时被破坏。
2.3 为什么要单独设计坐标系统
刚开始写的时候我犯过一个常识性错误:直接用屏幕坐标存节点位置,缩放时把每个节点的 x/y 全部乘以 scale。这个方案一旦滚动平移,所有节点都得整体重算,而且浮点数误差会不断累积,越操作越偏。
正确的做法是场景坐标与屏幕坐标分离:
- 场景坐标:节点、连线、锚点的真实位置,永远不随缩放和平移改变。
- 屏幕坐标:鼠标、画布滚动的位置,随着用户操作实时变化。
两者通过 viewport 中的scale、offsetX、offsetY互转。屏幕坐标转场景坐标的公式是:
sceneX = (screenX - offsetX) / scale sceneY = (screenY - offsetY) / scale反过来:
screenX = sceneX * scale + offsetX screenY = sceneY * scale + offsetY这套公式是整个编辑器的基础设施,后面所有交互——拖拽、缩放锚定、框选、连线——都离不开。设计数据模型时,把 viewport 独立出来,和节点、连线并列,是最重要的决定之一。
3. 交互层实现:拖拽、缩放、连线、框选
3.1 拖拽与缩放背后的坐标换算
交互层是最容易写乱的部分,核心难点全在“坐标换算”上。直接贴一段代码,看看一个标准的拖拽实现需要注意什么:
canvas.addEventListener("pointerdown", (e) => { if (e.target.closest(".node")) { // 记录拖拽起始点(注意转为场景坐标) dragStart = toScene(e.clientX, e.clientY); const node = getNodeById(e.target.closest(".node").dataset.id); nodeStart = { x: node.x, y: node.y }; canvas.setPointerCapture(e.pointerId); } }); canvas.addEventListener("pointermove", (e) => { if (!dragStart) return; const current = toScene(e.clientX, e.clientY); const dx = current.x - dragStart.x; const dy = current.y - dragStart.y; // 节点位置 = 起始节点位置 + 鼠标位移 updateNode(nodeId, nodeStart.x + dx, nodeStart.y + dy); });这里最容易踩坑的是toScene函数的实现。很多人直接写(clientX - offsetX) / scale就完了,但如果在pointerdown前画布发生过滚动,offsetX已经变化,而pointerdown记录的是屏幕坐标,pointermove又记录一次场景坐标,两次换算之间 offset 不一致,拖动就会出现“节点跳一下再跟随”的鬼影。
解决办法是:在 pointerdown 时把起始屏幕坐标和起始场景坐标都记录一次,每帧位移用“当前屏幕坐标 - 起始屏幕坐标”除以 scale 得到增量,再用这个增量加到起始场景坐标上。也就是说,增量在屏幕空间计算,最终位置在场景空间还原:
const deltaX = (e.clientX - downScreenX) / viewport.scale; const deltaY = (e.clientY - downScreenY) / viewport.scale; node.x = downNodeX + deltaX; node.y = downNodeY + deltaY;这个方案完全绕开了 offset 变化的问题,即便指针移出画布再回来也不会跳。
3.2 以鼠标为中心的画布缩放
画布缩放不是简单地把 scale 改大改小,而是要让鼠标所在位置在缩放前后保持“场景坐标不变”。这个需求背后的数学公式推导很关键。
假设缩放前鼠标处的场景坐标为(sceneX, sceneY),屏幕坐标为(mouseX, mouseY)。缩放后我们希望鼠标处仍指向同一个场景坐标。由屏幕坐标转场景坐标的公式可得:
sceneX = (mouseX - offsetX) / scale缩放后scale' = scale * factor,为了让sceneX不变,需要新的offsetX'满足:
sceneX = (mouseX - offsetX') / scale'代入并化简,得到:
offsetX' = mouseX - (mouseX - offsetX) * factor offsetY' = mouseY - (mouseY - offsetY) * factor执行滚轮缩放时,我就用这两行公式实时更新 viewport:
canvas.addEventListener("wheel", (e) => { e.preventDefault(); const factor = e.deltaY < 0 ? 1.05 : 0.95; const mouseX = e.clientX; const mouseY = e.clientY; const newScale = clamp(viewport.scale * factor, 0.2, 3); const realFactor = newScale / viewport.scale; viewport.offsetX = mouseX - (mouseX - viewport.offsetX) * realFactor; viewport.offsetY = mouseY - (mouseY - viewport.offsetY) * realFactor; viewport.scale = newScale; render(); });这里的clamp把缩放比限制在 0.2 到 3 之间,防止用户误操作把画布缩放到找不着的程度。另外一个细节:滚轮事件必须加passive: false,不然 Chrome 会默认把滚轮当作页面滚动处理,preventDefault无效。
3.3 连线交互:从点选锚点到实时路径预览
连线是整个交互里最有成就感的模块。我实现成“点住锚点拖出连线,松开落到目标锚点完成连接”。这里有两个关键细节决定手感。
第一是锚点命中区域。实际渲染时锚点是一个半径为 6 的小圆,但鼠标命中范围应该放大到 12 到 16 像素,否则拖拽连线到对端锚点时很难精准对中。这个命中半径还要除以 scale,否则缩小画布时命中区域会变小,放大时会变得特别敏感:
const hitRadius = 12 / viewport.scale;第二是临时连线预览。从起点锚点拖出时,我创建了一个临时的 SVG path,粗虚线样式,颜色与正常连线区分。在pointermove中不断把终点坐标更新为鼠标位置;同时检查当前是否悬停在某个目标锚点的命中范围内,如果是,把终点吸附到该锚点,并把临时连线颜色改为“可释放”的绿色。
为了判断“悬停到哪个锚点”,我维护了一棵简单的 R 树(一个非常轻量的矩形索引),把所有可见节点的四个锚点位置实时加入索引。每次pointermove只需要做一次区域查询,就能知道鼠标落在哪个锚点上。没有索引时直接遍历所有节点算距离也行,但节点一多会明显卡顿。
function getAnchorAtPoint(sceneX, sceneY) { const candidates = spatialIndex.query({ x: sceneX - hitRadius, y: sceneY - hitRadius, width: hitRadius * 2, height: hitRadius * 2 }); return candidates.find((anchor) => { const dx = anchor.x - sceneX; const dy = anchor.y - sceneY; return Math.sqrt(dx * dx + dy * dy) <= hitRadius; }); }3.4 框选与多选
框选是图形编辑器的基础能力,实现起来比想象中简单,只要做一次矩形相交判断。因为节点坐标就是左上角加宽高,框选判断直接复用 DOM 的矩形相交逻辑就好。
function isRectOverlap(selectionRect, nodeRect) { return !( selectionRect.x + selectionRect.width < nodeRect.x || selectionRect.x > nodeRect.x + nodeRect.width || selectionRect.y + selectionRect.height < nodeRect.y || selectionRect.y > nodeRect.y + nodeRect.height ); }但框选有两个细节容易被忽略:
- 框选矩形应该用场景坐标来画,换算方式与拖拽一致。如果直接用屏幕坐标画矩形,框选场景内所有节点的范围会与视觉不一致。
- 缩放状态下,框选矩形的宽高要除以 scale,否则框选范围超过视觉范围,用户会觉得“我没框到它它却被选中了”。
多选后的批量移动,逻辑上就是在拖拽基础上,把“更新一个节点”换成“更新所有选中节点”。每个节点记录相对位移起点,然后整体同步移动。真正麻烦的是后面接的批量更新要作为一条独立的 undo 记录,这是第 4 节要讲的。
4. 撤销重做与历史栈
4.1 两种方案的对比:命令模式 vs 快照模式
撤销重做是编辑类工具的必备能力,但如何实现,业界基本分两派:命令模式和快照模式。
命令模式,是把每次操作封装成“执行 + 反执行”两个闭包,例如“移动节点”的 undo 就是“把节点移回原位”,“新增连线”的 undo 就是“删除这条连线”。优点是可以精确控制每一次操作的影响范围,内存占用小;缺点是每个操作都要写对应的反操作,业务复杂度高。
快照模式,是在每次操作完成后,将整个文档序列化一次,存入历史栈。undo 时直接把栈顶快照替换为当前状态。优点是实现简单、不会漏操作;缺点是内存占用大,每次操作都要深拷贝整棵数据树。
我最终选择了命令模式,但做了一个关键优化:把操作粒度从“基本操作”聚合为“用户可感知的步骤”。比如拖拽移动一个节点,pointerdown 到 pointerup 之间产生的所有增量更新,视为同一条命令,只记录起始和结束两个位置。这样用户一次拖拽,undo 一次就能回到拖拽前,而不是回退十几步中间状态。
命令对象的结构很简单:
interface Command { do(): void; undo(): void; label: string; // 用于历史列表显示 }实际写的时候,我写了一个commit函数:
function commit(command) { history.push(command); command.do(); // 清空 redo 栈 redoStack = []; }撤销操作:
function undo() { const command = history.pop(); if (!command) return; command.undo(); redoStack.push(command); }4.2 实操中踩过的坑:原子性和异步干扰
命令模式最容易踩的坑,是一次用户操作意外产生了多条命令。我举一个真实例子:实现“框选后拖动”时,如果每个节点在 pointermove 里单独执行一次位置更新,同时每次更新都注册为一个 command,那么一次拖拽 20 个节点会产生几十条 undo 记录,undo 一次只回退一个节点,体验非常糟糕。
解决方式是把“整批移动”做成一条命令,在 pointerdown 时记录所有选中节点的初始位置,pointerup 时统一提交一条BatchMoveNodeCommand。
另一个坑是异步任务污染历史栈。比如节点文本编辑过程中触发 debounce 保存,或者节点位置动画过渡中用户立刻执行 undo,很可能 undo 掉的是中间状态而非最终状态。我在实现时统一约定:所有产生数据的操作必须是同步完成的,动画只做视觉过渡,不产生业务数据变更。节点完成移动后,只有最终位置进入命令系统,中间过渡帧纯粹是 CSS transform,不影响数据。
这个约束让撤销重做模块稳定了很多,后来接键盘快捷键 Ctrl+Z / Ctrl+Y 时,几乎没遇到逻辑死结。
5. 导出与跨端复用:让图真正能“带出去”
5.1 自定义 JSON 格式与版本兼容
内部工具的最大优势,就是可以设计一种完全贴合业务的数据格式。我设计的 JSON 结构长这样:
{ "version": 1, "viewport": { "scale": 1, "offsetX": 0, "offsetY": 0 }, "nodes": [ { "id": "node_1", "type": "rect", "x": 120, "y": 80, "width": 160, "height": 60, "style": {} } ], "edges": [ { "id": "edge_1", "sourceId": "node_1", "sourceAnchor": "right", "targetId": "node_2", "targetAnchor": "left", "points": [] } ] }这里必须强调version 字段有多重要。我做过的另一个小项目里,因为没有版本字段,后来数据格式升级时只能靠人肉猜旧数据哪些字段不合法,迁移脚本写得痛苦不堪。diagram-design里我一开始就加了version: 1,并且在loadJSON函数里写了一套版本迁移映射表:加载时如果发现 version 小于当前版本,依次执行每个旧版本到新版本的迁移函数。这套机制成本极低,却让格式升级从此变成一件可以放心做的事。
5.2 导出 SVG 与 PNG 的细节
导出 SVG 的远比想象中简单,原理是把当前画布上的所有节点和连线重新构建为一个独立的 SVG DOM 字符串,然后触发下载。需要注意的是:
- 不要再导入外部 CSS 文件。独立 SVG 文件打开时不会加载页面的样式表,样式必须全部内联到每个元素的
style或attributes上。 - 字体要显式声明。如果页面用了非系统字体,导出后其他设备打开可能显示为默认字体。最简单的方案是全部使用系统字体栈,或者导出时把
font-family写全,避免继承。
导出 PNG 时需要借助 Canvas:先把 SVG 字符串编码为 Image,再绘制到 Canvas 上,最后调用toBlob。这个流程有一个隐蔽的坑:如果 SVG 引用了外链图片,或者画布上存在跨域图片资源,Canvas 会被污染,toBlob直接报错。
解决方案是:所有图片资源在进入画布时,先转成 data URL 或 blob URL,并且把图片的粗细粒度控制在导出可接受的范围内。另外,导出前必须确认所有字体已经加载完成,否则 PNG 里的文字会错位。我有一个loadFonts()函数,在导出按钮点击后先等待相关document.fonts.ready,再执行渲染。
6. 常见问题与排查速查表
6.1 高频问题对照表
在实现过程中我反复遇到若干问题,这里直接整理成速查表,方便你以后对照排查:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 缩放后拖不动节点 | 坐标换算公式里忘记除以 scale | 统一走toScene函数,不要手写换算 |
| 节点拖动时线条不跟随 | 连线拐点没有在节点移动后重新计算 | 监听节点 change,同步更新相关 edges 的锚点与折线 |
| 拖拽连线时无法吸附锚点 | 命中半径没有除以 scale,缩小时看不出吸附 | 命中半径按12 / viewport.scale计算 |
| 框选选中了没框到的节点 | 框选矩形写成了屏幕坐标 | 框选矩形全部转成场景坐标后再判断相交 |
| 撤销后节点位置不对 | 批量操作拆成了多条命令 | 用原子命令包裹整批操作 |
| 导出 PNG 空白 | 外链资源污染 Canvas 或字体未加载完成 | 图片转 data URL,导出前等待document.fonts.ready |
| 滚轮缩放页面跟着滚 | 缺少preventDefault或未设置passive: false | 绑定 wheel 时显式声明{ passive: false } |
6.2 性能优化与渲染细节
SVG 渲染的性能瓶颈主要在节点数量上。500 个节点以下基本不用担心,超过 500 个可以关注下面几个点:
- 减少重渲染范围:把“画布背景层、连线层、节点层”拆成三个独立的 SVG 组,节点移动时只更新节点层,不动连线层。
- 用 transform 代替 x/y 更新:单个节点移动时,用
transform="translate(x y)"更新位置,而不是每帧修改x、y属性,后者会触发属性解析和布局,明显更慢。 - 事件委托到画布:不在每个节点元素上绑定 listener,而是统一监听 SVG 根元素,用
closest做命中检测。这样节点数量增加不会增加事件监听器数量。
6.3 调试实战技巧
图形编辑器的调试比其他前端项目难,核心原因是坐标体系和视觉之间隔着一层运算。我每次遇到“节点跑到画布外边”这类问题,第一件事就是打印 viewport 和节点坐标:
console.log({ viewport: this.viewport, node: { x: node.x, y: node.y, screenX: node.x * this.viewport.scale + this.viewport.offsetX, screenY: node.y * this.viewport.scale + this.viewport.offsetY } });这样能快速判断是节点坐标本身问题,还是换算函数问题。另外,我给画布加了一个内置的“显示坐标网格”开关,打开后能看到以场景坐标为基准的参考网格,凡是和网格对不齐的对象,基本可以断定是换算函数出了偏差。这个开关在调试阶段帮了大忙,最后直接当作网格吸附功能留在了产品里。
说到网格吸附,也是一处容易忽略的逻辑:吸附计算必须基于场景坐标,吸附步长也要除以 scale。我曾经直接拿屏幕坐标做吸附判断,结果在缩放 1.5 倍时,节点永远对不齐网格,后来才意识到这是坐标没有统一导致的。
结尾
这个项目做下来,最大的体会是:图形编辑器最难的不是某个炫酷的图形算法,而是把所有模块用同一套坐标体系、同一套数据模型串起来。我在拖拽和缩放这里返工过三次,每次都是因为局部优化破坏了全局一致性。后来把整套坐标换算抽成toScene/toScreen两个纯函数,所有模块只允许通过这两个函数做坐标转换,问题瞬间减少一大半。
另外一个深刻的经验就是:数据格式和版本化,越早做越好。刚开始多花半天设计好 JSON 结构、加上版本号,后面能节省几天的迁移成本。图表工具的代码可以重构,但用户已经保存的图数据一旦格式变了,兼容负担会压在项目上很久很久。
最后分享一个实用小技巧:如果你也在画布类工具里做“缩放后拖拽锚点连线”的功能,记得把 anchors 的空间索引也做成“懒更新”——只在缩放结束或节点静止时重新构建索引,而不是每次 move 都重建。我把这个优化加上后,500 个节点的图连线时瞬间从卡顿变成流畅。希望这次的复盘能给你的图形编辑项目省掉几个我踩过的坑。