最近给团队做内部工具时,我发现一个特别普遍的痛点:业务流程图、系统架构图、状态机图,几乎每个项目都离不开,但每次想快速画一张能拿得出手的图,都在工具和格式之间折腾半天。正因如此,我启动了 diagram-design 这个图表设计项目。简单说,它是一款面向 Web 端的可视化编辑器,核心功能就是让用户通过拖拽节点、连接关系、调整布局,几分钟内产出一张清晰的架构图或流程图。这篇文章不打算写成产品说明书,而是把 diagram-design 从零搭建过程中最值得分享的设计思路、技术选型和踩坑经验完整摊开,适合正在做可视化工具、或者想给团队搭内部绘图平台的朋友参考。
1. 项目定位:diagram-design 到底解决什么问题
1.1 为什么图表表达比文字更高效
很多人低估了图表在协作里的权重。我观察过团队需求评审的现场:同样的分布式调用链路,用文字描述要写满整页 PPT,而且每写一段就有人追问“这步是同步还是异步”“失败重试挂在哪个环节”。换成一张带箭头和颜色分层的链路图,三分钟就对齐了。图表天然能把“顺序”“分支”“依赖”“归属”这些关系压缩成视觉信号,人脑对空间关系和颜色的处理速度远超对纯文本的解析速度。diagram-design 的核心目标不是做一个画图玩具,而是把“关系表达”这件事的成本降下来。
但成本这件事,恰恰是市面工具最容易忽视的。通用绘图软件功能很全,架不住每位使用者都要重新学习它的交互习惯;在线协作白板够灵活,导出到文档里又经常样式错乱。diagram-design 的定位更聚焦:它默认你画的是“节点 + 连线”型图表,把流程图、架构图、网络拓扑这几类高频场景做到极致,其余能力全部往后退。项目初期我甚至刻意屏蔽了自由画笔和任意形状编辑,目的就是让数据模型保持简单,用户不需要关心“这条线是不是被某个框挡住了”之类的渲染细节。
1.2 核心场景与目标用户
从实际调研来看,diagram-design 最适配三类场景。
第一类是技术文档配图。写接口文档、架构说明时,一张自动布局的组件依赖图比十段代码引用关系更直观。第二类是产品原型辅助。产品经理在画用户流程图时,经常要在白板和正式原型工具之间来回切换,diagram-design 的轻量导出能力可以直接输出图片贴进原型图。第三类是内部运维和监控图的静态预览,比如把服务链路关系可视化,辅助排查问题。
目标用户也不是某个小圈子。可以是完全不会写代码的运营同学,只需要拖拽和填文字;也可以是有二次开发需求的工程师,通过项目暴露的 JSON 数据接口批量生成图表。这个“双端兼容”的思路影响了很多后续设计:操作上必须简单到“零学习成本”,数据上必须开放到“可被程序驱动”。所以 diagram-design 从第一天起就不是纯前端渲染项目,它同时包含了一套稳定的数据结构规范,这一点我后面会专门展开。
1.3 先定数据规范,再做界面
这是 diagram-design 立项时最重要的一条决策:界面可以慢慢调,数据规范必须先稳定。如果界面和数据结构耦合太深,后面加一个自动布局功能可能把整张画布搞崩。
我先定义了一个最简数据模型:
{ "canvas": { "width": 1280, "height": 720, "zoom": 1.0 }, "nodes": [ { "id": "node_1", "type": "rect", "position": [120, 80], "size": [180, 48], "style": { "fill": "#4F7CFF", "label": "订单服务" } } ], "edges": [ { "id": "edge_1", "source": "node_1", "target": "node_2", "label": "HTTP" } ] }可以看到,整个图被拆成画布、节点、连线三类对象。节点只关心自己的位置和样式,连线只关心两端的节点 ID,画布统一管理缩放和尺寸。这种“扁平 + 引用”的设计有几个好处:
- 序列化和反序列化零成本,localStorage、URL 参数、后端数据库都能直接存。
- 自动布局只需要算节点的 position 字段,连线的坐标由渲染层实时计算,不需要持久化。
- 多端协作时,每个客户端拿到同一份 JSON 就能渲染出一模一样的图,不会出现“我这边连线被拉了一下,你那边就乱了”的情况。
diagram-design 到今天迭代了好几版,这个 JSON 结构只增加过字段,没有做过破坏性变更。回头看,这算是最值得的一笔投资。
2. 整体设计思路:一个绘图引擎的骨架长什么样
2.1 渲染层选型:Canvas 与 SVG 的边界
diagram-design 第一个绕不开的技术决策,是用 Canvas 还是 SVG。这两者的脾气完全不同,很多人一上来就二选一,结果要么是大材小用,要么是后期性能捉急。
SVG 的优点是 DOM 化。每个节点都是一个真实 DOM 元素,事件绑定、CSS 控制、辅助功能适配都很直接,非常适合节点数量不太多(比如 200 以内)、需要频繁改样式的场景。缺点是当节点数量上来之后,DOM 节点一多,重绘开销和内存占用会成倍增长,拖拽缩放时容易卡顿。
Canvas 的优点是绘制性能高,几千个节点也能撑住,适合高频重绘的场景。缺点是无状态,所有图形都画在一块画布上,想做“选中某个节点高亮它的连线”这类命中测试,就必须自己实现坐标换算,开发成本明显更高。
diagram-design 最终选择了“SVG 为主体,Canvas 做背景层”的混合方案。具体分工是:背景网格、缩略图预览这类不追求精细选中态的内容用 Canvas 绘制;节点、连线、选中框、控制点这些需要频繁交互的元素用 SVG。这个方案还有个隐藏优势:SVG 本身就是文本格式,配合浏览器的开发者工具可以直接查看某个节点对应的 XML 结构,调试体验非常友好。
2.2 坐标变换:把鼠标位置翻译成画布坐标
绘图工具里最容易让新手翻车的就是坐标换算。屏幕上的鼠标坐标不等于画布坐标,因为中间隔着缩放和位移。
diagram-design 里维护了一个视图变换对象:
const viewport = { x: 0, // 画布左上角在屏幕上的偏移 y: 0, zoom: 1.25 // 当前缩放比例 }; function screenToCanvas(screenX, screenY) { return { x: (screenX - viewport.x) / viewport.zoom, y: (screenY - viewport.y) / viewport.zoom }; }所有鼠标事件进来,第一件事就是调用 screenToCanvas 做坐标转换。如果漏掉这一步,拖拽节点时节点会跟着鼠标跑偏,而且缩放倍率越大跑偏越明显。这是绘图工具最容易被忽略却又最基础的环节。
缩放的核心逻辑也围绕 viewport 展开。鼠标滚轮缩放时,我们希望鼠标指向的那个点,在缩放前后都能保持在同一屏幕位置。实现做法是先算出鼠标对应的画布坐标,然后调整 viewport.x、viewport.y 让这个语义不变:
// 已简化,核心是缩放后修正偏移 function zoomAt(screenX, screenY, nextZoom) { const point = screenToCanvas(screenX, screenY); viewport.zoom = nextZoom; viewport.x = screenX - point.x * viewport.zoom; viewport.y = screenY - point.y * viewport.zoom; }这套“锚点缩放”逻辑在线上跑了大半年,基本没有收到过位置漂移的反馈,说明思路是对的。
2.3 交互状态机:避免拖拽、连线和框选互相打架
绘图编辑器本质上是多个交互模式共存的系统:鼠标按下可能是选中、可能是拖拽、可能是开始连线、也可能是框选。如果不做状态管理,代码很快会变成一堆 if else。
diagram-design 用了一个轻量状态机来管理交互:
- idle:空闲状态,鼠标按下进入候选状态。
- maybe-drag:按下后如果移动距离超过 4 像素,判定为拖拽,进入 dragging。
- dragging:拖拽节点或画布平移。
- connecting:从一个连接点拉出一条线,等待释放到目标节点。
- rubber-band:框选状态,释放后批量选中。
状态机的核心在于“移动阈值”和“目标识别”。按下鼠标时先不急着决定做什么,而是记录起点;移动超过阈值后,根据最初命中的元素类型决定进入哪个状态。这套机制彻底解决了“想框选结果拖动了节点”的误操作问题。很多绘图开源项目里能明显感觉到操作发“粘”,多半就是状态切换写得太草率。
3. 核心实现拆解:关键模块是怎么做的
3.1 节点拖拽与吸附对齐
节点拖拽实现起来不难,难的是拖得舒服。diagram-design 在拖拽里加了几个缓冲细节:
- 拖拽期间自动降低图片质量或去掉阴影,保证 60 帧流畅度。
- 靠近其他节点边缘时触发吸附,吸附半径是 8 像素,帮助用户快速对齐。
- 按住 Shift 键时限制水平或垂直方向移动,方便整理结构。
拖拽的代码核心是监听 pointermove 事件并更新节点 position:
element.addEventListener('pointermove', (e) => { if (state !== 'dragging') return; const canvasPos = screenToCanvas(e.clientX, e.clientY); const dx = canvasPos.x - dragStartCanvas.x; const dy = canvasPos.y - dragStartCanvas.y; updateNodePosition(selectedNodes, dx, dy); });注意这里用的是增量而不是绝对值,因为可能同时拖拽多个节点,增量计算能保持多节点的相对位置不变。
吸附逻辑不是拖拽时逐个检查所有节点,那样复杂度会随节点数线性上升。我按网格预分区,拖拽时只检索目标位置的 3x3 邻域里的节点,实际运行中节点数到 500 依然不卡。
3.2 连线的贝塞尔曲线与实时路径更新
diagram-design 里的连线默认使用三次贝塞尔曲线。比起直线,曲线看起来更柔和,而且能表达方向感。控制点计算有讲究:水平布局的节点之间,控制点向水平方向延伸;垂直布局则向垂直方向延伸。简单来说就是:
function createEdgePath(source, target) { const sx = source.x + source.width; const sy = source.y + source.height / 2; const tx = target.x; const ty = target.y + target.height / 2; const dx = Math.max(40, Math.abs(tx - sx) / 2); return `M ${sx} ${sy} C ${sx + dx} ${sy}, ${tx - dx} ${ty}, ${tx} ${ty}`; }连线上的箭头用的是 SVG marker-end 定义,这样只要路径更新,箭头会自动跟随,不需要额外计算。路径更新通过监听节点的 position 变化来完成。每个节点持有一个依赖列表,存了所有指向它或从它出发的连线 ID;节点移动时只重算这些连线,而不是全图画一遍。这种局部更新策略对性能非常关键。
3.3 自动布局:让图自己排整齐
手动画图最大的问题是“越画越乱”,所以 diagram-design 后期加入了自动布局能力。最优先实现的是分层布局,适合做流程图的从左到右或从上到下的展开。
实现思路是拓扑排序 + 按层分配坐标。核心步骤是:
- 通过 edges 构建有向无环图,如果有环则先做环检测并临时断开一条关系。
- 按入度排序,找出所有起始节点作为第一层。
- 通过层级关系逐层分配 y 坐标,同一层节点均匀分布在 x 轴上。
- 遍历完成后做一层交叉最小化处理,尽可能减少连线交叉。
这部分完全依赖前文定义的扁平 JSON 数据模型。因为数据里只存“谁连谁”,布局器就有足够的自由去重新计算位置;如果一开始把节点坐标写死进结构体,自动布局根本无从下手。
实际体验中,自动布局不是让图变成“标准答案”,而是给用户一个体面的起点。所以 diagram-design 把自动布局设计成“重新排版后保留手动微调”的流程,用户不满意还可以点击还原为手动位置。
3.4 导出与序列化:JSON 和 PNG 的完整输出链路
一个绘图工具如果不能导出,那它永远只是个玩具。diagram-design 支持两种导出格式:JSON 和 PNG。
JSON 导出最简单,把当前数据模型序列化后交给用户,可以直接用于二次开发。PNG 导出则要绕一段路。SVG 渲染的图不能直接截图保存,因为浏览器里的 SVG 可能使用了外部字体和自定义样式,直接转图片会丢失细节或出现空白字体。
我的做法是先把 SVG 字符串序列化,然后绘制到离屏 Canvas 上,最后用 canvas.toDataURL 输出 PNG:
const svgData = new XMLSerializer().serializeToString(svgElement); const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = viewport.width * 2; // 2 倍尺寸保证清晰度 canvas.height = viewport.height * 2; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const url = canvas.toDataURL('image/png'); // 触发下载 }; img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgData);导出时有几个坑必须注意。一是 Canvas 的尺寸和 SVG 的尺寸要匹配,导出后再缩放只是拉伸,不会提高清晰度;二是如果 SVG 里用了非系统字体,导出时浏览器可能无法加载,需要提前用字体嵌入或者在导出时改为纯色文本;三是透明背景导出后放到深色 PPT 里会看不清,所以给用户提供“透明 / 白色背景”两个选项。
4. 实操过程:从零搭一个最小可用图表设计工具
4.1 初始化工程与核心依赖
考虑到代码移植和上手成本,我不建议一上来就上大而全的框架。diagram-design 初版只用了原生 JavaScript + Vite,没有引入任何 UI 框架。这样做的考虑是,绘图工具的核心逻辑集中在坐标变换、数据模型和事件处理上,框架反而会带来额外的渲染不确定性。
工程结构大致是:
diagram-design/ ├── index.html ├── src/ │ ├── model.js # 数据模型定义与序列化 │ ├── viewport.js # 视口变换与缩放 │ ├── renderer.js # SVG 渲染 │ ├── interactions.js # 交互状态机 │ ├── layout.js # 自动布局 │ └── export.js # 导出模块 └── package.json先跑一个最小的 HTML 容器,里面放一个 SVG 元素作为画布主节点:
<div id="app"> <svg id="canvas" width="100%" height="100%"></svg> </div>然后通过模型驱动渲染:
function render(model) { const svg = document.getElementById('canvas'); // 清空再渲染,节点数量少时直接重绘最简单 svg.innerHTML = ''; for (const node of model.nodes) { svg.insertAdjacentHTML('beforeend', ` <g>let dragging = false; let startPos = null; let nodeStartPositions = []; svg.addEventListener('pointerdown', (e) => { const g = e.target.closest('[data-node-id]'); if (!g) return; const nodeId = g.dataset.nodeId; const node = model.nodes.find((n) => n.id === nodeId); dragging = true; startPos = screenToCanvas(e.clientX, e.clientY); nodeStartPositions = selectedNodes.map((n) => ({ id: n.id, x: n.position[0], y: n.position[1] })); g.setPointerCapture(e.pointerId); }); svg.addEventListener('pointermove', (e) => { if (!dragging) return; const current = screenToCanvas(e.clientX, e.clientY); const dx = current.x - startPos.x; const dy = current.y - startPos.y; for (const item of nodeStartPositions) { const node = model.nodes.find((n) => n.id === item.id); node.position[0] = item.x + dx; node.position[1] = item.y + dy; } render(model); updateConnectedEdges(model); }); svg.addEventListener('pointerup', () => { dragging = false; });这套代码的关键点在于记录“起始 canvas 坐标”,而不是用鼠标的屏幕坐标直接赋给节点。否则遇到画布缩放时,节点会飞出去。
4.3 实现线段连接
连接线的交互分为两步:从连接点拉出线,释放到目标节点。
先给节点增加连接锚点。锚点就是节点边框上四个中心点,分别位于上下左右。渲染时用一个小圆表示,平时隐藏,鼠标悬停节点时显示。
拉线逻辑:
let tempEdge = null; // 临时虚线路径 svg.addEventListener('pointerdown', (e) => { const anchor = e.target.closest('[data-anchor]'); if (!anchor) return; const sourceNodeId = anchor.closest('[data-node-id]').dataset.nodeId; state = 'connecting'; const start = getAnchorPosition(model.nodes, sourceNodeId, anchor.dataset.anchor); tempEdge = { start, current: { ...start } }; }); svg.addEventListener('pointermove', (e) => { if (state !== 'connecting') return; tempEdge.current = screenToCanvas(e.clientX, e.clientY); renderTempEdge(tempEdge); // 画一条跟随鼠标的虚线 }); svg.addEventListener('pointerup', (e) => { const target = e.target.closest('[data-node-id]'); if (target && target.dataset.nodeId !== tempEdge.sourceNodeId) { model.edges.push({ id: generateId(), source: tempEdge.sourceNodeId, target: target.dataset.nodeId }); } tempEdge = null; render(model); });这里有一个产品细节:释放鼠标时如果悬停在大段空白区域,会丢弃这条连线,而不是像某些工具一样把线挂在半空中变成“幽灵连线”。这个取舍让数据模型始终保持干净。
4.4 接入撤销重做和自动布局
有了数据模型,撤销重做实现起来非常简单。维护一个操作栈,每一步操作前把当前模型深拷贝推入历史栈:
const undoStack = []; const redoStack = []; function commit() { undoStack.push(JSON.parse(JSON.stringify(model))); if (undoStack.length > 50) undoStack.shift(); // 限制栈深度 redoStack.length = 0; // 新操作后清空重做栈 } function undo() { if (undoStack.length === 0) return; redoStack.push(JSON.parse(JSON.stringify(model))); Object.assign(model, undoStack.pop()); render(model); } function redo() { if (redoStack.length === 0) return; undoStack.push(JSON.parse(JSON.stringify(model))); Object.assign(model, redoStack.pop()); render(model); }为什么限制 50 步?因为深拷贝一个包含几百个节点的大图占用内存不小,不限制的话编辑一小时,浏览器内存可能上涨几百 MB。50 步对于一般编辑操作完全够用。
自动布局模块在实操中只需要一行入口:
import { layoutDag } from './layout.js'; applyLayoutButton.addEventListener('click', () => { commit(); // 先记录状态,方便用户撤销 layoutDag(model); render(model); });试想一下:用户画了一堆乱糟糟的节点,点击“一键整理”后所有节点自动对齐成清晰的层级结构,还能按 Ctrl+Z 撤销回到原样。这种“有后路的自动化”让用户对自动布局的信任度直线上升。
5. 踩坑记录:性能和交互的十个常见问题
5.1 节点多了卡顿怎么办
diagram-design 早期在节点数超过 300 且拖拽时会出现明显掉帧。排查后发现是拖拽时调用了全量 render,把所有节点和连线重新创建了一遍。优化策略是分层渲染:
- 静态背景(网格)用 Canvas 绘制,初次创建后不再重绘。
- 选中节点和高亮连线单独用一个 SVG 图层。
- 只有实际变化的节点才更新 DOM。
效果非常直观,300 个节点拖拽的帧率从 30 fps 提升到 55 fps 以上。如果还想进一步压榨性能,可以把节点拆成多个分区块,通过虚拟滚动只渲染视口区域内的节点。
5.2 拖动节点时页面出现横向滚动条或者选中文本
这是 Web 绘图工具最常见的小毛病。拖拽操作如果碰上了文字、输入框或者整页滚动,会造成体验断裂。解决思路有两个:
- 在 pointerdown 时调用 e.preventDefault(),阻止默认行为。
- 给 SVG 设置全局样式 user-select: none,并针对可编辑文本单独开放。
拖拽期间可以不处理,但释放鼠标后一定要记得恢复正常,否则用户没法右键复制文本,这个细节很容易被忽略。
5.3 导出 PNG 模糊或者背景变黑
导出模糊的原因基本是 Canvas 尺寸设置不对。把 canvas.width 设置为画布逻辑宽度的 2 倍或 3 倍,再让 img 绘制时铺满,导出图会很清晰。背景变黑则是把透明背景直接交给 Canvas 的默认行为导致。正确做法是先填充白色背景,或者导出选项里显式控制 transparent 标志:
if (options.background === 'white') { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); }5.4 连线总是从节点底部穿出而不是最近边缘
我之前手动计算连接点时,只考虑了节点中心点之间的距离方向,结果竖直布局的图里,连线可能从节点的左边缘穿出再绕回右边缘,非常难看。后来改成根据相对位置判断锚点方向:
function getAnchorDirection(source, target) { const dx = target.x - source.x; const dy = target.y - source.y; if (Math.abs(dx) > Math.abs(dy)) { return dx > 0 ? 'right' : 'left'; } return dy > 0 ? 'bottom' : 'top'; }这样保证连线始终从“朝向目标的那一侧”伸出,整体观感立刻好了很多。
5.5 常见问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 拖动节点位置偏移 | 未做屏幕坐标到画布坐标转换 | 调用 screenToCanvas 换算 |
| 缩放后看不到节点 | viewport 偏移未更新 | 使用锚点缩放算法 |
| 节点数量大时卡顿 | 全量重绘 SVG | 分层渲染 + 局部更新 |
| 连线不跟随节点 | 节点移动后未通知连线重算 | 建立节点到连线的依赖表 |
| 导出图片模糊 | Canvas 尺寸等于逻辑尺寸 | 渲染尺寸放大 2 倍 |
| 导出图片白底消失 | 未绘制背景 | 按选项绘制白底或透明 |
| 自动布局后交叉线多 | 缺少交叉最小化 | 分层后做同层交换检测 |
| 拖拽过程中触发浏览器返回 | 触控板手势干扰 | 设置 touch-action: none |
| 撤销后样式丢失 | 只存了数据没存样式引用 | 深拷贝整个 model |
| 双击编辑文本光标跑偏 | 文本偏移未减掉节点坐标 | 编辑框定位相对整个 SVG |
最后分享一个我在 diagram-design 开发过程中体会最深的原则:绘图工具的复杂度从来不在某个炫技功能上,而藏在“节点移动时线跟着动”“导出时一切都还在”这些基础体验里。每当你觉得某处交互“差不多能用了”,大概率还有一个边界情况没覆盖到。我习惯的做法是每次修完一个 bug,立刻写一个最小复现用例放到回归文件里,宁可多花十分钟,也不要让同一个问题在三个月后重新找上门。diagram-design 到现在还在持续迭代,希望这些经验能帮你少走几步弯路。