手写基于 SVG 的图表编辑器:diagram-design 核心实现

📅 发布时间:2026/10/21 10:00:35
手写基于 SVG 的图表编辑器:diagram-design 核心实现
做了几年业务系统我越来越觉得工具类的活儿自己做一遍才算真正搞明白里面的门道。前阵子我手写了一个轻量级图表设计工具项目名就叫diagram-design。你可能觉得桌面上一堆流程图、白板软件已经很成熟了为什么还要重复造轮子但真正做起来就会发现通用工具往往为了通用牺牲了太多东西而自研一个编辑器恰恰能厘清节点、连线、布局、渲染这些底层概念之间的真实关系。先说这个项目是干什么的一个基于 SVG 渲染用节点和连线作为核心原语的图表编辑工具支持无限画布、缩放平移、拖拽连线、属性编辑以及 JSON/SVG/PNG 导出。它不是什么重型的工程建模套件也不是卖天价许可的商业绘图软件而是一个逻辑清晰、可扩展、代码量适中的编辑器内核。如果你正在设计自己的低代码平台、文档工具、架构图模块或者单纯想搞清楚可视化编辑器内部是怎么工作的这篇文章能把整个骨架给你搭清楚。1. 为什么还要再做一个图表设计工具1.1 现有方案到底卡在哪儿市面上主流的图表工具我大体把它们分成两类。一类是万能工具箱功能多到溢出。你想画个简单的流程图它给你弹出一堆主题、模板、素材库光是对齐和样式参数就能折腾半天。这类工具适合做精美汇报图但如果你想要的只是一个可嵌套、可编程、数据驱动的轻量编辑器它的心智负担和包体体积都不合适。尤其是要嵌入到自己的 Web 应用里光是初始化那一套复杂配置就足够劝退。另一类是在线白板上手快画出来的东西也好看但导出的数据格式极度封闭。你想从数据库里批量生成节点或者把画布内容集成到自己的业务数据里基本没门。白板工具的核心是给人一个自由表达的空间不是给程序一个可操作的数据模型。这就引出了我真正的痛点我需要一个编辑器既能让人在界面上自由拖拽又能让程序完全读懂它的数据结构。diagram-design就是在这个背景下立项的。它要解决的不是画一张好看的图的问题而是如何让图表变成一种可编程、可存储、可交互的数据结构的问题。这个定位决定了后面所有技术选型的走向。1.2 项目边界与核心设计目标任何一个编辑器如果一开始不划定边界都会膨胀成一个无底洞。我给diagram-design定了三个原则第一只做节点 连线两种原语。不支持自由笔迹不支持复杂的图形组合一切复杂图形都通过节点内部的 SVG 模板来扩展。这样数据结构非常稳定序列化和渲染都容易做。第二渲染层必须简单可控。选择 SVG 而非 Canvas 或 WebGL是因为图表场景下节点数量通常在几十到几百SVG 天然的 DOM 事件模型和 CSS 样式能力能省下大量交互代码。后面我会专门讲这个选型的得失。第三编辑器核心与业务完全解耦。diagram-design只负责维护一个图模型并提供画布、拖拽、连线、缩放这些通用交互至于节点长什么样、边上显示什么文字、底下的数据怎么同步全部通过配置和回调注入。这样一来它既能画流程图也能画架构图甚至画组织关系图谱。这三个原则像筛子一样把很多天马行空的需求挡在了外面也让项目的核心逻辑始终能保持在一个可控的复杂度内。2. 技术选型与关键细节解析2.1 渲染引擎选型为什么我坚持用 SVG现在做可视化很多人第一反应是 Canvas或者直接上 WebGL。但我在diagram-design里坚持用 SVG不是因为它新潮而是因为图表编辑这个场景它的特性高度匹配。Canvas 是画完就忘的像素缓冲区适合高频重绘的动画和游戏场景而 SVG 是声明式的矢量图形模型每个元素都是一个真实 DOM 节点自带事件、样式、子元素结构。对编辑器来说我不需要每帧重绘整块画布只需要在节点位置变化时更新对应元素的属性。节点上的文字、图标、图片直接塞进 SVG 内部结构里就行根本不需要额外的命中检测和绘制逻辑。我在实际测试中对比过在节点数量低于 500 时SVG 的交互响应速度完全不输给 Canvas而 SVG 带来的便利是巨大的——事件直接绑定在图形上鼠标 hover、点击、拖拽一行addEventListener就解决完全不用自己算坐标反查。唯一的坑是当节点数量上千以后DOM 数量会拖累渲染性能这个我在后面问题排查部分会给出缓解方案。2.2 数据结构把图表代码化成对象diagram-design的核心是一套非常朴素的数据模型。我把它分了三层第一层是画布配置记录画布的尺寸、缩放比例、平移偏移量、背景网格参数。第二层是节点集合每个节点至少包含 id、x、y、宽高、类型 label。第三层是连线集合每条连线记录 id、起点节点、终点节点、起点锚点方位、终点锚点方位以及可选的路由点。interface DiagramNode { id: string; x: number; y: number; width: number; height: number; type: string; // 节点类型用于渲染不同模板 label: string; data?: Recordstring, unknown; // 业务自定义数据 style?: Recordstring, unknown; // 覆盖默认样式 } interface DiagramEdge { id: string; source: string; // 起点节点 id target: string; // 终点节点 id sourceAnchor: top | right | bottom | left; targetAnchor: top | right | bottom | left; points?: Array{ x: number; y: number }; // 手动路由点 }为什么锚点要记录方位而不记录具体坐标因为节点的位置一变锚点坐标马上会跟着变。如果我在数据结构里存死坐标每次拖动节点都要重新计算所有关联边的数据麻烦且容易出错。存方位配合渲染时实时计算锚点坐标才是图编辑器应该有的状态管理方式。这套模型还带来一个额外好处图数据天然是可序列化的。前端把整个画布 JSON 化之后扔进数据库存起来下次打开再做一次反序列化就能完整还原。这也是diagram-design能嵌入业务系统的前提。2.3 画布交互无限画布、缩放与平移的实现思路无限画布听起来玄乎实现起来其实就是两层变换。第一层是视觉变换。我维护一个panX、panY和zoom变量渲染时把它应用到画布的根元素上。SVG 里最直接的做法是用transform属性transformtranslate(panX, panY) scale(zoom)。所有节点和边画在世界坐标系里改动 pan 和 zoom 不会动任何节点数据。第二层是坐标换算。鼠标在屏幕上的坐标要换算成世界坐标才能正确落点公式是世界X (屏幕X - panX) / zoom世界Y (屏幕Y - panY) / zoom这个换算在拖拽、创建节点、移动连线时都要用到。很多编辑器做出来手感不对洋感觉鼠标和图形脱节十有八九是这个坐标变换没处理干净。我用一个统一函数包住换算逻辑所有交互入口都在这个函数上走避免各写各的换算导致误差。画布的背景网格也是跟着 world 坐标走的这样才能在缩放时保持网格线间距变化。我做了两档网格缩放较大时显示粗网格并自动隐藏细网格简单但很提升观感。3. 实操过程一步步做出编辑器3.1 功能集拆解与工程骨架动工之前我把最小可行版本拆成六个模块图模型层负责节点和连线的增删改查以及撤销重做的数据源画布渲染层负责 SVG 根节点、坐标变换、事件绑定节点渲染层根据节点 type 渲染不同的 SVG 模板连线渲染层根据锚点和路由点计算连线路径并渲染选中态交互控制器处理拖拽、框选、键盘删除、复制粘贴工具面板节点类型选择、属性编辑、导出按钮我用 React 作为上层壳渲染部分直接操作 SVG DOM项目管理上用 TypeScript 写类型定义。整体目录是这样的diagram-design/ src/ model/ # 图数据模型与操作 render/ # SVG 渲染 interaction/ # 鼠标键盘交互 tools/ # 面板与工具 utils/ # 坐标变换、路径计算 index.ts # 暴露给外部的 API3.2 画布层实现三明治结构SVG 的渲染顺序遵守绘制顺序后画的盖在先画的上面。我按照三层三明治的思路组织画布底层是网格和背景中间层是连线被节点压住避免连线挡住节点的可点击区域顶层是节点和锚点标记。在 React 里我直接用 SVG 元素作为骨架分发渲染svg ref{svgRef} classNamedg-canvas onMouseDown{handleCanvasMouseDown} onMouseMove{handleCanvasMouseMove} onMouseUp{handleCanvasMouseUp} g transform{translate(${panX}, ${panY}) scale(${zoom})} g classNamedg-grid-layer{renderGrid()}/g g classNamedg-edge-layer{renderEdges()}/g g classNamedg-node-layer{renderNodes()}/g /g /svg值得提醒的是SVG 的 CSS 背景最好用background-color直接写在 svg 元素上画布内的网格则用简单线条生成。网格不需要太多技巧横竖两组循环线段间距取 20 再乘缩放系数控制在两个数量级以内切换性能基本无感。3.3 节点渲染拖拽背后的状态流转节点的核心交互就是拖拽。实现拖拽最关键的三个步骤是按下时记录鼠标的世界坐标以及节点当前坐标算出偏移量移动时用最新鼠标世界坐标减去偏移量得到新位置更新节点松开时清空拖拽状态触发一次统一的变更回调关键代码const handleNodeMouseDown (event, node) { event.stopPropagation(); const worldPos screenToWorld(event.clientX, event.clientY); dragState.current { nodeId: node.id, offsetX: worldPos.x - node.x, offsetY: worldPos.y - node.y, }; }; const handleNodeMouseMove (event) { if (!dragState.current) return; const worldPos screenToWorld(event.clientX, event.clientY); const dx worldPos.x - dragState.current.offsetX; const dy worldPos.y - dragState.current.offsetY; updateNode(dragState.current.nodeId, { x: Math.round(dx), y: Math.round(dy) }); };这里有个很多人踩过的坑事件监听不能只挂在节点上因为鼠标移动速度过快时会跑出节点图形区域导致 mouseup 和 mousemove 丢失。正确做法是把 mousemove 和 mouseup 监听绑定在 SVG 根元素上甚至必要时绑定到 window 上按下时开始监听松开时移除监听。我还会对节点坐标做整数取整这样导出的 JSON 更干净也不会出现细微数值累积导致的抖动。另外节点拖拽时会给被拖节点加一个dg-dragging类临时抬高 z-index 效果视觉上更像浮起来。3.4 连线层实现锚点、折线与动态更新连线是图编辑器里最容易出问题的部分。diagram-design里每条边的路径由三部分组成起点锚点坐标、终点锚点坐标、中间路由点。锚点坐标是从节点矩形边界和方位实时推算的。比如sourceAnchor是right那么锚点就是节点右上角和右下角中点再往右偏移一个固定长度比如 40px保证连线不贴着节点边框。function getAnchorPoint(node, anchor): { x: number; y: number } { switch (anchor) { case right: return { x: node.x node.width, y: node.y node.height / 2 }; case left: return { x: node.x, y: node.y node.height / 2 }; case top: return { x: node.x node.width / 2, y: node.y }; case bottom: return { x: node.x node.width / 2, y: node.y node.height }; } }连线路径用正交折线方案。两点之间先沿锚点方向延长 40px然后取中间点连接。如果两侧锚点是水平对置就水平连接否则先水平后垂直。这样画出来的连线在多数情况下都干净整齐而且路径计算规则简单避免了我之前尝试贝塞尔曲线时出现的路径重叠看不清楚的问题。动态更新方面节点拖拽时会找到所有与此节点关联的边重新计算它们的起点或终点触发一次渲染更新。我的关联关系是在图模型里维护了一个adjacency映射能在拖拽回调里 O(1) 找到受影响边集合性能上没有任何压力。3.5 属性面板与导出能力编辑器没有属性面板等于画完就废。我把属性面板做成右侧浮层当某个节点被选中时显示它的 label、类型、样式当连线被选中时显示起点终点和路由点编辑。所有的属性修改都走统一的updateNode/updateEdge方法这样撤销重做和变动通知始终一致。导出功能我做了三种JSON 导出把画布当前数据对象序列化后输出SVG 导出把整个 SVG 根节点序列化加上合适的 xml 头作为独立的 .svg 文件下载PNG 导出把 SVG 画到一个离屏 canvas 上再调用 toDataURL输出为 pngPNG 导出有个需要注意的细节SVG 里的图片资源和字体样式在绘制到 canvas 时可能被跨域或者未加载的问题卡住我在导出前会把所有图片转成 base64并且等所有资源加载完成后再执行绘制。这个我在踩坑环节详细展开。4. 常见问题与排查技巧实录4.1 拖拽节点时连线不跟着走很多第一次写图编辑器的人都会遇到这个问题节点移动得欢边却留在原地。原因很直接节点坐标变了但边的路径缓存没有同步更新。如果你把边的路径计算好了之后就直接存进数据里那么每次节点移动就必须手动找到那条边并重新计算漏掉任何一个节点边就不动了。我的处理办法是不存路径数据只存节点 id 和锚点方位。渲染的时候边的路径永远从锚点函数现算出来节点拖拽触发的updateNode会让所有依赖的边进入一个dirtyEdges集合统一在下一帧重算。这样逻辑上不会出现忘了更新某条边的状态。4.2 缩放状态下鼠标落点偏移画布缩放之后鼠标点击位置不准是编辑器开发的头号新人杀手。我早期也犯过这个错在 mousedown 事件里直接读event.offsetX当作节点坐标。在没缩放的情况下这个值是准的但一旦zoom不等于 1offsetX和offsetY其实是相对 SVG 整个元素屏幕坐标的值根本没经过坐标变换。统一的解决方案就是我前面提到的那个换算函数function screenToWorld(clientX: number, clientY: number) { const rect svgRef.current.getBoundingClientRect(); const screenX clientX - rect.left; const screenY clientY - rect.top; return { x: (screenX - panX) / zoom, y: (screenY - panY) / zoom, }; }所有涉及鼠标位置的地方都用这个函数而不是自己去读 offsetX。这个规则我写进项目注释里后面任何新增交互都强制要求走这个函数从根上杜绝了坐标错乱。4.3 节点一多就卡顿SVG 的优势是简单但代价是 DOM 数量。我实测到 500 个节点时页面滚动和拖拽开始出现可感知的掉帧1500 个节点时基本没法用。缓解方案有几个层次。第一层是减少不必要的重渲染我把节点更新拆成数据更新和DOM 属性更新两步移动一个节点只更新它的xy属性和关联边的d属性而不是整棵 SVG 树重建。第二层是采样更新拖拽过程中mousemove 事件每帧可能触发几十次如果每次都跑一遍updateNode和recomputeEdges计算量会爆炸。我用requestAnimationFrame做了批处理事件只负责更新 dragState渲染循环统一在同一帧内找出所有 dirty 节点做一次更新。这个优化在 800 个节点场景下能把拖拽帧率从 18 提升到 55 左右。第三层是渲染裁剪只对视口范围内的节点做实际 DOM 渲染视口外的节点用一个占位元素替代。这个方案改变渲染逻辑较多我在diagram-design里做了一层很简单的按坐标裁剪因为节点本身有完整坐标判断它是否在panX/panY/zoom组成的可见窗口里非常容易。4.4 撤销重做的性能与一致性撤销重做是我最后当着社区朋友的面新增的功能因为之前总是拖着不做觉得图表编辑器能导出就行。但实际用下来没有撤销功能的编辑器简直没法用。实现上我不选快照方案理由是画布数据一旦变大每次操作都深拷贝一份 JSON 的成本太高。我用命令模式每一个增删改操作都封装成CreateNodeCommand、MoveNodeCommand、DeleteEdgeCommand这样的对象命令对象里保存足够的 undo 信息。执行时调用图模型的对应方法撤销时调用反方向操作。这样即使一千个节点的图一次操作的撤销内存消耗也只是命令对象本身而不是整个画布的拷贝。有个一致性坑如果用户在移动节点过程中连续生成了几十次MoveNodeCommand撤销时会一步步回退非常繁琐。我加了一个合并机制同一个节点连续发生移动时如果时间间隔小于 500ms就把移动合并进同一条命令撤销时一次回到拖拽起始位置。4.5 折线交叉与贴边细节正交折线在小图里挺好看一旦两个节点距离很近折线会乱跑甚至和节点边框重合。我的解法是在路径计算里加了两个偏移逻辑。第一锚点外延距离不能小于 30px避免折线贴着节点拐弯第二当起点和终点在同一边时路由点向下或向右偏移错开形成绕行效果。这两个参数我做成常量暴露在配置里后续可以根据不同图表类型调整。还有一个细节连线选中状态。我把每条边渲染成两条路径底层是一条宽一点的透明路径专门用来接收鼠标点击上层是可见细路径。这样鼠标点选连线时不需要精确定位到细线上误操作率大幅降低。这个方法成本几乎为零但体验提升非常明显。5. 后续扩展与个人体会5.1 往架构图和时序图方向扩展diagram-design目前的模型是通用的图模型只要节点类型够丰富就能表达流程图、架构图、状态图。我下一步的扩展思路主要有两个方向。一是增加容器节点。架构图里经常有子网服务组这种把多个节点嵌套在一个矩形区域里的需求。容器节点本质上也是一个节点只是它内部可以挂子节点需要额外实现子节点渲染的坐标系偏移和容器拖拽时子节点联动。二是增加时序图支持。时序图的核心不是自由布局而是对齐和序列。我打算在模型里增加lifeline类型用类似消息箭头的连线表达调用关系编辑器的画布交互仍然复用只是渲染模板不同。这套设计的好处是交互层完全不用变动只扩展节点和连线的 type 就能容纳新图种。5.2 多人协同的取舍有人建议我直接上多人实时协同我在评估后暂时压后了。原因很简单多人协同不只是要共享画布数据还要处理冲突、光标位置、操作意图协商这些复杂度远超编辑器本身。如果非要实现我会选择操作转换或者 CRDT 方案这会大幅增加代码量。更实际的路线是先异步协作后实时协作。上线异步的导出与导入两个人各自编辑完把 JSON 合并回主分支冲突用简单的时间戳策略解决。等到数据结构和操作序列稳定后再把每个操作 JSON 回调发送到协作后端逐步过渡到实时模式。5.3 踩坑后的几句心里话这个项目做下来我最深的体会是图编辑器最难的从来不是渲染而是状态管理。节点的位置、边的连接关系、选中态、拖拽态、路由点这些状态交织在一起如果没有一个清晰的数据模型写代码会越写越乱最终变成一团谁都不敢动的意大利面。另一个体会是编辑器的功能边界必须非常明确。你想当然地觉得多画一个图形有什么关系结果就是新增的每个图形都要处理专属交互、专属序列化、专属属性面板很快把核心逻辑淹没在边角功能里。diagram-design能保持代码整洁正是因为守住了只有节点和连线的边界。最后再分享一个小技巧当你在开发这种可视化项目时一定要尽早给自己做一个最小可演示版本。我第一版只用了一个月的业余时间就做出了一个能拖拽、能连线、能导出的雏形之后每一次改动都能在真实场景里立即验证比闷头写三个月再回头要高效得多。如果你的项目也需要一个能嵌入业务系统的图表编辑器完全可以参考这个思路一步步把它耐着性子抠出来。