
说实话当初决定做这个 diagram-design 项目的时候我内心其实没底。起因是我们平时内部讨论架构、梳理接口依赖、给新人讲业务链路最常用的就是画各种示意图。可问题在于大家用的工具太散了有人用在线白板有人用桌面绘图软件还有人直接用代码里的文本标记转图。结果就是图散落在各个平台想统一评审、迭代、复用非常痛苦。后来我就在想与其让大家迁就工具不如自己做一个能嵌入团队内部系统的轻量级图表设计工具于是就有了这个项目。这里说的 diagram-design指的是一整套用于绘制、编辑、渲染、导出架构图、流程图、时序图等通用示意图的 Web 端设计器。它不是一个简单的画布 Demo而是包含了数据模型、渲染引擎、交互机制、持久化方案、导出链路在内的完整实现。这篇博文不会贴完整源码但我会把项目里最核心的设计思路、技术选型依据、实测中遇到的坑以及最终采用的解决方案一条一条拆开讲清楚。准备从零开始做一个图表工具、或者对图形编辑器感兴趣的朋友可以直接照着思路去搭自己的版本。1. 项目起缘我们需要的不是画图而是协作与复用先说说这个项目最初的需求来源因为需求定义错了后面技术选型全都会跑偏。我刚接手的时候团队里其实已经有几套画图方案了比如在线白板、桌面绘图软件甚至有的人直接在会议软件的白板模式里画。但它们有个共同的痛点图是一次性的。画完发到群里过两周再想改原文件找不到了或者只能截图重画。更麻烦的是图里涉及的对象比如一个服务节点、一条依赖链路在系统里是真实存在的实体文本工具生成的图虽然方便管理但又无法像鼠标拖拽那样直观调整布局。所以 diagram-design 的核心需求从一开始就不是能画图而是这三点图即数据每一张图都应该能序列化成结构化数据存进系统、进版本库甚至可以走代码评审流程。可交互编辑要有拖拽、连线、框选、缩放、撤销重做这些基础编辑器能力不能只是个静态渲染器。统一导出最终图要能导出成高清图片和矢量图用于文档、PPT 和外部协同。这一点非常重要因为它直接影响了技术选型。如果你只想做一个静态渲染工具用现成的绘图库然后拼模板就行但如果要做的是可交互的编辑器那数据模型、渲染方案、交互层都要从底层重新设计。1.1 成本评估为什么不直接用现成开源编辑器立项之初我其实也犹豫过直接基于现有的开源绘图框架二次开发不是更快吗当时我认真对比了几类方案发现确实能省很多事但有几个问题很难绕过。第一是定制成本。现有框架一般面向通用场景从图形样式、属性面板到快捷键体系都有一套预设逻辑。我们需要的场景里节点要绑定内部系统的服务元数据连线要根据接口状态显示不同颜色这些定制需求一旦深入几乎是在别人的框架里写一套插件体系学习成本和约束成本都不低。第二是包体积。很多成熟的图编辑器为了功能完整光 JS 就几百 KB 往上了嵌入到我们的内部系统里会明显拖慢首屏。第三是可控性。图形编辑器这种复杂项目一旦遇到性能瓶颈或者渲染 Bug如果用的是开源框架排查起来就像在黑盒里猜我花在阅读源码上的时间不一定比自己写少。当然这不是说自研一定优于复用。如果你需要的只是能画个大概的图表完全没必要重造轮子。diagram-design 的定位从一开始就是轻量但完整所以自研反而能更好地控制每一层的行为。1.2 功能边界哪些必须做哪些明确不做自研项目最容易失控的地方就是范围蔓延。画着画着突然觉得这个功能加上也不难然后项目就永远无法交付了。我在立项的时候把功能边界写得很明确必须做的节点、连线、分组Group三类核心元素以及它们的新增、删除、移动、编辑多选、框选、对齐、分布等基础编辑辅助能力画布平移、缩放、缩放到适应屏幕撤销/重做核心编辑操作的撤销粒度要准确JSON 序列化与反序列化保证图可以完整还原导出 PNG 和 SVG明确不做的不支持多人实时协同编辑团队规模不大后端存 JSON 够了不做复杂模板市场、素材库、贴纸库不做类 Visio 的智能连接器不搞流程图自动布局算法手动布局优先自动布局作为辅助功能想清楚这些边界之后项目的开发方向就非常聚焦了。后续所有架构设计与技术选型都围绕这些边界展开。2. 底层数据模型一张图在内存里到底是什么图形编辑器的数据模型是整个项目的地基。地基歪了后续做交互、做导出、做持久化全都别扭。我最初想得很简单不就是节点数组加连线数组吗于是直接定义了一个包含 Node 和 Edge 两个数组的数据结构。但画了几天之后发现不够用问题出在分组和图形多样性上。没有分组复杂图根本没法管理和移动图形多样性不够很多架构图需要的容器节点、注解框、泳道都实现不了。后来我花了比较多的时间在数据模型这块最终的形态可以这么概括2.1 元素模型Node、Edge、Group 的统一抽象diagram-design 里的所有可见元素都继承自一个基础接口 BaseElement它定义了 id、类型、坐标、尺寸、旋转角度、样式线宽、填充色、字体等。在此基础上派生出三类具体元素Node节点最基础的图形单元携带 position中心点坐标和 size宽高主要负责承载矩形、圆角矩形、菱形、图标等视觉表达。Node 可以独立存在也可以属于某个 Group。Group分组本质是一个容器节点除了 Node 的几何属性外还维护一个 children 列表。移动 Group 的时候子节点跟着移动删除 Group 的时候可以连坐删除子节点也可以打散成独立节点这个策略我设计成可配置的。Group 还负责统一的裁剪逻辑子节点不能画出分组边界这让它在实现泳道和容器节点时特别有用。Edge连线连接两个节点的关系实体记录 source 和 target 的节点 id。Edge 不直接存坐标数组而是通过连接点 Anchor 的 id 来锚定起点和终点。为什么要这样因为如果直接存坐标节点移动后连线不会自动跟随而存锚点 id每次渲染时重新计算线段端点坐标就能天然实现连线跟着节点走的效果。Edge 还可以带若干控制点用于折线和曲线。这个模型最关键的设计决策是所有元素的坐标都是相对坐标Group 子元素的坐标是相对于 Group 左上角的。这个看似简单的决定为后续实现缩放、平移、分组移动省了无数功夫。渲染的时候只需要从根节点到叶子节点逐层做坐标变换逻辑非常清晰。很多教程里会建议直接用绝对坐标加矩阵变换硬算但实测下来在实现组合移动分组缩放这些常见操作时相对坐标的思维模型简单得多也不容易算出浮点误差。2.2 序列化方案JSON 之外还要考虑版本兼容内存里的对象模型确定之后下一步就是怎么持久化。我选了 JSON这是最直接也最通用的方案。但真正实现的时候才发现序列化要考虑的事情远不止 JSON.stringify 一下那么简单。第一个问题是版本兼容。diagram-design 还在快速迭代阶段数据模型几乎每两周变一次。如果旧数据加载进来直接报错用户手里的存量图就全废了。所以我给序列化格式加了 schema 版本号并且写了一个数据迁移层从 v1 到 v2、v2 到 v3 的迁移逻辑分函数维护。加载时先读取旧版本号逐级升级到当前版本。第二个问题是冗余字段与压缩。编辑器内部为了方便查询会在内存对象里挂一些临时字段比如 hitTest 用的缓存 bounding box。这些字段不能进 JSON否则一个两千节点的图序列化出来能有个好几兆存库和传输都难受。我实现了一套白名单序列化只有标记了 serialize 的字段才会被写入 JSON其余字段一律忽略。最终一个中等复杂度的图大约 80 个节点、100 条连线序列化出来大概是 60~90KB完全可接受。下面是我最终定义的 JSON 骨架结构简单说明一下{ schemaVersion: 3, id: chart-001, viewport: { x: 0, y: 0, zoom: 1 }, elements: [ { id: node-1, type: node, x: 240, y: 160, width: 120, height: 60, style: { fill: #ffffff, stroke: #3b82f6 }, text: { content: Order Service, fontSize: 14 } }, { id: edge-1, type: edge, source: { nodeId: node-1, anchorId: right }, target: { nodeId: node-2, anchorId: left }, points: [], style: { stroke: #94a3b8 } } ], groups: [{ id: group-1, children: [node-1, node-2] }] }别小看 viewport 这个字段。它在序列化里存入当前画布的位置和缩放这样用户下一次打开图时视角能还原到上次离开的地方体验细节差很多。2.3 布局引擎为什么坚持手动优先自动辅助图形设计工具里布局是最容易得罪用户的模块。做深度自动布局比如一键拓扑排序、力导向布线看起来炫酷但自动计算出来的布局往往不符合人类的阅读习惯用户最后还是得手动微调。diagram-design 的做法是默认手动布局自动布局只提供两种辅助——树形展开和环形排列。树形展开用于把一组选中节点按层级从上到下均匀排布环形排列则用于把节点围成一个圆环展示星型拓扑或者中心化架构时特别方便。这两种算法实现都很简单树形展开就是个深度优先遍历环形排列就是均匀计算角度。它们的价值在于给用户一个合理的起点剩下的微调交给手动。我强烈建议做类似工具的朋友不要在布局引擎上投入太多精力。用户的真实诉求是我拖出来的布局不要被算法打乱而不是算法帮我画得比我好。这一点我在跟团队里的设计师交流之后才彻底想通设计师对布局的控制欲远高于算法能提供的想象空间。3. 渲染引擎选型Canvas、SVG 还是混合方案数据模型是地基渲染引擎就是承重墙。这个选择一旦做错项目后期几乎没法翻盘所以我花了大量的时间做实测对比。先说结论diagram-design 最终选择了 Canvas 2D 作为主渲染引擎SVG 作为辅助层。这是一个混合方案核心图形用 Canvas 绘制交互热区、文本输入框这类需要精确 DOM 能力的东西用 SVG 叠加。听起来有点绕但实际收益非常大下面展开说。3.1 纯 SVG 与纯 Canvas 的实测对比我把同一个场景300 个节点、600 条连线分别用纯 SVG 和纯 Canvas 实现了两个原型实测结果差异非常明显对比项纯 SVG纯 Canvas初始化渲染约 120ms约 45ms平移缩放每帧约 18msDOM 不断更新约 6ms重绘1000 节点内存占用明显偏高每个元素都是 DOM 节点稳定无 DOM 增长命中检测浏览器原生isPointInStroke需手动实现稍复杂文本编辑直接用 DOM天然支持需要 overlay 或隐藏 textarea表面看SVG 的命中检测和文本编辑是巨大优势但性能在节点数量上去后完全顶不住。1000 节点时拖动画布每帧要更新一千多个 DOM 节点的坐标浏览器即使能扛住帧率也会掉到 40fps 以下体验很差。Canvas 则没有这个问题因为画布只是个位图节点数量增加只影响绘制时间不影响 DOM 结构。那为什么不全用 Canvas还要保留 SVG 层呢因为文本输入。图形编辑器里双击节点要改文字这个输入光标必须能定位到画布的准确位置。用 Canvas 绘制文字的话输入框在逻辑上要自己算 offset、自己管光标、自己处理输入法工作量非常大。我最终的做法是Canvas 画所有图形和连线的背景层上面叠一个透明 SVG 层SVG 层只负责承载文本和输入框。这样文本天然支持 DOM 的一切能力滚动、选中、输入法、无障碍全都免费获得。3.2 混合渲染的分层架构具体实现上我把画布拆成了三层注意不是用 z-index 硬堆而是三个独立 canvas/svg 元素通过 CSS 绝对定位重叠底层 Canvas背景层绘制网格点、页面阴影、非交互装饰元素。中层 Canvas图形层绘制所有节点、分组、连线、锚点等核心图形。这是最重的一层负责 95% 的渲染工作。顶层 SVG交互层承载文本标签、输入框、选中框、拖拽辅助线。这一层使用 SVG 的 pointer-events 属性控制哪些元素能响应鼠标事件。分层之后有个明显的好处交互的时候不需要重绘所有东西。比如拖拽选中框只需要在顶层 SVG 里画一个矩形底层和中层的 Canvas 完全不动帧率稳稳的。如果要框选半透明遮罩效果也只需要在顶层加一个带 fill-opacity 的 rect不用碰 Canvas。3.3 缩放时的文本模糊问题Canvas 全量绘制有个老问题缩放比例不是整数时文字边缘容易发虚。我刚开始也遇到这个情况画布放大到 125% 时节点上的文字看着像蒙了一层雾。调研后确认这个是 Canvas 的常见问题因为浏览器在非整数 devicePixelRatio 下渲染位图时抗锯齿算法无法完美处理文字边缘。我的解决方案有两个配合起来效果很好。方案一把 Canvas 的物理尺寸按 devicePixelRatio 放大再用 CSS 缩回逻辑尺寸这样高 DPI 屏幕上文字清晰度恢复正常。方案二对于放大超过 150% 的场景直接切换成矢量渲染模式也就是把当前视口范围内的图形以 SVG 形式重新绘制一帧利用 SVG 的矢量特性保证无限缩放下的文字清晰度。这个切换是瞬时的用户基本感知不到但效果立竿见影。说到这我想起一个细节Canvas 的 ctx.setTransform 方法要慎用。它确实能简化坐标变换但用多了会积累浮点误差特别是在反复平移缩放之后。我在核心渲染路径里只用 resetTransform translate scale 进行单层变换其余都交给相对坐标计算稳定性好很多。4. 交互层实现拖拽、连线、命中检测的踩坑实录图形编辑器的交互是用户每天要摸几百遍的东西。这一块做得糙渲染再流畅也会被骂难用。diagram-design 的交互层实现过程中我踩过不少坑挑几个最典型的说。4.1 画布坐标转换world / screen / viewport几乎所有图形编辑器都有一个坐标系统的问题鼠标在屏幕上返回的是 screen 坐标但是我们的数据模型里存的是 world 坐标或者叫 scene 坐标。当你拖动节点时必须把 screen 坐标转换成 world 坐标然后修改节点 position。公式不复杂worldX (screenX - canvasRect.left - viewport.x) / viewport.zoom worldY (screenY - canvasRect.top - viewport.y) / viewport.zoomviewport.x 和 viewport.y 在这里代表视口左上角在世界坐标系中的位置。平移画布时其实是修改这两个值缩放时除了改 zoom 还要调整视口位置让鼠标所在点保持不动。这个以鼠标为中心缩放的实现方式值得记一下// 缩放前鼠标在 world 坐标的落点 worldBeforeX (mouseScreenX - viewport.x) / viewport.zoom // 应用新缩放 viewport.zoom * scaleFactor // 调整 viewport.x 让鼠标落点保持原位 viewport.x mouseScreenX - worldBeforeX * viewport.zoom这套变换逻辑我用了一个专门的 ViewportTransform 类来维护不在 UI 层直接裸算。因为交互逻辑一多到处手写公式特别容易出错而且测试也不好写。ViewportTransform 类接收 screen 坐标和 world 坐标互相转换所有交互模块只跟它打交道。这个抽象虽然多写了一百行代码但避免了后面无穷无尽的边界 Bug。4.2 连线命中检测点到线段的距离才是王道连线的命中检测是交互层的一个隐性难点。用户要去点一条线凭什么判定点中了你以为会有个 hit area 帮你处理Canvas 里其实什么都要自己来。Edge 有几种形态直线、折线、贝塞尔曲线。直线最简单用点到线段距离公式判断阈值设为 8px命中距离以内就算命中。折线是对每段线段分别算距离取最小值。贝塞尔曲线稍微麻烦一点因为它没有解析解可以直接算点到曲线距离我的做法是把曲线采样成 N 个点N 30 左右然后对这 N 个点做点到线段距离判断。30 次采样计算量很小但精度足够用户感知不到误差。这里有个性能细节如果图上有一千条连线你不能每一帧都对每条线做精确距离计算。我的优化策略是两步走。第一步快速包围盒检测每条 Edge 先算一个宽松的 AABB轴对齐包围盒鼠标点不在 AABB 内就直接跳过第二步对通过了 AABB 检测的少数 Edge 再做精确采样计算。这样把每次鼠标移动的计算量从 O(N) 降到接近常数实测非常有效。另外连线的 hover 态和选中态要分开设计。hover 态是给用户提示这条线可以点所以颜色要轻微变化选中态则是明确的蓝框高亮。刚开始我把两者混在一起导致鼠标扫过线条时视觉闪烁得很厉害。后来区分开体验立刻舒服了。4.3 文本编辑overlay 输入框的定位陷阱双击节点进入文本编辑这个交互我本来以为很简单结果是隐藏 Bug 最多的一个模块。核心问题是输入框的位置必须跟 Canvas 里绘制的文字完全重合。文本在 Canvas 里有自己的对齐方式textAlign、textBaseline而且画笔的 fillText 坐标和实际绘制区域的关系在不同字号、字体下一不注意就会偏移几个像素。我最初的实现是在节点中心放置一个自动宽度的透明 TextArea然后通过 CSS 把它定位到节点对应的屏幕坐标。结果发现输入框的基线总比画布上的字低几个像素每次双击都会跳一下。排查半天根因在于 composite 的方式不对。我把 TextArea 定位到了节点中心坐标但 Canvas 文字本身是以中心为基准绘制而 TextArea 的文字默认是从左上角开始排的。两者天然不重合。最后解决方案是TextArea 的 CSS left/top 定位到节点的左上角同时设置行高和节点高度一致文字垂直居中再用 transform 微调。之后再微调字体大小直到 overlay 内文字与画布上文字一一对应这个问题才彻底了结。顺带一提文本编辑状态下的拖拽选择、输入法组合、Enter 确认、Esc 取消这些都要单独处理。尤其是中文输入法的组合态compositionstart/compositionend如果你不监听 composition 事件用户拼音还没选完字你的 keydown 监听器就先触发了可能直接把输入状态给结束了。这个坑我印象非常深。4.4 撤销/重做命令模式比快照更省内存撤销/重做我一开始天真地以为只要在每次操作前把整个图 JSON.stringify 存一份撤销时恢复快照就行了。后来发现一次框选删除操作可能只改动 20 个节点但快照要存整个图的数据。如果图很大每步快照就有几百 KB用户操作二十步内存直接爆炸。正确的做法是命令模式。每个操作移动节点、添加连线、修改文本都封装成一个 Command 对象它有 execute() 和 undo() 两个方法。执行操作时调用 execute()把操作涉及的增量数据记录在 Command 内部撤销时调用 undo()反着应用增量。我实现的时候维护了两个栈undoStack 和 redoStack。每次操作完成后undoStack.push(command)同时清空 redoStack。撤销时从 undoStack pop 出 command调用 undo()然后 push 进 redoStack。命令模式的好处很明显内存占用跟操作数量线性相关而不是跟图的规模成正比。而且对于移动节点这种高频操作我还做了命令合并——在同一帧内连续发生的多次移动合并成一条命令避免用户撤销时一次只退一个像素。5. 性能优化节点数量过千时每一次重绘都不能浪费图形编辑器做到 80% 的功能可能只花了 40% 的时间剩下 60% 的时间基本都在跟性能作斗争。diagram-design 在节点数超过一千之后遇到的性能瓶颈非常典型我逐个拆解。5.1 第一个瓶颈重绘面积过大早期版本里只要画布上任何一个元素发生变化哪怕只是改了一个节点的颜色我会调用 render() 把整个场景重新画一遍。200 节点时没问题但到了 800 节点一次重绘 20ms拖拽节点时每帧都要重绘帧率只剩 30fps 左右卡得明显。优化思路是脏矩形。每次操作完成后计算发生变化的区域比如节点原来的包围盒和新的包围盒的并集把这个区域标记为 dirty rect渲染时只 clearRect 掉脏矩形区域只重绘脏矩形内的元素。Canvas 的 clip API 在这里能限制绘制范围一次重绘的面积可以缩小 80% 以上。但实现脏矩形有一个隐含的复杂性如果元素 A 移动了它原来的位置需要重绘新位置也需要重绘同时与它重叠的连线、分组背景、锚点也需要重绘。我最终没有做特别精细的脏矩形那个工程量太大了而是用了区域重绘 全量重绘兜底的策略全局维护一个 dirtyRects 数组如果帧内累积的脏矩形面积超过了画布总面积的 40%就直接降级为全量重绘。因为这种情况下区域重绘的总代价反而比全量重绘高。5.2 第二个瓶颈事件监听与批量更新第二个性能瓶颈不在渲染而在事件处理。最初我把 mousemove、mouseup 事件直接监听在 Canvas 上每次鼠标移动都会触发多次事件回调在回调里做命中检测和重绘。这样的写法逻辑简单但是 mousemove 的触发频率远超渲染帧率有的浏览器能达到 200Hz导致无意义的重复计算被大量触发。我用类似 React 的理念做了事件批量处理所有原生事件先推入一个 eventQueue然后在 requestAnimationFrame 回调里统一从队列中取事件、合并计算、统一渲染。这样浏览器一帧最多做一次命中检测和一次渲染逻辑上还是按事件顺序处理但计算量却大幅下降。实测下来从无脑实时处理改为 rAF 批量处理后拖拽连线的流畅度显著提升帧率稳定在 60fps。这个改动的代价是要小心处理计算中途又插入新事件的情况但整体来说收益远大于复杂度。5.3 第三个瓶颈视口裁剪看不见的图形不画一千个节点分布在一张很大的画布上屏幕上其实只能看到其中三四百个。如果每次都把所有节点画一遍那 60% 的绘制工作都是浪费的。解决思路是视口裁剪Viewport Culling。每次渲染前用当前的 viewport 区域生成一个裁剪矩形遍历所有节点的 AABB如果与裁剪矩形没有交集就跳过不画。对于连线也做类似的检测但判定条件宽松一点因为连线跨出视口但端点仍在视口外的情况很常见直接用节点检测会漏掉大量半在视口内的线段。视口裁剪实现之后无论图上有三千个还是一万个节点只要屏幕可视区域内的节点数量不变渲染性能就几乎不变。这也让我彻底放下了节点数不能超过多少的包袱数据模型里就没人限制元素规模了。5.4 辅助线渲染的降级策略还有一个性能细节很多人会忽略辅助线、网格点、对齐吸附这些装饰性渲染在高缩放级别下会变得非常密集。想象一下画布缩放到 30%网格点几乎铺满整个视口如果每个点都画一个半径 2px 的圆几千个圆会让 Canvas 都吃力。我的处理是三级降级默认缩放级别在 100% 以上时网格线完整绘制缩放到 50%~100% 之间时网格线变细、点阵代替网格低于 50% 时网格彻底不画只保留页面阴影。这样在宏观视图下用户注意力在全局结构上网格的作用已经不大删除后性能和视觉清爽度都提升。这类细节属于看起来是小优化但实际体验影响很大的类型。做完之后从 300% 缩到 10% 的过程中画面始终保持流畅没有突然卡顿的瞬间。6. 导出与持久化不能让用户画完图带不走图形编辑器最容易被低估的模块是导出。很多项目做到渲染和交互就收工了结果用户画完图想放进文档里发现导出的图片模糊、SVG 文件畸形或者字体全丢了。diagram-design 在导出上花了很大的心思因为这是最后一公里做不好前面的编辑体验再好也会被扣分。6.1 PNG 导出离屏 Canvas 与高清倍率PNG 导出我用的是离屏 CanvasOffscreenCanvas。也就是说在内存里创建一块比屏幕实际显示尺寸更大的画布把当前的图形数据按照指定倍率默认 2x最大可设 4x完整绘制一遍然后调用 toBlob 生成 PNG。有几个细节特别容易踩坑。第一离屏 Canvas 的前提条件是字体先加载完成。如果你用了一个自定义字体但导出时字体还在加载中画出来的文字会变成默认字体甚至导出后文字位置错乱。我的做法是在导出前先 document.fonts.load 确保字体可用然后强制等待字体重渲染完成。第二导出倍率不是越高越好它跟内存成正比。4 倍倍率画一万像素宽的图内存会瞬间飙升我的实现里加了内存估算超过阈值自动降级到 2 倍并提示用户。第三隐藏元素不能导出。如果你有图层隐藏功能导出前要遍历一遍跳过所有可见性为 false 的元素否则导出图里会冒出用户故意藏起来的内容。6.2 SVG 导出矢量化的关键是把控制点算准SVG 导出比 PNG 难在矢量还原。Canvas 绘制用的是一套路径 API导出的 SVG 却需要精确的 path 数据。如果只是简单地把所有节点画成 rect 或 ellipse 元素也还行但连线的贝塞尔曲线、圆角矩形的圆角半径、描边虚线这些细节用 SVG path 来表达就复杂多了。我的做法是为每个元素类型写一个 toSVG() 方法返回对应 SVG 标签字符串。节点类型直接映射到 rect/ellipse/path文本节点映射到 SVG text连线类型需要把 Canvas 的折线/贝塞尔曲线转换成对应的 path d 属性。这里最大的坑是贝塞尔的控制点位置。Canvas 的 quadraticCurveTo 控制点坐标是基于相对坐标系的中间点SVG 的 path 需要你精确传入起点、控制点、终点如果换算错了导出的 SVG 曲线就会扭曲。我的建议是SVG 导出逻辑最好在开发早期就跟渲染逻辑同步实现不要最后再来补。因为早期数据结构里每个元素都带完整的几何信息导出只是一个序列化过程等到后期图形样式和交互逻辑复杂了再回头提取几何信息就难了。6.3 JSON 快照与增量保存编辑器的持久化我最终用的是全量 JSON 快照 定时自动保存的组合。每 30 秒自动把当前图序列化为 JSON 发到后端存一份同时在用户主动点击保存时强制存一次。增量保存看起来更高效但考虑到团队单张图的编辑时长一般不超过几分钟全量快照的存储成本完全可控而增量存储的冲突处理复杂度太高性价比很低。持久化的版本化这里再强调一次JSON 里的 schemaVersion 字段不要删哪怕你现在只有一版数据结构也要把版本号写上。因为只要项目还在迭代数据结构就一定会变。你可以先只实现 v1 的读取逻辑但一旦将来需要迁移有了版本号你至少知道哪些数据是老格式。我吃过这个亏最开始没写版本号后来数据结构大改存量 JSON 全部无法识别只能写一次性脚本硬转换过程相当痛苦。7. 实测数据与后续演进方向项目走到这里已经具备完整的编辑能力了我还做了一轮系统性的性能和稳定性测试。测试环境是我日常开发用的笔记本具体数据如下200 节点、300 条连线初始化渲染耗时约 35ms拖拽平移帧率稳定在 60fps。800 节点、1200 条连线初始化渲染约 120ms拖拽平移降级到约 45fps但视觉上仍然流畅。2000 节点、3000 条连线开启视口裁剪后初始化渲染约 280ms但缩放平移时屏幕可视区域的帧率仍能保持 50fps 以上。连续撤销 100 步内存占用仅增长约 8MB命令模式的增量数据明显优于快照。这套数据基本验证了架构设计的有效性。对于常规架构图、流程图几十到几百个节点diagram-design 的性能完全够用即便面对上千节点的复杂系统拓扑也能保持可用。至于后续演进方向我目前给自己排了三个优先级。第一支持更丰富的节点样式比如阴影、渐变、图标嵌入这会让导出的图漂亮很多。第二做一个简单的自动布局插件框架把树形、环形这些布局算法改成插件注册的方式方便团队其他人接入自定义布局逻辑。第三在序列化格式上引入 checksum防止后端存储时数据被意外篡改导致加载报错。这些方向不会改动核心架构更多是在现有骨架上的增量功能。坦白说图形编辑器是一个看起来简单、实际做起来永无止境的项目。即使是现在这个版本我也很清楚它距离商业级工具还有不小距离比如协同编辑、版本 Diff、模板系统都还没做。但作为一个团队内部工具diagram-design 已经完全解决了图散落各处、无法复用、无法评审的核心痛点每天都有同事在用这已经让我觉得这个项目非常值了。