资讯详情 自研Web图表编辑器:数据模型与SVG渲染的实践
📅 2026/10/21 10:05:27
在分享这个项目之前先交代一下背景。diagram-design是我前段时间折腾了大半个周末的一个小项目起因很简单团队内部需要一个能快速画出系统架构图、网络拓扑图和简单流程图的工具市面上现成的编辑器要么太重、要么嵌不到自己系统里要么数据格式不透明。于是干脆自己写了一个 Web 端的图表设计器代号就叫diagram-design。这个项目本身不算大但它把“图表编辑”这件事涉及的几个核心问题都过了一遍数据模型怎么设计、渲染层选 SVG 还是 Canvas、鼠标交互怎么映射到业务操作、缩放平移怎么换算坐标、自动布局怎么避免节点重叠、导出图片怎么保证清晰度。这些问题每个单拎出来都不复杂但凑在一起坑比想象中多。这篇文章把整个过程和踩过的坑整理出来对打算自己做图编辑器、或者想在项目中嵌入一个可视化绘图功能的朋友应该有点参考价值。1. 为什么会有这个项目diagram-design 到底解决什么问题1.1 需求场景画图这件事的痛点先说说最原始的痛点。团队里画架构图这件事长期处于“各画各的”状态有人用在线白板有人用桌面端绘图软件还有人直接拿代码画 Graphviz 或者 Mermaid。问题在于在线白板类工具偏自由画布适合头脑风暴但画出来的图没法结构化节点之间没有真正的“连接关系”后面想统计、校验链路根本做不到。代码类画图工具Mermaid、PlantUML适合版本管理但交互很反人类调个布局要改代码非研发同事基本用不了。桌面端工具功能全但数据存在本地没法跟内部系统打通也没法在 Web 页面里嵌入。所以核心需求其实是一个可以嵌入 Web 系统、数据结构化、支持拖拽编辑、导出结果干净的图表设计器。这个定位决定了后面所有的技术选型。1.2 方案定位做一个可嵌入的 Web 图编辑器确定了需求接下来就是定位。diagram-design的目标不是做一个通用绘图软件而是做一个“组件式”的图编辑器具备这几项基础能力节点支持拖拽创建、移动、删除、缩放部分场景、自定义样式。连线支持从节点锚点拖出连线、支持折线/曲线、支持箭头和标签。画布支持缩放、平移、框选、多选、对齐辅助线。数据节点和连线的数据模型独立于 UI随时可以序列化成 JSON也能从 JSON 恢复。导出支持导出为 SVG 和 PNG后续可以扩展导出为 DOT 等其他格式。这几点看起来是基本功但真正做下来会发现“拖拽画图”这种事交互细节比预想多得多。比如鼠标按下去的时候要判断你到底是点中了节点、连线的锚点、还是空白区域拖动的时候要判断是移动节点还是拖拽出一个新的连线缩放到一定级别之后节点上文字要跟着缩放还是保持大小不变撤销重做怎么截断和合并操作。1.3 技术选型为什么不用现成开源编辑器改前期调研过几个开源方案比如基于 React 的流程图库、基于 Canvas 的自由画布库甚至考虑过直接魔改某个知名的开源绘图工具。最后放弃这些现成方案决定自研理由比较现实有的库抽象层次太高核心数据模型被封装死了想接入自己系统的数据格式要先做一层兼容。有的库渲染层固定在 Canvas而我的场景里节点数量通常不超过几百个SVG 的灵活性、DOM 事件能力、样式定制能力在这个量级下明显更舒服。最重要的是团队有时不只需要“画图”还要图形校验比如检查有没有孤立节点、有没有环、自动布局比如按层级重排、与后端数据联动。这些需求往往要深入到图结构层面做定制自研反而更可控。当然自研的代价是工作量集中在交互细节上。这一点后面会详细展开。2. 整体设计思路先画清楚数据模型再写界面2.1 核心数据模型节点、连线、画布写图编辑器的第一件事不是写界面而是先定义清楚数据模型。我采用的是经典的“节点-边”模型Graph Model加上一层的画布描述。节点模型设计如下const node { id: n_001, type: service, // 节点类型service / storage / external 等 x: 120, // 世界坐标位置左上角 y: 80, width: 160, height: 48, ports: [ // 锚点连线的接驳点 { id: p_in, side: left, offset: 0.5 }, { id: p_out, side: right, offset: 0.5 } ], data: { // 业务数据 name: 订单服务, color: #4A90D9 } };注意几个设计细节坐标用世界坐标而不是屏幕坐标。画布缩放平移之后节点存储的坐标不随之改变渲染时统一做变换。这样在序列化、多人协作如果后续要做时会省很多事。锚点独立建模。不需要连线的时候把锚点直接放在节点上而是用ports数组单独管理。这样后续想支持“一个节点上多个出点”或者“自定义锚点位置”时数据结构不用动。业务数据和渲染数据分离。data字段里放业务相关的内容渲染相关的宽度、颜色、位置单独放在外层。这个分离在做数据联动时非常有用后端更新业务数据前端只需要合并data字段。连线模型长这样const edge { id: e_001, source: n_001, target: n_002, sourcePort: p_out, targetPort: p_in, points: [ // 中间经过的控制点可选 { x: 300, y: 104 }, { x: 360, y: 104 } ], style: { stroke: #333333, dash: false, arrow: true } };这里最有争议的设计是points字段。有些图编辑器不允许用户手动调整连线拐点连线路径完全由算法自动生成比如正交路由。我在diagram-design里选择保留手动控制点因为画架构图时用户经常希望连线绕开遮挡物或者按照阅读习惯调整路径。自动路由算法在简单场景很好用但复杂场景下用户想手动干预是刚需。画布模型就简单多了保存视口信息const canvas { zoom: 1.25, // 缩放比例 pan: { x: 100, y: 50 }, // 平移偏移量 width: 1920, height: 1080, background: #ffffff };2.2 渲染层选择SVG 还是 Canvas这是图编辑器绕不开的选择题。我查了不少资料也做了个简单的原型对比。结论如下对比维度SVGCanvas节点规模适合 1000 个节点适合 1000 个节点事件处理天然支持 DOM 事件命中检测精准需要手动计算点击坐标与图形的关系复杂样式定制直接用 CSS支持 hover、动画需要自己实现状态管理麻烦导出天然矢量导出 SVG 简单导出 PNG 需要额外调用toDataURL性能瓶颈节点太多时 DOM 操作会成为瓶颈绘制量大时优势明显重绘逻辑复杂开发效率高调试直观较低需要抽象绘图层做图编辑器命中检测是个大问题。在 SVG 里判断鼠标是否点中了某个节点、连线、锚点浏览器已经帮你做好了你只需要监听事件。在 Canvas 里你需要自己维护一份图形列表鼠标点击时反向遍历查找。这个逻辑在简单场景不复杂但一旦涉及分组、旋转、贝塞尔曲线麻烦成倍增加。diagram-design的场景是技术架构图、流程编排图节点数量在几十到几百之间我果断选了 SVG。这个决策让我在后面的开发中省了大量时间所有 hover 效果、点击事件、拖拽反馈都直接用 CSS 和原生事件搞定。2.3 交互层设计鼠标事件到业务动作的映射图编辑器的核心交互只有这几类平移拖空白区域、缩放滚轮、框选拖空白区域 Shift、移动节点、拖拽连线、编辑文字。看起来不多但状态管理很容易混乱。我采用了一种“状态机 命令分发”的模式鼠标按下时根据命中结果决定进入哪种交互模式鼠标移动时根据当前模式执行具体动作鼠标释放时退出模式并触发业务回调。const interactionState { mode: idle, // idle / pan / select / moveNode / drawEdge / marquee targetId: null, startX: 0, startY: 0 }; function handleMouseDown(event) { const target hitTest(event); // 基于 SVG 的 hitbox 判断 if (target.type node) { interactionState.mode moveNode; interactionState.targetId target.id; } else if (target.type port) { interactionState.mode drawEdge; interactionState.targetId target.nodeId; interactionState.portId target.portId; } else if (event.shiftKey) { interactionState.mode marquee; } else { interactionState.mode pan; } interactionState.startX event.clientX; interactionState.startY event.clientY; }这种设计的好处是每个交互模式都是独立的逻辑单元不会互相干扰。比如你正在拖拽连线突然鼠标出了画布边界或者中途按下了 ESC都可以在模式里单独处理不会影响其他状态。一个值得注意的经验不要把所有逻辑都写在事件回调里。如果把交互逻辑写在 mousemove 回调里后期想增加“自动吸附”功能改起来会非常痛苦。把每个模式拆成一个类暴露start、move、end、cancel四个方法思路会清晰很多。3. 关键实现细节从零到可用的过程拆解3.1 画布缩放与平移的坐标换算这是图编辑器最容易出错的地方值得多说几句。画布相当于一个“摄像机”缩放zoom和平移pan改变了摄像机的视角。屏幕上看到的位置屏幕坐标和节点存储的位置世界坐标之间存在换算关系。公式如下screenX worldX * zoom pan.x screenY worldY * zoom pan.y反过来鼠标点击时要把屏幕坐标转回世界坐标worldX (screenX - pan.x) / zoom worldY (screenY - pan.y) / zoom在 SVG 渲染中我建议用一个g元素统一承接变换而不是每个节点单独计算位置g transformtranslate(pan.x, pan.y) scale(zoom) !-- 节点、连线都放在这个组里 -- /g这样做的好处是所有子元素都只关心世界坐标渲染层自动处理缩放平移。需要特别注意缩放中心点。默认情况下scale是围绕原点0, 0进行的如果希望缩放以鼠标所在位置为中心需要在缩放时调整pan。实际代码大致如下function zoomAtPoint(factor, mouseScreenX, mouseScreenY) { const worldPointBefore screenToWorld(mouseScreenX, mouseScreenY); const newZoom Math.min(maxZoom, Math.max(minZoom, canvas.zoom * factor)); canvas.zoom newZoom; // 缩放后让鼠标指向的世界坐标保持不变 const worldPointAfter { x: worldPointBefore.x, y: worldPointBefore.y }; canvas.pan.x mouseScreenX - worldPointAfter.x * newZoom; canvas.pan.y mouseScreenY - worldPointAfter.y * newZoom; }这段代码踩过的坑是如果先修改pan再修改zoom坐标换算会出错。正确的顺序是先根据旧坐标算出鼠标对应的世界坐标再设置新zoom最后用这个不变的世界坐标反推pan。3.2 拖拽节点与对齐吸附拖拽节点看起来简单但要做顺手必须处理对齐吸附。对齐吸附的逻辑分两类节点之间的边缘对齐拖拽节点 A 时如果它的左边缘接近节点 B 的左边缘自动把 A 的左边缘吸附到和 B 一致。中心线对齐创建一条临时的辅助线显示两个节点的中心线重合。画布网格吸附开启网格对齐时节点的坐标自动取整到网格的整数倍。我的实现是维护一个“吸附参考数组”每次拖拽移动时扫描所有其他节点的边缘坐标判断差值是否小于阈值默认 5px。如果命中就产生吸附偏移量。const SNAP_THRESHOLD 5; function computeSnapOffset(draggingNode, allNodes) { let offsetX 0; let offsetY 0; const edges collectEdges(allNodes); // 所有边缘坐标 for (const edge of edges) { const diff edge.coordinate - draggingNode.x; if (Math.abs(diff) SNAP_THRESHOLD) { offsetX diff; break; } } // Y 方向同理 return { x: offsetX, y: offsetY }; }注意吸附偏移量不能直接改节点位置而是叠加在拖拽位移上。否则一旦吸附之后鼠标再微调节点会因为位置已经等于边缘坐标而抖动。我的做法是拖拽过程中持续计算目标位置如果检测到吸附就显示辅助线并调整视觉位置但内部记录“未吸附的原生位置”保证用户可以继续拖拽离开吸附状态。3.3 连线交互从锚点拖出到自动布局从一个锚点拖出连线到另一个锚点这个交互做起来比想象中复杂。核心问题是拖拽过程中连线的一端跟随鼠标移动但另一端要保持节点锚点位置不变当鼠标经过其他节点的锚点时要给出高亮提示此时鼠标松手连线才算完成。这里有一个细节页面上的鼠标坐标需要先转换成世界坐标再计算最近的锚点。因为锚点坐标是世界坐标直接拿屏幕坐标去算缩放之后会偏差很大。我第一次实现时偷懒没做这个转换缩放到 1.5 倍的时候明明鼠标已经贴在锚点上了程序还认为没到体验很糟糕。连线路径生成也分几种情况直线起点到终点直接一条线段。曲线贝塞尔适合脑图式的场景。正交折线先横后竖或先竖后横每次转折都是直角适合架构图。我实现了正交折线因为架构图用这种风格最干净。简单实现如下function orthogonalPath(sourcePoint, targetPoint) { const dx targetPoint.x - sourcePoint.x; const dy targetPoint.y - sourcePoint.y; const midX sourcePoint.x dx / 2; return M ${sourcePoint.x} ${sourcePoint.y} L ${midX} ${sourcePoint.y} L ${midX} ${targetPoint.y} L ${targetPoint.x} ${targetPoint.y}; }中间点记录在edge.points里这样用户手动拖动连线中间的控制点就可以改变路径。自动布局和手动布局的矛盾在这里有一个折中的处理用户手动拖过的控制点在自动布局时被标记为锁定不参与重排。3.4 撤销重做与数据序列化撤销重做功能一开始我觉得很简单无脑快照就行。真正做下来发现存储开销和操作粒度都是问题。方案一是“快照模式”每次操作之后存储整份 JSON。优点是实现简单缺点是操作多了以后占内存而且无法细粒度控制合并操作比如连续拖拽节点 10 次应该只算一次撤销动作。方案二是“命令模式”每个操作抽象成命令记录变更前后差异撤销时反向执行。优点是粒度和内存都可控缺点是写代码麻烦。diagram-design最终采用的是命令模式但只对“结构性操作”启用例如添加节点、删除节点、创建连线、批量移动。而对于拖拽移动这种高频操作会在拖拽开始时记录起点结束时记录终点整段拖拽合并为一条命令。class MoveNodeCommand { constructor(nodeId, fromX, fromY, toX, toY) { this.nodeId nodeId; this.fromX fromX; this.fromY fromY; this.toX toX; this.toY toY; } execute() { setNodePosition(this.nodeId, this.toX, this.toY); } undo() { setNodePosition(this.nodeId, this.fromX, this.fromY); } }序列化方面整张图的 JSON 结构直接对应 2.1 节的数据模型导出导入都很自然。我额外做了图层元信息const diagramJson { version: 1.0.0, canvas: { width: 1920, height: 1080, zoom: 1, pan: { x: 0, y: 0 } }, nodes: [...], edges: [...], metadata: { creator: diagram-design, updatedAt: ... } };version字段很重要后续数据结构调整时可以根据版本号做迁移。4. 实操中的性能优化与疑难排查4.1 节点数量上来之后的优化策略SVG 的性能在小规模几十个节点下完全没问题但一旦节点超过 300 个拖拽和缩放时会有明显的卡顿感。我做了三件事来缓解。第一批量 DOM 更新。拖拽节点移动时不要直接操作node.position触发 Vue/React 的重新渲染整个列表而是直接操作 SVG 元素的transform属性。也就是说用原生 DOM 操作绕过框架的 diff 机制。等拖拽结束再一次性更新数据模型并重渲染。这个优化效果最明显。第二视口裁剪。只渲染视口范围内的节点。图可能很大但视口只有那么一块。我按节点坐标和视口范围做了一次过滤外面的一律不渲染。代码大致思路function getVisibleNodes(nodes, viewport) { return nodes.filter(n n.x n.width viewport.x n.y n.height viewport.y n.x viewport.x viewport.width n.y viewport.y viewport.height ); }第三对高频事件做节流。mousemove 和 wheel 事件在拖拽、缩放时触发频率极高配合requestAnimationFrame做节流让渲染逻辑保持在每一帧最多执行一次。let rafId null; function onMouseMove(event) { if (rafId) return; rafId requestAnimationFrame(() { // 实际处理逻辑 rafId null; }); }4.2 常见问题速查表问题现象可能原因解决方案缩放后鼠标点击错位屏幕坐标没有转世界坐标确认换算公式中先除 zoom 再减 pan连线拖出来后找不到目标锚点边界检测用了屏幕坐标将鼠标坐标转换为世界坐标后再查锚点节点多时拖拽卡顿每次移动都触发了全量重渲染移动过程用原生 DOM 改 transform松手再重渲染导出 PNG 模糊直接用canvas.drawImage导出的尺寸小先按缩放倍率放大画布再截图撤销重做后连线丢失连线的 source/target 指向已删除节点删除节点时级联删除相关连线缩放中心不受控缩放时 pan 和 zoom 更新顺序错误先算世界坐标再更新 zoom再反推 pan辅助线抖动吸附后仍持续触发吸附判断保留原生位置只改视觉位置4.3 我踩过的坑第一个坑是锚点的高亮逻辑。一开始我把“是否高亮”绑定在 mouseenter / mouseleave 事件上结果拖动过程中快速经过多个锚点事件触发顺序混乱有时候高亮停留在一个已经不相关的锚点上。后来改成拖拽过程中实时计算鼠标与所有锚点的距离只对最近的一个锚点做高亮状态。这件事告诉我编辑器的交互状态不要依赖 DOM 事件去维护而是依赖数据模型的实时计算。第二个坑是导出 SVG 的字体问题。在浏览器里编辑时字体用的是系统默认字体。把 SVG 内容复制出去用其他工具打开时字体变了布局也乱了。后来我导出的 SVG 里显式指定了font-familyArial, sans-serif并且把文字转成text元素不依赖外部 CSS。第三个坑是撤销重做的栈溢出。快照模式听起来离谱但有段时间我为了赶进度临时用 JSON.stringify 整图存栈。结果连续操作几十次之后内存占用飙到几百 MB。换成命令模式后内存占用降了一个数量级。建议大家从一开始就老老实实写命令模式快照只作为兜底安全机制。5. 导出能力的实现细节与数据可视化方向5.1 导出 SVG保留矢量跨工具可用导出 SVG 相对简单因为渲染本身就是 SVG直接序列化画布内容即可。但要做两个处理把diagramJson里的世界坐标换算成导出文件里的绝对坐标避免依赖g transform来缩放平移。把节点里的业务数据比如名称、颜色映射到 SVG 的样式属性上。导出伪代码function generateSvgString(diagram) { let svg svg xmlnshttp://www.w3.org/2000/svg width${diagram.canvas.width} height${diagram.canvas.height} viewBox0 0 ${diagram.canvas.width} ${diagram.canvas.height}; for (const node of diagram.nodes) { svg rect x${node.x} y${node.y} width${node.width} height${node.height} fill${node.data.color} stroke#333 stroke-width1 rx4/; svg text x${node.x node.width / 2} y${node.y node.height / 2} text-anchormiddle dominant-baselinemiddle font-familyArial, sans-serif font-size12${node.data.name}/text; } // 连线同理 svg /svg; return svg; }注意导出用的字体要在文件里显式写死否则不同环境打开效果不同。5.2 导出 PNG高清晰度的实现手段导出 PNG 我的做法分两步先把diagramJson渲染到一个离屏 SVG或者直接用已有的 SVG 节点。用XMLSerializer把 SVG 转成字符串再用Image加载这个 svg data URL最后绘制到canvas上调用canvas.toBlob()导出。关键的清晰度问题在这里出现。SVG 里的坐标不管多大在屏幕上直接显示时是物理像素对应逻辑像素。如果画布逻辑尺寸是 1920x1080导出 PNG 默认就是 1920x1080这在高分屏上会显得发虚。我的解决方案是在导出参数里增加scale选项最终导出尺寸 逻辑尺寸 x scalescale 默认 2。function exportPng(diagram, scale 2) { const svgString generateSvgString(diagram); const svgBlob new Blob([svgString], { type: image/svgxml }); const url URL.createObjectURL(svgBlob); const img new Image(); img.onload () { const canvas document.createElement(canvas); canvas.width diagram.canvas.width * scale; canvas.height diagram.canvas.height * scale; const ctx canvas.getContext(2d); ctx.scale(scale, scale); ctx.drawImage(img, 0, 0); canvas.toBlob(blob downloadBlob(blob, diagram.png), image/png); }; img.src url; }这里有个浏览器兼容性的点ctx.drawImage(img)在某些浏览器里会因为 SVG 外部资源比如外部字体加载问题而失败。稳妥的做法是把字体样式直接内联到 SVG 字符串里并且所有图片都转成 base64 内联。我线下的实测用 Chrome 最新版完全没问题Safari 里偶尔会丢字体但这属于已知限制。5.3 从图编辑器到数据可视化下一步的扩展思路diagram-design做出来之后我意识到它其实可以往“数据可视化编排工具”方向延伸。因为图的底层数据模型是通用的不只是画架构图它可以变成知识图谱编辑器节点变成实体连线变成关系。流程编排器节点变成步骤连线变成流转路径附加条件表达式。网络拓扑监控节点绑定真实设备状态连线绑定流量数据。这些场景都只需要改“节点的类型定义”和“数据的来源”核心的图编辑交互层完全复用。我在设计时把节点type做成了可扩展的注册表新增一种节点类型只需要注册它的渲染组件和属性表单不需要改动编辑器内核。这也是我后来坚持把数据模型单独抽出来的原因。一次数据结构设计得当可以省掉未来无数次重构的麻烦。最后分享一点经验做diagram-design这几十个小时最大的体会是图编辑器难写的不是绘图而是交互状态管理。如果你也想做类似的东西建议按这样的顺序推进先做数据模型和序列化再做渲染最后做交互。交互里面先做缩放平移这个是基础再做节点拖拽最后再做连线连线的调试成本是所有功能里最高的不要一开始就追求完美。另外测试建议用随机生成的大图来压测。我写了个脚本能随机生成 500 个节点和 800 条连线每次改了渲染逻辑都跑一遍看帧率。很多性能问题在小图上是看不出来的必须用大图触发。如果生成随机图时发现节点重叠得很厉害大概率是自动布局算法的问题而不是测试脚本的问题这一点也别搞混了。