我最早决定做这个diagram-design项目,是因为一次极其痛苦的画图经历:给系统架构汇报准备一张架构图,花了三个小时,拖动、对齐、调样式,最后导出的图还是像一张“带箭头的便签纸”。后来我意识到,问题不在我手残,而是通用绘图工具根本不理解“软件架构图”该长什么样。
于是我开始业余时间折腾一个面向技术内容创作者的前端图表设计引擎,代号就叫diagram-design。它的定位很明确:不做一个复杂白板工具,而是做一个能通过数据驱动生成“有设计感图表”的渲染与编辑库。核心能力包括节点锚点系统、正交连线算法、自动分层布局、主题化样式输出,以及 SVG/PNG/HTML 导出。这篇文章完整记录从技术选型、数据模型设计到交互细节、性能优化和踩坑修复的整个过程,适合对图形编辑器、图表渲染、Web 前端可视化感兴趣的读者。
1. 起因:找一个能“画得像设计稿”的图工具为什么这么难
1.1 我最初想解决的问题
市面上不是没有画图工具。但我当时的使用体验可以分成三类:专业图形软件功能全但学习成本高,我为了画一张架构图要研究半小时图层面板;在线白板工具侧重实时协作和便利贴思维,对“节点+连线”这种结构化内容的支持其实很弱;还有一类是代码生成图表的方案,比如声明式脚本和纯代码绘图库,输出结果千篇一律,默认配色和间距几乎完全不像一个正常设计师会交付的东西。
核心矛盾在于:我可以接受用代码描述图表结构,但我不能接受生成的图没有设计感。我希望项目导入一段结构化数据,输出的是可以直接放进文档站、PPT 或海报里的高质量图形。diagram-design的核心理念也由此确定下来:把“图数据”和“视觉表现”彻底分离,然后让视觉表现层默认值足够好。
1.2 项目边界与目标定位
为了避免项目失控,我一开始就明确不做什么:
- 不做完整 Photoshop 式矢量编辑器,不做无限画布协作白板
- 不做复杂 Workflow 自动化和流程图执行引擎
- 不追求支持所有 SVG 特性,比如复杂渐变、滤镜、蒙版
而必须做好三件事:
- 提供一个简洁的数据模型:节点、连线、画布
- 提供一套有分寸感的默认渲染效果和主题系统
- 提供浏览器端交互编辑能力和可靠的导出能力
这个定位决定了后续所有技术决策的取舍方向。它不是一个通用绘图软件,而是一个面向嵌入式图表场景的高质量渲染内核。
1.3 适合谁来读这篇文章
如果你正在做或者准备做这些方向,这篇文章大概率对你有用:
- 想做图形编辑器或节点编辑器的前端开发者
- 在文档工程、技术写作领域搭建图表渲染方案的同学
- 被现有绘图工具“硬伤”折磨、考虑自研工具的人
- 对图布局算法和交互优化感兴趣,但不想一上来啃完整论文的实现者
文章整体按我实际开发的时间线组织,前面偏设计决策,中间偏算法细节,后面偏工程落地和教训总结。建议按顺序读,遇到想看的部分也可以直接跳到对应章节。
2. 技术选型:Canvas 还是 SVG,以及数据模型怎么设计
2.1 Canvas 与 SVG 的取舍过程
这是项目第一个重大分岔路。我一开始倾向于 Canvas,理由是性能,尤其当节点数量达到几百上千时,Canvas 的绘制效率明显更高。但真正深入设计后发现,要做的是一个“可编辑图形引擎”,而不是一个单纯的渲染器,这就必须考虑命中检测、事件分发和局部更新。
我用一张表整理了关键差异:
| 维度 | SVG | Canvas |
|---|---|---|
| 命中检测 | 浏览器原生支持,直接绑定事件 | 需自行实现,判断坐标是否落在图形内 |
| 局部重绘 | 天然支持,修改属性只重绘对应元素 | 无脏矩形机制,通常需要整帧重绘 |
| 导出矢量 | 直接输出 SVG 内容,保真度高 | 矢量导出需要额外记录路径数据 |
| 无障碍与CSS | 可通过 DOM 结构被辅助技术读取,可完全用 CSS 控制外观 | 本质是一张位图,无 DOM 语义 |
| 数千节点性能 | 元素数量过大时 DOM 开销高,操作卡顿 | 单次绘制开销可控,整体表现平稳 |
最终的方案是在项目初期主用 SVG,并预留 Canvas 层用于某些高频全量重绘场景。理由是:图表的编辑体验本质是“点选、拖动、改样式”,这些操作对 DOM 事件的依赖远大于对纯帧率的依赖。SVG 的矢量导出能力也降低了交付成本,对文档站场景非常关键。
后来实测也证明这个决策基本正确。一千个以内的简单节点在 SVG 模式下配合下文介绍的优化手段,交互流畅度完全可接受;而 Canvas 模式被我放到了数据预览和高密度节点量级场景中作为备选渲染层。
2.2 数据模型设计:节点、连线、锚点
很多类似项目死在数据模型设计上:一开始就引入复杂的图数据模型和序列化框架,最后发现 90% 的场景只需要简单的节点和边。diagram-design采用了一个非常朴素的三层模型。
type PortId = string; interface Port { id: PortId; // 锚点相对节点左上角的归一化位置,0-1 区间 position: { x: number; y: number }; // 锚点允许连接的方位,用于连线方向判断和多边形节点适配 side: "top" | "right" | "bottom" | "left"; } interface DiagramNode { id: string; type: string; x: number; y: number; width: number; height: number; ports?: Port[]; data?: Record<string, unknown>; } interface DiagramEdge { id: string; source: string; sourcePortId?: PortId; target: string; targetPortId?: PortId; // 折线关键点,允许用户手动调整路径 waypoints?: Array<{ x: number; y: number }>; data?: Record<string, unknown>; } interface DiagramModel { nodes: DiagramNode[]; edges: DiagramEdge[]; viewport: { x: number; y: number; zoom: number; }; }从一开始我决定使用“锚点”而不是简单的“节点连接”。因为在实际图表里,一条连线从 A 的右侧连接到 B 的左侧,和从 A 的底部连接到 B 的顶部,视觉含义完全不同。没有锚点的模型只能绘制“中心点到中心点”,这在复杂图里往往会造成严重的路径交叉和可读性问题。
锚点位置使用归一化坐标存储,而不是绝对像素坐标。这样在节点尺寸改变时锚点能自动保持相对位置,不必重算连线入点出点。不过这里的归一化指的是相对于节点的矩形包围盒,对于多边形或自定义形状,在注册节点类型时额外提供一个函数来计算锚点坐标即可。
2.3 命令模式与撤销重做
图形编辑器如果没有撤销重做,等于没有退路。在diagram-design里,我采用命令模式实现操作历史:每一次用户操作都包装成一个 command,command 必须包含execute和undo两个方法。
interface Command { kind: string; execute?: () => void; undo?: () => void; // 用于合并可撤销的连续操作 mergeWith?: (next: Command) => boolean; }一个值得记录的细节是:拖拽节点过程中,鼠标每移动几个像素就会产生一次位置更新。如果把每次 move 都压入历史栈,几分钟操作后栈里全是垃圾。我的做法是给命令增加”合并“能力:在拖拽开始时创建事务,后续 move 事件不断更新事务内的当前状态,拖拽结束后只提交一个最终 command 到历史栈。撤销时一次性恢复所有节点的旧位置。
另外一个经验是:历史栈中的记录应该保存对象 ID 和最小必要状态,而不是对象的深拷贝。早期版本为了简化直接克隆整个 model,当图数据稍大时会导致卡顿。后来改成只记录被修改节点的旧属性,效果立竿见影。
3. 节点、连线与交互:从“能画出来”到“画得好用”
3.1 锚点:决定连线质量的第一要素
锚点是整个编辑器的“接口”。我最初犯过的错误是给每个节点默认生成四个固定锚点,位置定死在上下左右的正中心。这种方案在矩形节点上勉强可用,但一遇到矩形宽度特别大的场景,连线角度和视觉重心就非常怪异。
改进后,锚点支持两种定义方式:
- 矩形节点使用标准化位置,默认提供顶部、底部、左侧、右侧四个基础锚点
- 自定义形状节点提供一个
getPortPositions(node)函数,返回锚点绝对坐标数组
锚点的命中范围也应该大于视觉范围。用户鼠标不需要精确点到 4 像素的小圆点上,实际命中半径我设为 12 像素,这在触摸设备和鼠标场景下都能有较好的容错率。
对于连线起止点,有一个细节:连线默认连接到节点锚点,但锚点位置固定在节点边界上,如果连线从目标锚点反向接入,应该自动修正为从锚点侧面的方向进入。简单说,一条从 A 右侧出发的连线,如果目标是 B 的左侧锚点,那么路径要确保进入 B 时保持水平方向,而不是斜着插进去。
3.2 连线路径生成:正交折线与简单避障
diagram-design的连线默认是正交折线,也就是只有水平线和垂直线,没有斜线。这样在技术架构图、流程图里最容易辨认方向。路径生成逻辑分三层:
- 从源锚点出发,沿锚点方向以固定长度引出第一段线段
- 在源和目标的中间区域确定主干路径,优先选择曼哈顿距离更短的方向
- 从目标锚点反向接入,保证最后一段与锚点方向一致
三层逻辑说起来简单,但如果不处理避障,两条连线很快会重叠在一起。我没有引入完整的 A* 寻路,因为对大多数 2D 图表来说,A* 的栅格化成本和路径平滑效果不成正比。我采用的是一种基于“障碍矩形列表”的贪心绕行:
function findOrthogonalPath( start: Point, end: Point, startDir: Direction, endDir: Direction, obstacles: Rect[] ): Point[] { // 1. 尝试无碰撞的 L/Z 型路径 // 2. 如果碰撞,沿碰撞矩形的外围插入绕行点 // 3. 对绕行点做拐角优化,消除相邻同向点 // 4. 最后确保首尾段方向与锚点方向一致 }这套算法大约 200 行,替代了需要引入重依赖的寻路方案。实测结论是:对于节点数量小于 200、边数量小于 300 的场景,贪心绕行的结果基本符合预期,少数情况下路径不是全局最短,但视觉上依然清晰可读。如果数据量更大,那就应该优先考虑自动布局配合正交网络,而不是依赖实时寻路。
3.3 拖拽体验与对齐参考线
拖拽本身不难,难点在于“对齐”。没有参考线的拖动,即使有网格吸附,用户也很难把两个节点水平对齐。我实现了一套轻量的动态参考线方案。
参考线的触发条件是:在拖拽节点时,检测到被拖拽节点的边缘或中线与参考节点边缘或中线距离小于阈值时,显示参考线并施加吸附力。需要检测的候选点包括:被拖节点的left、centerX、right,参考节点的left、centerX、right,以及画布零点。垂直方向同理。
“吸附”不能是瞬间硬跳,否则手感很差。我用的是位移补偿:当距离小于阈值时,将节点对应方向的位置调整到参考线所在位置,同时记录该方向的吸附状态,在拖出阈值范围后释放锁定。实际手感上,阈值设为 6 像素左右是比较舒服的。
这里有一处容易忽略的交互细节:当按住方向键微调节点位置时,参考线吸附应该被“冻结”,否则节点会被参考线拖住,无法用方向键进行 1 像素级别的精细调整。我在设计时用鼠标拖动事件和键盘事件的标志位区分了这两类操作。
4. 自动布局:发现拖拽整理不是终点
4.1 为什么需要自动布局
单靠手动拖拽整理,第一个遇到瓶颈的并不是编辑体验,而是数据更新场景。比如后端接口返回了几十个服务节点,研发同学希望一键生成一张拓扑图,再手动微调;或者在文档站里嵌入架构图,用户希望能动态渲染多份数据。
纯手动编辑无法支撑这些需求。自动布局核心要解决三个问题:层级计算、减少交叉、紧凑排布。
我采用的布局策略是有向分层图布局,思路参考了经典分层绘制算法,但实现时做了大量简化和调整。
4.2 分层与排序:让连线尽量朝一个方向流动
分层的基本思路:
- 对图做拓扑排序,确定每个节点的层次
- 同一层次的节点放在同一水平带内
- 处理跨层边,在中间插入虚拟节点来保证路径经过每层一次
- 通过反复迭代交换同层节点的顺序,减少边交叉
拓扑排序可以使用简单的 BFS/DFS 配合入度统计,这里就不贴完整代码了。关键优化点在于第 4 步的交叉减少:经典算法使用重心启发式,即对每层节点计算其所有邻居在上一层中的平均位置,然后按平均值排序。这个启发式的效果在大多数中等规模图上都不错,而且实现成本低。
1. 初始化每层节点顺序为当前顺序或输入顺序 2. 对每一层,按“邻居在上层的平均索引”排序 3. 重复若干轮,直到顺序稳定 4. 统计交叉数,如果交叉数比上一轮多,回退顺序一个容易忽略的坑是:同一层内可能存在多棵互不相连的子图。排序时要考虑把子图内部的相对顺序尽量保留,否则布局结果每次刷新都可能出现抖动。简单的做法是先固定一个节点顺序作为初始状态,迭代只在相邻交换的范围内进行。
4.3 紧凑排布与固定节点约束
分层完成后,如果直接按层展开,会出现大量空白区域。我做了两层压缩:
- 层内压缩:计算同层所有节点的宽度总和加上间距,然后整体居中放置
- 层间压缩:调整相邻层的垂直间距,只保留边转折所需的最小空间
如果用户手动固定了部分节点的坐标,自动布局会陷入两难:布局算法会打乱所有节点。我最终实现的方案是“锚定固定节点”策略:固定节点参与层级计算,但在布局结果的最终坐标映射阶段,以固定节点坐标为基准构建仿射变换,将整图平移和缩放,让固定节点仍然停留在原名位置附近。
这样做并不能在所有场景下保证无重叠,但它保证了用户“钉住”的关键节点不会被无脑打乱。在交互逻辑上我还加了一个“一键重排”按钮,而不是在节点变化时自动重排。实时自动重排看似智能,实际使用中会剧烈干扰用户的视线焦点和拖拽操作。
4.4 布局算法与真实使用感受
实现完成后,我拿真实的数据测试过:一个约 80 节点、120 条边的微服务部署图,整体布局耗时在 30 毫秒以内,交叉数量从手动摆放的 40 多处降到了 10 处以内。作为对比,如果有 500 个节点和近千条边,纯分层布局就会明显吃力,此时需要进一步开启边合并和子图分区。
经验总结是:自动布局解决的从来不是“完美布局”,而是“一个可接受的初始布局”。它真正的价值在于把从零开始的排布成本压缩到零,但最终美感仍然需要人工微调。所以交互与布局算法必须并存,不能互相替代。
5. 样式系统:让默认输出看起来像“有人设计过”
5.1 从零定义一套图表设计规范
当我尝试把图表接入文档站时才发现,用户对“好看”的要求很低,但默认样式带来的第一印象极其重要。曾有合作开发者对我说:你的图“能看出是个工程产物,但不像是被设计过”。这句话刺激我开始认真设计默认样式。
我首先定义了一套设计 token:
- 节点背景色采用同色系低饱和颜色,边框色采用同色系高饱和颜色
- 连线颜色采用中性灰,重点关系可用加粗或品牌色
- 圆角统一为 6px,避免过圆显得卡通,过方显得生硬
- 阴影只保留一层轻投影,偏移量控制在 2px 以内
对应到 CSS 变量:
:root { --dd-bg-node: #f8fafc; --dd-border-node: #94a3b8; --dd-bg-node-active: #eff6ff; --dd-border-node-active: #2563eb; --dd-edge-color: #64748b; --dd-edge-highlight: #2563eb; --dd-font-family: "Inter", system-ui, sans-serif; --dd-radius: 6px; --dd-shadow: 0 1px 3px rgba(15, 23, 42, 0.1); }主题化通过覆盖这些 token 实现。比如暗色主题只需要改变一组值,所有节点和连线都会自动适配,不需要组件层加判断。
5.2 自定义节点渲染机制
固定样式只能覆盖常见场景,真实世界里一定需要自定义节点。这套系统的做法是:允许注册自定义节点渲染器,接收 node 数据和主题 token,返回一个 HTML 字符串或 SVG 模板。
registerNodeType("database", ({ node, theme }) => { return ` <div class="custom-node database" style="background:${theme.bgCard};border-color:${theme.borderPrimary}"> <div class="db-icon"></div> <div class="db-label">${node.data.label}</div> </div> `; });这个机制非常简单,但因为数据与渲染分离,自定义渲染器也能拿到完整的节点类型和连接关系上下文,所以可以做到很复杂的可视化效果。比如我见过有人用自定义节点渲染器把节点画成圆柱体的数据库图标、云厂商的 Logo 形状,还有带仪表盘样式的监控节点。
5.3 导出保真:SVG、PNG、HTML 嵌入
导出功能是这套系统真正能够嵌入生产环境的关键。我实现了三种导出形式:
- SVG 导出:直接序列化内部 SVG 结构,并内联自定义字体和 CSS 变量值
- PNG 导出:在 Canvas 上逐级绘制并处理 scale,支持指定导出倍率
- HTML 嵌入:生成一个附带样式的独立 DOM 片段,可直接粘贴进文档站
这里最大的坑是字体加载。如果目标机器上没有安装对应字体,导出 SVG 在其他浏览器打开时会出现字体回退,导致排版位置变化。解决思路是:在导出前读取当前页面实际加载过的字体,将文本节点转换为只依赖已嵌入字体的路径或者把字体链接包含进导出文件的元数据里。
PNG 导出时“背景透明”也是一个常见需求。我的默认实现里区分了三种模式:透明背景、白色背景、暗色背景(按主题背景色填充),由调用方显式指定。
6. 性能与工程化:从能演示到能交付的必经之路
6.1 卡顿排查:从事件绑定开始
项目做到这个阶段,已经具备良好的演示效果,但一旦数据量上升到千级节点,就出现了肉眼可见的卡顿。我先做了一次性能排查,定位到几个问题。
第一个问题是事件绑定。早期版本给每个 SVG 图形元素都绑定了mousedown、mouseenter、mouseleave等事件。节点一多,事件监听器数量变成性能瓶颈。解决方法是事件委托:只在被遮挡的顶层容器上绑定一次事件,通过事件对象的target属性找到最近的图形节点 ID。
第二个问题是全量重绘。拖拽某个节点时,原本的实现会清空整个 SVG 容器再重新渲染所有节点。优化后,只有被操作节点和与其关联的连线会被更新,其他元素完全不动。SVG 提供了良好的局部更新机制,这个优化收益非常明显。
6.2 可视区裁剪与脏矩形
即使做了局部更新,当画布有几千个元素时,渲染本身仍耗时。SVG 模式下,我采用了可视区裁剪的最简化实现:
- 监听
viewport的变化,计算当前可视矩形范围 - 渲染阶段只保留与可视矩形有交集的节点及其关联连线
- 滚动或缩放结束时做一次全量重算,而不是在每一帧都做
在 Canvas 模式下,脏矩形机制则更为重要:我不再将整个画布重绘,而是维护一个 dirty 区域列表,只在需要时重绘受影响的部分。
这里必须说一句实在话:在图像类应用里,合理的数据结构与准确的增量更新,比一切花哨的算法优化都更可靠。
6.3 批量操作与撤销栈的内存控制
批量操作是另一个典型问题。用户可能会框选 200 个节点然后一起拖动,如果不加处理,会产生 200 条历史记录。性能与内存都有压力。
我的解决方案是对命令做事务分组:一次拖拽产生一个包含 200 个子记录的moveNodes命令,撤销时执行批量更新而非逐条更新。命令内部保存的是节点 ID 数组和旧属性数组,这样存储开销只是 ID 加少量数值,不会随拖拽过程成倍膨胀。
在实测中,一次 200 节点的批量拖拽,命令对象大约只有几 KB,无论撤销还是重做都能在一帧内完成。
6.4 数据序列化与本地持久化
一个不能成为“空格子”的工具,必须考虑数据持久化。我设计了一个带版本号的 JSON 格式,节点、连线、视口、自定义主题都包含在一个对象中。
{ "version": 1, "type": "diagram", "model": { "nodes": [], "edges": [], "viewport": { "x": 0, "y": 0, "zoom": 1 } }, "theme": "light" }序列化格式里特意没有保存任何组件状态或渲染缓存,保证数据与视图完全解耦。这样带来的好处是:同一份数据可以导入到任何实现了相同数据协议的渲染器里,包括未来可能的移动端导出工具或服务端渲染脚本。
7. 回头再看:一些取舍、教训和建议
7.1 后来删掉的那些“酷功能”
开发过程中,我一度计划添加很多功能,包括实时协同编辑、无限画布小地图、L 系统生成逻辑图分支素材等。但后来都砍掉了,原因各不相同:
- 实时协同编辑需要后端基础设施支撑,和前端渲染库的核心定位不符
- 小地图在节点数量不大时价值有限,反而挤占界面空间
- L 系统生成素材虽然炫酷,但使用概率极低,维护成本却很高
每次砍功能时我都会问自己同一个问题:这个功能让用户能更快地产出一张好图吗?答案不确定的,都被排到了后面。
7.2 我保留的三个核心原则
回顾整个开发过程,有三条原则对我的帮助最大:
首先,数据和渲染必须分离。正因为有这个边界,我才能安心重写渲染层,而不必迁移画布数据;也因为这个边界,网站端和服务端才能共用同一份图数据协议。
其次,默认样式必须先于编辑功能被重视。很多工具开发流程是先堆功能后美化界面,但图表工具的产品心智高度依赖视觉结果。默认输出丑,用户就不愿意继续用。
最后,一切内容都可导出。一个图表项目如果不能直接交付到文档、演示和设计稿里,它就会被隔离在“自嗨工具”的范畴。导出能力必须融入项目早期设计,而不是最后缝补。
7.3 想从零做类似项目的人,我的建议
如果你也想从零做一个图表设计类项目,根据我的个人经历,有三条很实际的经验:
先做死数据驱动的固定规模图表,不要一开始就追求通用和动态。拿三四张真实场景的图表当测试样例,把渲染、样式、导出链路跑通,这比面面俱到但处处漏水要重要得多。
把撤销/重做和数据协议做在第一天。这两个东西看似基础设施,实际上决定了后续所有功能的开发效率。拖拽和连线可以在后加,但一旦模型稳定非常难改。
不要迷信最新最酷的渲染技术。Canvas 和 SVG 之争、WebGL 和 CSS 3D 之争,最后都要回归到你的用户场景:需要精确的可访问性和矢量导出,就老老实实用 SVG;需要超高密度和大规模渲染,再考虑 Canvas。
diagram-design做到现在,还没有成为一个完整的产品,但它已经成了我个人项目堆里回头率最高的一个。每次我要给新系统画架构图,或者给文档站做代码图示时,我还会打开它。对比最开始那些用三小时拖出来的“便签纸”,现在的输出至少能让我愿意多看一眼。这个项目告诉我:小型工具的竞争力,不在于功能清单有多长,而在于在一个窄小的场景里,把细节做到让人用得顺手、看得顺眼。