从零写一个 SVG 图形编辑器:diagram-design 架构设计与踩坑记录
“diagram-design”这个项目名乍一看平平无奇但只要是做过可视化、流程图、拓扑图这类前端工具的人都会心一笑——这种项目永远没有“做完”的那一天。节点、连线、布局、缩放、拖拽、命中检测、文本编辑、撤销重做……每个模块拆开都能写一篇长文合在一起就是一个十足的“磨人精”。这篇文章把我做 diagram-design 的完整过程、设计取舍、踩坑记录都摊开讲讲。内容偏实践适合已经能用框架做点东西、想挑战一下复杂交互或者打算从零写一个图形编辑器的开发者。我会尽量把“当时为什么这么选”“后来怎么改的”“哪个决定让我后悔了”都交代清楚不是为了自吹自擂而是希望你能少走一点我走过的弯路。1. 整体设计与思路拆解1.1 这个项目到底要做什么diagram-design 的核心目标很朴素在浏览器里画出一张可以交互的图表。节点可以拖拽移动连线会跟着节点走画布可以缩放平移节点支持双击编辑文字。听起来就是一个简化版流程图工具但真正动手的时候你会发现“简单”两个字是最昂贵的包装。我给自己定了一个范围边界不做自动布局不做复杂图形库不做协同编辑专注把手工绘制、交互体验和渲染性能这三件事做透。为什么这么定因为自动布局比如树形布局、力导向布局本质上是另一个大型课题一旦摊进去主线就会失焦而协同编辑需要后端基础设施配合前端项目本身根本承载不了。先把“人直接操作图表”这一条路走通反而是约束最少、最能出成果的做法。从技术栈开始我最早用原生 JS SVG 起步没有引任何绘图框架。很多人问为什么不直接上 canvas、不用现成的图形库这个问题的答案后面会详细讲但结论先放在这里对于典型的中等规模图表几百到几千个节点SVG 在交互开发效率和视觉还原度上依然是最稳妥的选择。1.2 核心功能拆解与优先级排序把需求逐个拆开我列了一张功能清单并给每一项标了优先级节点渲染矩形、圆角、图标、文字——P0连线渲染直线、折线带箭头——P0画布缩放、平移、适应视口——P0节点拖拽移动——P0点击选中、高亮、多选——P1双击编辑节点文本——P1样式面板颜色、边框、字体大小——P2导出图片 / JSON 序列化——P2小地图 navigator——P3撤销重做——P3我故意把 P3 的东西往后放因为撤销重做看似简单实际涉及命令模式重构、深拷贝策略、合并历史记录等多个环节做不好反而拖垮核心体验。P0 和 P1 做完之后这个工具已经很实用了P2 是锦上添花P3 更像长期演进方向。1.3 为什么不用现成库必须先回答一个质疑类似的需求用成熟的绘图库比如某图形编辑框架两周就能搞定为什么要自己造轮子我的理由有三条。第一可控性。图形编辑器的坑极其密集坐标转换、事件冒泡、缩放状态同步……用库的时候一旦出了深层 bug排查成本反而更高自己写整个模型在脑子里清清楚楚任何异常都能快速定位到具体模块。第二学习收益。自己写过一遍坐标变换、命中检测、序列化逻辑以后再接触任何上层框架你都具备“透视能力”能看出它底层在干什么。第三体积和依赖。核心图形逻辑全部自研包体积可以压得很小也不用担心第三方库的 breaking change。这不是说用库不行。如果你的目标是快速交付、对原理无感、需求常规那么成熟库绝对是效率最高的路径。但如果你想亲手掌控整个链路想要深入理解图形引擎的运转方式自己写一遍的回报非常高。diagram-design 的定位偏向后者所以我选择了硬造轮子。2. 技术选型与架构设计2.1 渲染方案对比SVG、Canvas、DOM这是一个绕不开的灵魂拷问。我分别做了简单实测把三者摆在一起看维度SVGCanvasDOM节点数量上限中等约 2000 左右流畅高数万甚至更高低几百就明显卡交互事件绑定每个元素可单独绑定直观全靠命中检测手动处理天然 DOM 事件但节点一多就是灾难样式控制CSS 驱动动画和 hover 容易需要手动重绘天然支持但布局负担重文本处理原生 text 元素自动换行比较费劲需要手动排版、测量最自然但图形内文本定位繁琐导出序列化 SVG 即可方便需要 toDataURL 或者另做快照复杂需要 clone 样式对比下来SVG 在“交互开发效率和中等规模性能”之间取得了一个对我很有利的平衡点。Canvas 的优势在规模但在“双击编辑文字”“样式面板调颜色”这些场景里你需要自己实现太多图形学层面的事情DOM 的劣势在规模一上来就崩而且绝对定位布局做自由画布会让浏览器负担很重。我当时还做了个简单压力测试在 SVG 里塞了 3000 个元素节点加连线合计平移缩放时的重绘还是能扛住的帧率在 30 到 50 之间浮动到 8000 个元素就明显吃力了。到后面我在优化阶段引入视口裁剪情况大幅改善这个细节后面单开一节说。最终选型就是主渲染层采用 SVG弹层、工具栏、上下文菜单使用 DOM 覆盖层overlay。2.2 顶层架构数据与渲染分离整个项目我坚持了“数据模型和视图渲染分离”这条铁律。内部维护一份独立的 JSON 数据模型节点{ id, x, y, width, height, type, text, style }连线{ id, source, target, points, style }SVG 层只负责把这份模型“翻译”成可视元素。任何交互动作——拖拽、缩放、双击编辑——都先修改这个数据模型再触发一个统一的重绘方法而不是直接去操作某个 SVG 属性。这带来的直接好处是排查问题容易数据错了就看数据不牵扯渲染序列化和反序列化自然成立导出 JSON 就是导出这份模型未来接撤销重做时可以对模型做快照和 diff这个架构思路不复杂但非常关键。很多人写图形工具写着写着就用变量到处存状态最后陷入“改哪里要同步哪里”的泥潭。始终让数据模型成为唯一事实来源是所有图形应用的定海神针。2.3 模块划分与目录组织为了不让自己在后期迷失我把目录按职责划分成四大块core数据模型、序列化、坐标变换、缩放状态renderSVG 渲染器、节点渲染、连线渲染、箭头绘制interaction拖拽、选择、双击编辑、滚轮缩放、快捷键ui工具栏、属性面板、上下文菜单、小地图core 和 render 之间通过一个极简接口通信interaction 只负责修改 core 模型不直接碰 SVG。这套划分让后续加新功能比如加一种新的节点类型只需要在 render 里加一个分支core 完全不用动。模块间依赖关系清晰回头维护的时候心情会好很多。3. 核心细节解析与实操要点3.1 坐标系统千万别在多个坐标系里迷路图形编辑器最容易翻车的地方就是坐标系混用。diagram-design 涉及三套坐标模型坐标业务数据里的坐标即节点在“真实画布”上的位置不受缩放和平移影响。屏幕坐标相对浏览器可视窗口的坐标通常来自鼠标事件里的clientX/clientY。SVG 用户坐标经过viewBox换算后的坐标。SVG 内部的元素定位一律是用户坐标所以需要把屏幕坐标换算到用户坐标才能做命中检测。换算的公尺是用户坐标 (屏幕坐标 - 容器的偏移量) / 缩放比例 - 平移量这里最容易踩的坑是“容器偏移量”。如果页面本身可以滚动一个普通的getBoundingClientRect().left就能解决但如果在 canvas 外层套了一层 transform那还要叠加 transform 矩阵不然算出来的坐标永远差一个常数而且只在特定缩放级别看起来是对的。为了这件事我加了一个screenToModel(x, y)和modelToScreen(x, y)的完整函数族所有坐标转换都走这两个入口杜绝了在业务代码里手写换算公式。3.2 缩放平移的正交性画布的缩放和平移一种常见实现是给 SVG 根元素加transform: scale(...) translate(...)。我实测下来直接修改 SVG 的viewBox在视觉上更干净但有一个问题viewBox 一变所有内部元素的用户坐标也会跟着变如果模型里存的是用户坐标就乱了。我采取的做法是模型数据完全不动缩放和平移只影响 render 层的全局变换参数。具体实现是维护一个viewport { scale, tx, ty }对象渲染的时候在根g元素上一次性设置g.setAttribute(transform, translate(${tx}, ${ty}) scale(${scale}));这样可以保证模型坐标始终是“真实坐标”任何状态下读出来的数据都是未经过变换的原始值。设计原则就是不要让可视化参数污染业务数据。3.3 连线绘制与正交路由连线是图表工具里远比想象中复杂的环节。diagram-design 支持直线和正交折线两种模式。直线最简单起点、终点一画就行。折线则需要计算一个不穿越节点内部的路径。我最初写的正交路由很简单从源节点中心出发先水平走一段再垂直走再水平到达目标节点。效果在节点对齐的时候还行但两个节点上下左右错位时画出来的线会穿过节点矩形非常难看。后来我改进成锚点 避让结合的策略每个节点定义一组连接锚点上、下、左、右连线的起点和终点是“最靠近目标方向的锚点”中间路径用曼哈顿路径生成器计算。曼哈顿路径核心是找到一条只包含水平/垂直段的折线且控制点偏离障碍物。如果两节点在同一水平线上就直接走水平线否则计算出拐点坐标再根据障碍检测微调。这个方案仍然不是完美的寻路算法真正的寻路要上 A*但对于手工绘制的图表场景视觉上已经足够自然。在箭头实现上我画在折线最后一个线段上通过计算终点线段的角度来旋转箭头 pathconst dx lastPoint.x - prevPoint.x; const dy lastPoint.y - prevPoint.y; const angle Math.atan2(dy, dx);箭头的两个侧翼就基于这个角度绘制。初期我踩过一个坑箭头方向在垂直向下时会翻转 180 度原因就是atan2的分支判断没处理好两端点完全重合的情况。后来加了线段长度阈值判断过短就直接不画箭头才对。3.4 文本测量与节点尺寸自适应节点里需要显示文字而 SVG 的text元素默认不会自动换行。逻辑上我设计成“节点宽度固定文字超出部分省略号处理”这实现起来简单但后来发现用户的需求往往是“文字长了节点能不能自动变宽”。自动变宽的思路先让文字渲染到不可见的测量元素里通过getComputedTextLength()拿到实际宽度然后比较它与节点默认宽度的关系取最大值作为最终节点宽度。对于多行文本事先根据预设宽度把文字拆行再计算每行宽度。这里的“预设宽度”有个经验值单行文本节点宽度下限建议 100px 左右上限画布内不做硬限制但节点过宽会影响布局可读性。拿max(defaultMinWidth, textWidth padding * 2)作为最终宽度即可。3.5 命中检测与选中交互SVG 的好处是自带事件系统节点上直接绑定事件就行。但画布是缩放过的鼠标事件拿到的坐标永远是屏幕坐标必须经过坐标转换才能判断“点的是哪个模型元素”。我给每个节点注册了独立的点击和 hover 事件连线同理。不过有一个细节连线的可点击区域非常小因为线条只有 1~2 像素宽手指粗的用户会点不中。这个问题我在绘制连线的 path 下面叠加了一层不可见的、宽度扩大到 10~12 像素的辅助路径专门用来接收事件视觉上完全看不出来。选中状态的处理同一个时间只允许一个主选中目标但用 Command/Control 键可以追加选中。所有选中的元素都会高亮主选中目标还会额外显示手柄用于后续尺寸调整。3.6 双击编辑与遮罩层双击节点进入编辑模式我用了一个 DOM 覆盖层实现在节点上方绝对定位一个textarea盒子大小和位置跟节点模型坐标对齐内部的文字是节点文本背景设为透明以模拟原地编辑的效果。这个方案比直接修改 SVGtext简单因为 textarea 天然支持光标、选区、换行、输入法而 SVG 文本编辑要自己处理一堆输入法问题。说起来很容易但我实际做的时候出过一次挺无语的 bugSVG 的坐标系和 DOM overlay 的坐标系不一致。DOM overlay 定位用left/top数字得从模型坐标经过 viewport 变换换算成屏幕像素。换算的时候有个四舍五入误差叠加的问题缩放级别是例如 1.25、1.5 这种小数时textarea 边框会对不上节点边框。解决办法是在计算尺寸时就按缩放系数换算const rect editor.getBoundingClientRect(); const textarea.style.left rect.left px; const textarea.style.top rect.top px; const textarea.style.width rect.width px; const textarea.style.height rect.height px;3.7 节点拖拽的模型驱动过程拖拽的本质是一个三阶段过程mousedown 记录起始位置判断命中的节点mousemove 计算位移增量修改模型坐标mouseup 结束拖拽并触发一次轻量 diff 更新关联的连线。位移增量计算有个细节连续帧之间鼠标位置的变化量可以直接加到模型坐标上不需要每次都做一次完整的 screenToModel 重算。但有一种情况例外——缩放值在拖拽过程中被 n 个设备同时改变比如另一只手滚轮此时位移增量需要先除以缩放系数否则节点会被拖得比鼠标快或慢。我统一封装了一个moveNodes(dx, dy)方法内部自动除以 scalemodelNode.x dx / viewport.scale; modelNode.y dy / viewport.scale;拖拽时连线的实时更新我直接对连线的端点做重计算。每个线段的路径重新生成算法不复杂但频繁的 path 重写会触发浏览器大量 layout/paint所以我在拖拽循环里用了 requestAnimationFrame 节流只有数据变化时标记 dirty下一帧统一 flush 更新 DOM而不是每毫秒都直接改 SVG 属性。4. 实操过程与核心环节实现4.1 初始化画布与渲染管线搭建画布的初始化代码我采用了一个简单的类封装。以DiagramEditor为核心类构造时传入容器元素和初始模型随后调用render()渲染全量数据。const editor new DiagramEditor({ container: document.getElementById(canvas), model: initialModel, viewport: { scale: 1, tx: 40, ty: 40 } });构造器内部做三件事创建 SVG 根节点、创建 DOM overlay 层、绑定窗口事件。渲染管线分两级全量渲染render()遍历整个模型生成所有节点和连线的 SVG 元素。适合初始化、数据导入、撤销重做等场景。增量渲染只对标记为 dirty 的元素执行更新。适合拖拽、编辑、样式修改等高频细粒度操作。增量渲染听起来很优雅但实现时需要小心更新一个节点时节点本身重画简单但关联它的所有连线都要重算路径。所以 dirty 标记不能只标记节点得在节点数据变更时把关联边列表也一并标记。4.2 视口裁剪让 3000 节点也不卡前面提到 SVG 在节点数量大的时候会变慢我最初的优化办法是视口裁剪。因为用户的屏幕有限画布上大部分元素其实都不在可视区域内把它们渲染出来是纯浪费。实现思路也不复杂每次 pan 或者 zoom 结束后计算当前视口对应的模型坐标包围盒然后遍历所有节点只保留与包围盒相交的节点及相关的连线其余节点的display设为none。visibleNodes allNodes.filter(node isRectIntersect( viewportRect, { x: node.x, y: node.y, w: node.width, h: node.height } ))实测效果立竿见影1500 个节点的图裁剪前平移缩放帧率 30~40裁剪后稳定 60。代价是每次视口变化要多一次遍历计算但filter的复杂度在几千量级下微乎其微。这里面有个细节连线的裁剪不能只看两端节点如果两个节点都在视口外但连线的中间段穿过了视口这条线就不能隐藏。我的粗粒度方案是只要连线任一端点在视口内就显示简单粗暴对性能的影响可以接受通常连线数不会超过节点数的两倍。如果想更精确可以额外做线段与矩形的相交检测但性价比不高。4.3 滚轮缩放与缩放聚焦滚轮缩放是图形工具的标准操作。通常希望光标所在位置成为视觉缩放中心也就是“看图的时候我想放大哪里就要以哪里为中心”。这个效果的数学推导不复杂但调试起来很顺利的原因在于我提前把坐标变换函数的单位测准了。核心逻辑是记录缩放前的鼠标位置对应模型坐标系中的点缩放完后再次把该点映射回屏幕看二者是否重合。如果不重合就调整平移向量。const scaleFactor e.deltaY 0 ? 1 / 1.1 : 1.1; const beforePoint screenToModel(mouseX, mouseY); viewport.scale * scaleFactor; viewport.scale clamp(viewport.scale, 0.2, 4); const afterPoint screenToModel(mouseX, mouseY); viewport.tx - (afterPoint.x - beforePoint.x) * viewport.scale; viewport.ty - (afterPoint.y - beforePoint.y) * viewport.scale;我一开始把tx/ty的符号搞反了结果是每次滚轮缩放完光标指向的内容会漂移一大截完全没法用。后来逐步推导坐标系公式才纠正过来。这个 function 一旦做对后面所有局部放大、聚焦编辑的体验都会变得很顺滑。4.4 序列化与导入导出数据驱动的好处在序列化时体现得最彻底。因为我所有状态都集中在model这一份数据结构里导出无非就是把对象转换成 JSON 字符串const json JSON.stringify(model, null, 2);导入反过来先解析 JSON校验基本结构有没有nodes、edges字段然后重置画布状态并全量渲染。序列化版本号的字段我也加了因为后续加新功能会改变数据结构的字段名和含义没有版本号的情况下老数据导入新版本会出现完全无法识别的情况。加了版本号以后就可以写迁移函数把旧版本数据逐步升级到最新结构。还有一个导出图片的需求这个直接用 SVG 的序列化方式做克隆当前 SVG 节点嵌入到一张canvas里然后调canvas.toDataURL(image/png)下载。克隆前要把 style 里的外部 CSS 属性都用内联样式设置否则导出的图片会出现样式丢失的问题。4.5 小地图导航器小地图minimap不是必须的功能但对体验的提升非常明显尤其是当图表很大用户迷失在大画布里的时候。实现思路相当直接渲染一个缩略版的 SVG等比例缩小整个模型。在缩略图上绘制一个矩形框表示当前视口范围。拖动矩形框或点击缩略图位置时反过来更新主画布的 viewport。小地图和主画布之间是单向同步的关系主画布变化则小地图更新小地图操作则主画布跳转。这套逻辑接入我已有的 viewport 状态后只花了一下午就做完了。它受益于“core 不依赖 render”的架构不需要侵入任何渲染细节。5. 常见问题与排查技巧实录5.1 坐标漂移视口计算中的经典谜团症状节点拖拽后鼠标和节点的相对位置会缓慢偏移缩放级别越极端偏移越明显。排查过程我花了不少时间最后发现是多因素叠加一是 DOM overlay 的坐标需要经过getBoundingClientRect换算而这个 rect 在页面存在滚动时会变化二是我的screenToModel在内部某个分支里使用了旧的viewport而不是最新值。两者叠加导致误差逐渐累积。解决办法是统一封装坐标换算 API所有事件回调里都从最新的 viewport 状态取数据禁止在交互逻辑里缓存任何旧的 viewport 快照。排查这个问题的过程中我还意外发现了一个边界情况页面在 iframe 里且有缩放时clientX的相对基准不再是 iframe 自身的坐标系需要再考虑 iframe 的左侧偏移。这套东西测试时不容易覆盖但真实使用中很可能遇到。5.2 事件冒泡导致的误选中症状在画布空白处按下鼠标拖动却会选中一个节点或者拖拽节点时触发了画布平移。原因是事件冒泡的时候我一开始把 mousedown 同时绑在了 SVG 根节点和每个图形元素上。点击节点时节点 mousedown 触发后冒泡到根节点根节点也执行了平移启动逻辑两者冲突。解决办法在图形元素的 mousedown 里显式调用stopPropagation()。在根节点 mousedown 里判断event.target是否等于根节点本身只有点空白区域才启动平移。记录按下时的目标元素 IDmouseup 时如果不一致不触发 click 动作。顺带提一句移动端浏览器里 click 事件会晚 300 毫秒触发为了判断双击所以拖拽和点击的判断不能依赖 click 事件必须用 mousedown mouseup 的距离差来综合判断。这个教训在 touch 设备上尤其重要。5.3 SVG 文本模糊与高清屏适配症状画布缩放到非整数比例时文字边缘有轻微模糊。排查后发现是 SVG 文本在 1.25、1.5 等非整数缩放系数下浏览器没有采用亚像素渲染于是部分像素被填充在网格之间看起来有毛边。解决方案有两个方向强制让文本使用整像素对齐text-renderinggeometricPrecision可以缓解一部分情况。接受缩放模糊但对倍数缩放2x、0.5x做特殊 round让缩放结果恰好是整数倍减少模糊感。大多数用户对轻微模糊并不敏感但作为工具类产品体验的打磨就在这些地方。最终我选择在 scaling 结束后对 transform 的平移量做 snap 取整避免非整像素定位viewport.tx Math.round(viewport.tx); viewport.ty Math.round(viewport.ty);这个方法二次解决了不少后续文字抖动的问题值得记下来。5.4 大量节点同时拖拽的卡顿当用户框选了几十个节点然后一起拖动如果每次 mousemove 都同步更新每个节点的 DOM 位置和每条关联连线性能会瞬间垮掉。我做了两件事把拖拽状态提到全局单例渲染层只根据最新模型状态绘制不做节点级缓存。mousemove 事件里只修改模型数据把具体的 SVG 更新操作推迟到requestAnimationFrame的 flush 阶段且多个变更合并到同一帧。实测下来50 个节点同时拖拽从原来的卡顿明显掉帧变成了流畅稳定 50 帧以上。核心思路就是交互逻辑和渲染逻辑彻底分离交互只改数据渲染统一收敛。5.5 内存泄漏与事件绑定图表编辑器一般会长时间驻留在一个单页应用里内存泄漏问题会随时间累积而最终导致页面卡死。我最开始把事件绑定写在某个子组件里但组件销毁时没有解绑导致每次切换页面就多出一份事件监听的引用。排查方式打开浏览器 DevTools 的 Performance 面板录制几轮“进入画布—退出画布—再进入”的操作看内存曲线是否持续上升用 Heap Snapshot 对比明显增长的引用定位到了具体未销毁的对象。解决办法并不复杂但在实践里要严格遵守所有动态创建的节点、定时器、事件监听器在destroy()中统一释放SVG 元素直接移除时如果绑定过事件先移除事件再移除元素。这套规则我用了很久后来项目长期挂载也不再肉眼可见地涨内存。6. 经验总结与扩展方向6.1 架构抽象要有但别过度抽一开始我为了“优雅”给渲染层设计了一整套工厂模式、策略模式每个节点类型一个类每类连线一个渲染器还把样式系统抽象成嵌套的 mixin 链。结果真正写代码时发现大量抽象根本没有必要反而让最简单的“改个颜色”都要跳三层函数。后来我砍掉了一半抽象换成“一个根部渲染函数 一个配置对象”的模式简单直接。好的抽象是在真真切切遇到重复代码之后才去提取的不要提前为未来几个月可能出现的需求设计接口。提前设计通常是过度设计。6.2 数据模型版本化从第一天就开始我吃过的最大亏是在现在这个项目早期导出了十几版 JSON 数据但没加版本号。后来模型字段名做了一次调整老数据全废只能封锁所有旧版本。从那之后所有导出的数据都带上version: 1这类字段。如果你的工具将来会开源或者被其他人长期使用数据向后兼容非常重要。版本号加在序列化根节点上是一切兼容性的前提。6.3 从 diagram-design 到通用图形工具diagram-design 当前已经具备了一个基础图形编辑器的完整骨架再往下扩展可以做几件事节点类型扩展图片节点、组件节点、iframe 嵌板核心是给节点定义type并分支渲染。自动布局算法把树形、层次、力导向布局作为一个可选插件接入让用户点击“自动整理”就能理清大图。预设主题系统节点和线段的样式集中到 theme 对象方便一键换肤。协同编辑接一个后端来做同步前端把本地修改广播给其他人。走到现在让我对这类工具有一个很深的体会它不是高不可攀的复杂系统但也绝对不是一个周末能做完的小玩具。它的每一块技术点单独拿出来都能写一篇长文——坐标变换、图形渲染、事件交互、性能优化每一样都需要你亲手调试出那层“屏幕与数据之间的翻译层”。如果你也在做类似的东西我最后给三条我自己的经验不一定对所有场景适用但确实让我少走了很多弯路第一数据模型一定独立于渲染层任何时候都能纯靠数据重建界面第二所有坐标转换必须收敛到统一的工具函数不要散落在事件回调里各自为政第三性能优化永远用数据说话先压测再动手别凭空猜瓶颈。先从一张 20 个节点的图开始把它做顺再慢慢往里加东西这比一开始就想着做一个全能编辑器要踏实得多。