资讯动态

自研在线图表设计器:从数据模型到SVG渲染的工程实践

发布时间:2026/10/19 5:04:16 来源:尧图企业网站定制
diagram-design 这个项目最开始是为了解决一个特别具体的痛点才动手做的我们要在产品里嵌入一套能拖、能连、能导出的在线图表设计能力让用户直接画系统架构图、流程图、ER 图。市面上的现成方案试了一圈总有几个绕不过去的坎——有的只适合看图不适合改图有的扩展点太封闭想加一种自定义节点得扒源码还有的一引进来就是几十兆依赖。于是我们决定自己写一个。项目从立项到稳定跑通花了大概一个季度今天我把整个脉络完整复盘一遍数据模型怎么设计、渲染为什么选 SVG、拖拽连线和自动布局里的工程细节、以及最终如何干净地接入业务方。1. 先聊聊为什么非要自研一个图表设计器1.1 现成方案的瓶颈不只是不好改一开始我们想得比较简单认为嵌一个开源图表库就行。但真正往产品里放的候才发现问题往往藏在三个层面。第一是编辑能力和展示能力是两回事。很多图表库做的其实是根据数据渲染一张图你给一份节点和连线的数据它把图画出来支持缩放、平移、点击。可我们要的是用户能自己拖一个新节点进来、从一个锚点拉线到另一个锚点、框选一堆节点批量删除这套完整的编辑交互。展示层做得再漂亮编辑入口缺失一切都白搭。第二是定制能力。这类产品通常节点类型是自己定义的比如数据库表节点、接口节点、依赖服务节点每种节点的外观、字段、校验逻辑都不一样。如果底层不预留节点类型注册机制后面每一个新需求都是灾难。第三是依赖体积和可维护性。引入一套重型的图形框架哪怕只用了 10% 的功能也要背上它的包体积、版本兼容和升级风险。对基础架构来说这种黑盒依赖很让人不安。我做过一张对比表把当时考察的几个方向和自研方案放在一起方案编辑交互自定义节点成本包体积性能表现维护可控性纯展示型图表库弱需二次开发中高小好一般通用图编辑框架强中但有隐性限制大一般差白板引擎强强但太重很大中差自研 diagram-design自己定注册制成本低可控可控完全掌控对比完之后我们的判断就很清晰了自研不是情怀是因为现成方案没有一个能同时满足编辑能力 自定义节点 可控体积这三个硬指标。1.2 自研的边界做图设计器不做大而全的白板我们给自己定了非常明确的边界只做节点 连线 画布这三种核心对象的通用图设计不做手绘画笔、不做便签贴纸、不做多人实时协同。原因很简单边界越大工作量越大后面每一个顺手加个功能的请求都会让架构变得臃肿。核心能力聚焦之后反而能把这台机器做得足够顺滑。这个边界后来帮了大忙。因为图设计的本质很容易抽象一切图无非是若干节点节点之间有连线关系再加上画布上的视图状态。把这个模型定清楚业务方可以自己在节点里塞任意 data而底层的拖拽、缩放、撤销、导出都是通用的。2. 核心数据模型节点、连线和画布如何抽象2.1 一个图在内存里到底存什么图表和树最大的区别是节点之间可以有任意多条边甚至可以成环。所以建模时我不会给节点加children这种层级字段而是走标准的图论模型——一组节点 一组引用关系的边。为了让数据可序列化、可保存、可同步节点和连线全部用 id 引用而不是内存对象引用。这一点在实现撤销栈和远端同步时尤其重要。直接看 TypeScript 的接口定义最直观interface DiagramNodeT unknown { id: string; type: string; // 节点类型如 dbTable / apiNode x: number; // 逻辑坐标不是屏幕像素坐标 y: number; width: number; height: number; ports: Port[]; // 锚点数组连线从这里出发和接入 data: T; // 业务数据任意类型 style?: NodeStyle; // 展示样式如 fillColor, strokeColor } interface Port { id: string; position: PortPosition; // 相对位置比如 top / bottom / left / right 或 {x: 0.5, y: 0} } interface DiagramEdge { id: string; sourceId: string; sourcePortId: string; targetId: string; targetPortId: string; points: Point[]; // 中间绕行点至少包含起点和终点 style?: EdgeStyle; } interface DiagramCanvas { nodes: DiagramNode[]; edges: DiagramEdge[]; zoom: number; // 缩放比例 offset: { x: number; y: number }; // 画布平移偏移 selectedIds: string[]; viewport: { width: number; height: number }; }2.2 几个容易被忽略的设计决策第一个决策是坐标必须用逻辑坐标而不是像素坐标。像素坐标跟屏幕分辨率、缩放比例绑定一旦 zoom 变化所有位置都要重算很容易产生浮点误差。我统一用一个画布坐标系渲染时通过一个 transform 把它映射到屏幕。缩放平移是视图层的职责数据层永不感知。第二个决策是锚点Port必须是节点的子对象而不是连线上的临时坐标。一开始我犯过错连线的端点直接存 x/y结果就是当节点被拖走后连线原地不动图立刻断掉。把端点绑定到锚点拖节点时连线才会自动跟随。锚点位置用相对比例存例如{x: 0.5, y: 0}表示节点顶部中点这样不管节点宽高怎么变锚点总能对齐到正确位置。第三个决策是连线要保留中间路径点。直线连当然简单但实际画图经常需要折线绕行。如果只存起点和终点路由算法每次都要重算而且用户手动调整过的绕行点会被覆盖。所以我让每条 edge 自带一个可变长度的points数组渲染时按顺序绘制。这套模型跑通后无论是撤销、导出、还是服务端保存都只需要对一份普通的 JSON 操作不用跟 DOM 或复杂对象打交道。3. 渲染层选型为什么是 SVG 为主3.1 SVG 和 Canvas 的取舍图表渲染方案吵了很多年核心其实就是两派SVG 走 DOM 节点Canvas 走像素绘制。我对它们的判断很直接SVG 适合几百到几千个图元、交互密集、需要样式丰富的场景。它是矢量图形天然支持事件绑定每个节点就是一个 DOM 元素调试也方便。Canvas 适合几万甚至更多图元、需要极致渲染性能的场景但交互命中检测、局部重绘都得自己实现复杂度高一个量级。diagram-design 面向的是在线设计器节点的数量级通常在一千以内但用户会在上面长时间拖拽、连线、框选。这种情况下 SVG 的事件模型可以省太多事情。把节点做成 DOM 元素之后浏览器自己处理命中和事件分发我只需要关心数据变化。3.2 分层渲染网格、图元、交互层各管各的一个细节但关键的架构选择是分层。整个渲染结构分为三层最底层是网格层用 SVGpattern画背景网格用户开启网格吸附时拖拽坐标可以直接取模吸附到网格值。中间是图元层也就是所有节点和连线。节点是独立元素连线用path绘制。最上层是交互层专门放框选矩形、拖拽中的临时连线、吸附高亮指示器。把交互元素独立出来是为了避免每次交互都要重绘所有节点。坐标变换我用了一个g transformtranslate(offset.x, offset.y) scale(zoom)包住整个图元层。所有节点绘制时只需要写逻辑坐标改变 zoom 或 offset 时只更新这个 g 的 transform 属性。这个方案比每次去改每个节点坐标要高效得多也避免了浮点漂移累积。3.3 性能优化只更新需要变的东西SVG 最大的性能陷阱是全量重绘。如果拖一个节点就把整张图的 innerHTML 重算一遍一千个节点时帧率会立刻掉到个位数。我的做法是按变更粒度更新拖节点时只改那个节点的 x/y 属性连线的 path 只更新与之相连的部分。在实现上我把内存中的数据和渲染出来的 DOM 元素做了映射。用一个 Map 存nodeId - 元素实例当数据变化时只找到对应元素执行更新操作。SVG 的 transform 和 d 属性更新开销都很小实测一千节点时拖动依然能保持流畅。还有一个小技巧不要频繁创建和销毁 DOM 元素。框选、拖拽过程中产生的临时元素留在交互层统一 update结束后再隐藏或清理。频繁的 append/remove 会带来大量重排和重绘而几十个临时元素的属性更新几乎不消耗性能。4. 交互层的工程细节拖拽、框选、连线、撤销全在这里4.1 统一用 Pointer Events交互层我一开始用的是 mouse 事件后来发现触屏设备完全不可用于是全部迁移到 Pointer Events。它能把鼠标、触屏、手写笔统一成同一套事件。下面三个是核心原则按下时记录起始坐标把事件对象里的 clientX/clientY 转成画布逻辑坐标存到状态里。设置判定阈值如果移动距离小于 3 像素视为单击超过阈值才进入拖拽。否则用户只是想点一下节点结果却把它拖走了体验非常差。使用 setPointerCapture在 pointerdown 之后把事件捕获绑定到当前元素这样即使鼠标移出画布up 事件也不会丢。4.2 拖拽节点与框选拖拽节点本身不难难点在于拖拽过程中其他一切的联动。一个节点被拖动时要同步做的事包括更新节点自己的 x/y重新计算所有连接到它锚点的边的路径如果开启网格吸附坐标取模到网格值如果开启了辅助对齐线还要检测它是否与其他节点的边缘对齐。这些逻辑如果都写在节点组件内部会非常混乱所以我把它抽成了一个InteractionManager负责统一收集事件、维护当前拖拽状态、调用模型层更新数据再由渲染层响应数据变化。框选稍微巧妙一点。用户在空白处按下拖拽时交互层出现一个半透明矩形同时遍历所有节点做矩形相交判断。这里要注意判断用的是逻辑坐标而不是屏幕坐标。屏幕坐标要经过减 offset、除 zoom的逆变换才准确。shift 框选时我把当前选中的 id 集合保留新的命中的点追加进去实现加选。4.3 连线从锚点拖出临时线连线是最能体现设计器手感的地方。用户从某个锚点按下鼠标拖出一条线放开时落到另一个锚点就成功建边。这个流程有个核心难点拖拽过程中要实时判断当前鼠标附近是否有可连接的锚点并且要有吸附效果。我的实现是mousemove 时拿到鼠标逻辑坐标遍历画布中所有锚点计算距离若距离小于阈值比如 12 像素则把临时线的终点吸附到锚点位置并把目标节点高亮。松开时判断是否处于吸附状态是则创建正式 edge否则丢弃临时线。这里有性能上的讲究每个 mousemove 都全量遍历所有锚点会有点耗。小图无所谓但为了避免大图卡顿我用了一个简单的空间索引——按网格把锚点分桶只需要检查鼠标所在格子及其相邻格子里的锚点即可。4.4 撤销栈命令模式而不是快照撤销/重做是设计器的标配。一开始我图省事每做一次操作就把整个 canvas 对象深拷贝一份存进历史栈。200 个节点时还勉强能看500 个节点时每次操作的内存占用就很吓人而且操作一多历史栈越来越大卡顿感非常明显。后来我重构成了命令模式每个操作先产生一个 command 对象里面包含 do() 和 undo() 两个函数。比如拖拽节点产生的命令就是interface Command { do(): void; undo(): void; label?: string; } // 拖拽节点示例 function createMoveCommand(nodes: DiagramNode[], before: Mapstring, Point, after: Mapstring, Point): Command { return { do() { nodes.forEach(n { const p after.get(n.id); if (p) { n.x p.x; n.y p.y; } }); }, undo() { nodes.forEach(n { const p before.get(n.id); if (p) { n.x p.x; n.y p.y; } }); }, }; }多个操作可以合并成一个命令例如拖拽多个节点before 和 after 统一记录。命令模式不仅省内存还可以在 do/undo 之外追加一些副作用比如更新连线路径、触发变更事件。历史栈维护一个 index执行 undo 就 index-- 并调用旧命令的 undo执行 redo 就 index 并调用新命令的 do。这里必须提醒一个极易踩的坑命令里保存的状态必须是深拷贝的坐标快照。如果直接引用节点对象撤销时节点早就被改掉了等于撤销了一个寂寞。我一般会为每次命令保存一份轻量的坐标 Map而不是一整棵对象树。5. 自动布局让乱糟糟的连线图自动变得可读5.1 什么时候会用到自动布局很多人觉得自动布局是锦上添花其实在真实场景里它是刚需。比如用户导入一份 JSON 数据节点全部堆在原点附近这时候如果不做自动布局用户第一眼看到的就是一张没法看的乱图。又或者某些业务方需要根据依赖关系自动生成架构图节点之间层级明确手摆根本不现实。diagram-design 的自动布局我按两种场景做了两套策略有向无环结构的层次布局和一般无向结构的力导向布局。这里重点说层次布局因为它对流程图、架构图、ER 图的支撑最直接。5.2 层次布局的三个步骤层次布局的核心思想是把节点按层级铺开每层一行或一列连线的方向尽量一致减少交叉。我的实现分三步第一步分配层级。找出所有入度为 0 的节点作为根节点从根开始 BFS 遍历每个节点的层级等于它的最大父层级 1。如果一个节点有多条入边我会取所有父层级中的最大值避免节点被安排得太靠前导致连线向上回溯。第二步层内排序。这一步的目标是在同一个层内调整节点顺序使连线的交叉尽可能少。我用的是 barycenter重心方法某个节点的理想位置 它所有相邻节点位置的均值然后按理想位置排序。这个算法会迭代几次每次都用上一次的结果作为输入通常四五次迭代后交叉数就收敛到很低。第三步分配坐标。确定每一层的节点顺序后横向用固定节点间距逐个摆放纵向每一层用固定的层间距。这一步会留出足够的空间给折线绕行。实际上在工程实现里我会先处理一个有环图的问题。BFS 层级分配在遇到环时会导致层级无限递增解决办法是先用 Tarjan 算法或简单 DFS 找出回边在计算层级时忽略回边布局后再用曲线或折线表示回边。5.3 工程化布局不能阻塞界面布局算法如果在主线程同步跑图一旦上千个节点用户会看到明显的卡死几秒。我把布局引擎设计成了异步可取消的任务class LayoutTask { constructor(nodes: DiagramNode[], edges: DiagramEdge[]) {} async run(): PromiseMapstring, Point { // 分阶段执行每个阶段之间让出主线程 const levels await this.assignLevels(); const ordered await this.sortWithinLevels(levels); const positions await this.assignCoordinates(ordered); return positions; } }使用方拿到布局结果后可以先预览再应用把结果坐标叠加在交互层上用半透明状态展示应用才真正写回数据并触发撤销命令。这样用户误点了布局还能一键撤销体验会好很多。5.4 力导向布局的取舍无向图或者关系网图我跑的是力导向算法本质上就是让节点之间相互排斥、有边相连的节点相互吸引迭代若干次后系统趋于稳定。它的优点是几乎不需要用户干预缺点是结果不稳定同样一组数据跑两次坐标不同而且迭代次数少了很容易陷入局部震荡。所以我的处理是只对没有指定布局类型的图调用它固定迭代次数比如 200 次迭代完成后高斯平滑一次坐标。它能给出一个能看的初始布局但精细调整还是得靠用户手工。6. 导出与接入把设计器变成业务的基础设施6.1 三件套JSON、SVG、PNG图表设计器最终要能交付内容导出功能是逃不掉的。我提供了三种导出格式各有用途JSON用于保存、恢复、协作同步。数据就是前面定义的DiagramCanvas结构直接JSON.stringify即可。SVG用于矢量图嵌入文档、PPT、网页。导出的 SVG 要求所有样式内联不能依赖页面里的 class。PNG用于截图、分享、论文插图。PNG 的生成不能直接截屏而是基于 SVG 做二次转码。PNG 导出我踩过不少坑核心代码大致是这个思路async function exportPng(data, options {}) { const svgString serializeToSvg(data); // 生成 svg 字符串 const svgBlob new Blob([svgString], { type: image/svgxml;charsetutf-8 }); const url URL.createObjectURL(svgBlob); const img new Image(); await new Promise(resolve { img.onload resolve; img.src url; }); const canvas document.createElement(canvas); canvas.width (data.width || 1200) * (options.scale || 2); canvas.height (data.height || 800) * (options.scale || 2); const ctx canvas.getContext(2d); if (options.background ! transparent) { ctx.fillStyle options.background || #ffffff; ctx.fillRect(0, 0, canvas.width, canvas.height); } ctx.drawImage(img, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(url); return canvas.toDataURL(image/png); }这里有两个容易翻车的地方一是外部字体SVG 里如果用到了特殊字体必须用 object 内嵌或转成 path否则别人的电脑上打开就是乱码二是分像素模糊导出时要把坐标取整或加 0.5 偏移否则缩放后线条会有明显毛边。6.2 对外 API 设计少暴露好扩展我认为一个好的设计器库对外 API 应该非常克制。diagram-design 暴露的东西只有三类初始化与数据加载const designer new DiagramDesigner({ container: document.getElementById(app), registry: registry, // 节点类型注册表 enableGrid: true, gridSize: 10, }); designer.load(canvasData);事件钩子业务方最关心的是节点被点击了连线创建了选中变化了这些以回调方式暴露内部实现完全不透出。自定义节点注册type NodeRenderer (node: DiagramNode, ctx: RenderContext) HTMLElement | SVGElement; registry.register(dbTable, (node, ctx) { const el document.createElementNS(http://www.w3.org/2000/svg, g); // 在这里根据 node.data 画表格外观 return el; });采用注册制之后业务方新增一种节点类型几乎不需要触碰底层代码。这是当时我们判断自研值得的最重要理由——业务在快速变化而底层模型稳定注册制把变化隔离在业务侧。6.3 包体积优化按需加载diagram-design 的包我做成了多入口核心渲染与交互是主包布局引擎、导出模块、内置节点类型各自独立。业务方可以根据场景只引需要的部分。现在前端工程都支持 tree-shaking把大模块尤其是布局和 PNG 导出单独拆出来首屏体积能显著下降。我见过有些产品只是想让用户画个简单的框图结果拉了整个白板引擎进来首屏多出几 MB这是完全可以避免的。7. 复盘这个项目最值得记住的几个坑7.1 坐标漂移问题缩放中心永远是鼠标这是交互层我踩得最深的一个坑。刚开始做滚轮缩放时只是简单地改变 zoom 值然后把 offset 设为offset * ratio。结果缩放过程中图中某个点会不断飘走用户完全无法控制缩放到想看的位置。正确的做法是缩放前后鼠标所在位置的逻辑坐标必须保持不变。实现起来也不复杂缩放前先把鼠标屏幕坐标反转成逻辑坐标缩放后根据新的 zoom 重新计算 offset让鼠标重新指向同一个逻辑坐标点。这个转换函数值得一写再写并且要在框选、拖拽中复用否则交互时很容易出现鼠标下没有东西的诡异手感。7.2 连线和节点状态不同步最开始连线的端点坐标是节点坐标的快照导致节点被拖拽时连线像断开了一样。后来改成每次渲染时根据节点和锚点的当前坐标实时推导连线端点这个问题就彻底消失了。这里的原则是不要保存任何可以从其他数据推导出来的冗余字段。数据越冗余不同步的风险越高。7.3 撤销栈引用的失效问题用命令模式做过撤销机制的人一定遇到过删了一个节点然后撤销结果连线还在引用那个节点的灵异状态。根因在于命令只记录坐标没有处理拓扑关系的变化。我现在会为每类命令定义它影响的节点集合并且在 do/undo 之外维护一个资源版本号。每次执行命令后所有依赖这些节点的视图都会收到结构可能已变化的通知从而重新校验连线引用。这个教训让我意识到撤销机制不是简单的历史栈它是数据模型的第二次实现。7.4 关于自研的一点个人体会做 diagram-design 最大的收获不是写了几千行代码而是想清楚了一个道理工具类产品最重要的是边界清晰。一开始很容易陷入什么功能都想要的冲动最后发现每次加功能都会让核心交互变慢。后来我强制自己只做三个核心能力——拖拽、连线、撤销其余全部靠扩展机制交给业务侧。事实证明边界清晰带来的收益比功能数量大得多。最后分享一个注意事项如果你的场景只是展示一张图偶尔编辑几个节点完全没必要自研用现成的展示型图表库就够了。但如果你要做的是一款以图表编辑为核心能力的业务系统希望自定义节点类型、深度控制交互细节、长期演进维护那自研一个像 diagram-design 这样的小而精的引擎会是更可持续的选择。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑