资讯动态

自研图编辑组件:从数据模型到交互导出的前端实现

发布时间:2026/10/21 10:11:08 来源:尧图企业网站定制
有段时间我一直在折腾一个叫diagram-design的前端项目本质是一个在浏览器里跑的自研图表设计器。起因很朴素团队内部的业务流程梳理、架构图、接口依赖图来回改需求太频繁现成的绘图软件要么导不到内部系统要么协作起来别扭。被磨了几轮之后我决定自己写一个能内嵌在现有平台里的图编辑组件。这篇文章就把这个项目的核心设计思路和实现过程拆开讲一遍包括数据模型怎么抽象、渲染为什么选 DOM SVG、交互层那些看起来简单、写起来头疼的细节、还有序列化和导出的弯弯绕。无论你是想给系统嵌入一个图编辑能力还是单纯对图表设计器怎么造感兴趣应该都能从这里拿到一些可以直接用的方案。1. 从想画图到想造画图工具一个被反复折腾的真实场景1.1 我为什么放着现成软件不用非要自己写先说场景。当时我们做的平台里有一块业务流程编排功能用户需要把不同的业务节点拖到画布上用连线表达先后关系再配上说明文字和分支条件。第一版我用过市面上成熟的开源图编辑器功能确实全但问题也很明显。第一体积和依赖问题。引入一个重量级库随随便便几百 KB 起步和平台的构建产物合在一起首屏性能直接受损。第二定制成本高。业务节点里有大量自定义表单、状态色、缩略图表很多库的节点渲染机制是内置的想要完全贴合业务样式要么侵入源码要么绕很多弯。第三数据格式难统一。我们内部有一套业务对象的 JSON 结构图编辑器需要和这套结构深度绑定通用库的序列化格式往往不匹配每次导入导出都要做一堆映射。所以第 N 次被集成折磨之后我启动了自己的diagram-design。初衷很明确不要做成一个通用产品而是做成一个数据模型开放、渲染层可替换、交互按需实现的图编辑组件。它不追求大而全但必须和业务咬合得足够紧。1.2 三个备选方案的横向对比云服务、开源库、自研网上常有人问到底要不要自研我自己的判断标准是先看你的图元复杂度和数据耦合度。这里列一份我在动手前做的对比二维码式的参考结论藏在表格里。方案优势劣势适合场景云绘图服务/在线白板功能成熟零开发数据隔离难无法深嵌业务临时出图、跨团队协作通用开源图编辑器开箱即用社区成熟依赖重定制受限格式绑定通用流程图、轻量展示自研图编辑组件完全可控数据闭环体积小开发周期长交互细节多业务深度耦合、长期演进我最终选了自研还有一个隐性原因数据模型。业务侧已经有了一套节点、边、画布的描述规范我们需要的不是再发明一套图的格式而是让编辑器直接消费已有格式。这一点在很多项目里被低估了等到做导出、做权限、做版本对比的时候你才会发现数据是自己的有多省事。2. 数据模型先行节点、边与坐标系的地基设计图表设计器容易翻车通常不是交互写崩了而是数据模型没搞对。我在diagram-design里用一套很朴素但很稳的结构核心就三个概念节点、连线、视口。2.1 一份能驾驭复杂图表的 DiagramModel 长什么样typescript export type AnchorPosition top | bottom | left | right | auto; export interface DiagramNode { id: string; type: string; // 节点类型对应渲染器注册表 x: number; // 画布坐标系非屏幕坐标 y: number; width: number; height: number; zIndex: number; attrs: Recordstring, unknown; // 业务透传数据渲染器自行消费 } export interface DiagramEdge { id: string; source: string; // 源节点 id target: string; // 目标节点 id sourceAnchor: AnchorPosition; targetAnchor: AnchorPosition; points?: [number, number][]; // 折线路径存 base64 字符串 label?: string; } export interface DiagramModel { nodes: Recordstring, DiagramNode; edges: Recordstring, DiagramEdge; viewport: { x: number; y: number; scale: number }; }这里有几个关键取舍值得说。节点用Recordstring, DiagramNode而不是数组是为了 O(1) 查找拖拽、连线时频繁通过 id 取节点用对象比find快一截。边上为什么要存锚点位置因为从哪个方向连出去、从哪个方向连进来决定了路由算法和箭头方向如果不存每次计算贝塞尔曲线时都得猜一旦节点位置变化路径就乱。attrs字段是业务数据透传的抽屉。比如一个审批节点attrs 里放审批人、审核规则、超时配置一个数据表节点attrs 里放字段列表。渲染器只负责画不负责理解业务含义这样图和业务解耦导出的时候也能完整还原。2.2 锚点、控制点与坐标系换算最容易犯错的三处细节第一个容易错的地方画布坐标系和屏幕坐标系混为一谈。拖拽时鼠标给的是屏幕坐标但节点位置是画布坐标。缩放过之后两者的换算关系是画布坐标 (屏幕坐标 - 视口偏移) / 缩放比例。我封装了一个工具函数所有交互层统一走它避免到处重复算typescript function screenToCanvas( clientX: number, clientY: number, viewport: { x: number; y: number; scale: number }, containerRect: DOMRect ) { const sx clientX - containerRect.left - viewport.x; const sy clientY - containerRect.top - viewport.y; return { x: sx / viewport.scale, y: sy / viewport.scale, }; }第二个容易错的地方锚点坐标计算。节点左上角是(x, y)左下角不是(x, y height)就完事了要考虑缩放、旋转如果支持旋转、以及节点可能有的 padding。我在实现里把锚点计算收敛成getAnchorPoint(node, anchor)一个方法内部统一消费节点的当前尺寸避免在多个渲染器里复制粘贴。第三个容易错的地方连线经过节点时路径遮挡关系。拖拽过程中边的端点要实时跟随节点边缘而不是节点中心。如果不处理你会看到一条线从节点肚子里穿过去。我的做法是每条边在渲染前调用一次router()根据锚点位置计算起点和终点然后才生成贝塞尔路径。3. 渲染层选型DOM节点 SVG连线并解释为什么不选纯Canvas3.1 三种渲染路线的实测结论图表渲染主流有三条路纯 DOM、SVG、Canvas。我最后选了DOM 节点 SVG 连线的混合方案。说一下为什么。单节点内部的内容通常很复杂表单、图标、状态色块、缩略图。用 SVG 或 Canvas 画这些等于自己实现一套 DOM 的布局和富文本能力成本极高。而且 DOM 天然支持事件绑定、CSS 样式、无障碍访问对业务开发最友好。所以节点用 DOM每一个节点头部是一个绝对定位的divtransform 负责位移。连线则是典型的路径密集型图形DOM 实现几乎不可行SVG 的path天然适合。边数在几百条量级的时候SVG 渲染完全没压力操作也方便。纯 Canvas 的优势在超大规模图上万节点里才体现出来但代价是交互命中检测、文字排版、事件分发全都要手写开发成本和维护成本都远高于前两种。对大多数业务图编辑场景来说混合方案是性价比最高的。3.2 缩放平移的正确实现避免文字模糊的关键一行代码缩放平移有三种做法改每个节点的width/height、改容器的transform: scale()、改容器位置加节点反向补偿。我在第一版用了容器整体缩放代码最省但缩放之后发现文字边缘开始发虚。原因很简单浏览器对transform: scale()的位图合成不总是做矢量重绘缩到 0.5 再拉回 1.5某些字体会出现锯齿。后来我改成容器只做平移节点的位置乘以scale并反向减掉偏移节点内部继续用transform: scale(1 / zoom)微调。这样 DOM 文字始终以接近原始分辨率渲染不会糊。typescript function applyViewport(model: DiagramModel) { const { viewport } model; // 容器平移 container.style.left ${viewport.x}px; container.style.top ${viewport.y}px; // 节点位置映射 反向缩放补偿 for (const nodeEl of nodeElements.values()) { const n model.nodes[nodeEl.dataset.id!]; nodeEl.style.left ${n.x * viewport.scale}px; nodeEl.style.top ${n.y * viewport.scale}px; nodeEl.style.transform scale(${1 / viewport.scale}); } }3.3 连线与箭头贝塞尔曲线的控制点计算边路径我默认用三次贝塞尔曲线控制点的水平偏移取两点纵向距离的一半这样出来的曲线在大部分情况下都自然。typescript function edgePath(sx: number, sy: number, tx: number, ty: number) { const dx Math.abs(tx - sx) / 2; return M ${sx} ${sy} C ${sx dx} ${sy}, ${tx - dx} ${ty}, ${tx} ${ty}; }如果起点在上方、终点在下方或者起点在左、终点在右控制点方向要做调整。我的 router 函数里会根据锚点方向调整偏移轴左右锚点用水平偏移上下锚点用垂直偏移最后用一个marker-end箭头标记。要注意的是SVG 箭头会根据 path 终点方向自动对齐但用markerUnitsuserSpaceOnUse时箭头大小不受缩放影响才不会出现放大后箭头大得离谱的问题。4. 交互层拆解从拖拽到框选到对齐辅助线图编辑器的交互层是最劝退的部分。用户觉得不就是拖一下吗真实现起来全是细节。我这边的落地顺序是拖拽、框选、对齐辅助线、快捷键。4.1 坐标转换与单次拖拽的更新合并拖拽节点看起来简单核心是两件事正确换算坐标避免高频更新 DOM。换算问题前面已经解决高频更新才是隐患——鼠标 move 事件一秒能触发几十次如果每次都同步改节点位置、重新计算所有相连边的路径低端机器上必然掉帧。我的做法是把数据即时变、DOM延迟变拆开。拖拽过程中节点数据在每一次 mousemove 里照常更新但 DOM 更新用一个requestAnimationFrame合并同一帧内的多次变更只触发一次重绘。typescript let rafId 0; const pendingNodes new Mapstring, DiagramNode(); function scheduleFlush() { if (rafId) return; rafId requestAnimationFrame(() { for (const [id, node] of pendingNodes) { syncNodeToDom(node); } pendingNodes.clear(); rafId 0; }); }这个优化在节点多的时候差距非常大。我在测试页里放了 300 个节点不加 rAF 拖动时能感觉到明显卡顿加上之后流畅度基本回到 60 帧水平。4.2 框选、多选与快捷键体系的落地框选的实现不复杂但有几个细节靠猜容易做错。按下鼠标左键时如果起点落在空白区域进入框选状态如果落在节点上进入拖拽状态。空白区域的定义是既没有命中节点也没有命中连线。框选矩形是一个独立的全屏 overlay div随着鼠标移动更新宽高。每一帧通过边界相交判断哪些节点被框住选中的节点加一个高亮样式。判断相交有一个边界情况用户从右往左拖、从小往大拖时框的宽高可能是负数所以判断前先规范化矩形的左上角和宽高typescript const left Math.min(startX, currentX); const top Math.min(startY, currentY); const right Math.max(startX, currentX); const bottom Math.max(startY, currentY);多选之后拖拽任意一个节点其余节点要跟着移动。这里有个引用还是复制的坑group 拖拽时如果每个节点各自存 delta那么多次拖拽后误差会累积导致选区内节点相对位置漂移。正确做法是在 dragstart 时记录所有选中节点的原始坐标之后每个节点统一用raw delta计算不做累加。快捷键体系我给了一个最小集合Delete 删除选中内容、Ctrl/Cmd C 复制、Ctrl/Cmd V 粘贴、Ctrl/Cmd Z/Y 撤销重做、方向键微调位置。这里最容易被忽略的是事件屏蔽输入框聚焦时不能触发 Delete 删除节点否则用户刚打完字想删几个字符结果把整块图删了。所以所有快捷键 handler 第一行都会检查document.activeElement是否为input/textarea/contenteditable。4.3 对齐辅助线让手动对齐变得像磁吸一样自然对齐辅助线是用了回不去的功能也是细节多得惊人的模块。我实现了一个基线吸附在拖拽或 resize 节点时扫描视口内的其他节点计算当前节点的左、中、右、上、中、下六条基线和其他节点的对应基线之间的差值如果小于 5px 就吸附过去同时在画布上画一条参考线。吸附逻辑本质上是把最近的一个满足阈值的差值找出来加到目标位置上。比如当前节点右侧right1另一个节点左侧left2差值d right1 - left2|d| 5时把节点向右移d像素。这里有个优先级问题六条基线会被分别尝试吸附时要选择位移最小并且不破坏已有对齐关系的那个。我的实现里是先把所有候选差值算出来排序优先命中绝对值最小的那个。参考线的绘制用 SVG一个全屏的虚线层所有参考线统一走这个层避免每个节点自己画线导致性能浪费。实测下来辅助线模块的代码量占了整个交互层的大约三分之一但它是用户感知最强烈的体验点之一值得投入。5. 序列化与导出JSON、PNG 与一个不依赖浏览器截图的导出器图编辑器的价值一半在能编辑另一半在能输出。输出分两种数据形态和图片形态。5.1 数据序列化版本号、增量与兼容性diagram-design的序列化直接输出DiagramModelJSON。但项目跑了一阵子之后我发现直接吐整个 JSON 有几个问题一是大图序列化慢每次操作都全量保存很浪费二是不同版本之间字段演进不兼容老数据打开就报错。我后来加了三个约定。第一JSON 里强制带version字段反序列化时走版本迁移管线每个迁移函数负责一个版本到下一个版本的字段转换。第二历史记录快照采用完整快照 操作增量混合撤销栈里每隔 N 步存一个完整快照其余存操作指令避免内存暴涨。第三所有节点 id 在上层要求必须是全局唯一且稳定的字符串业务侧保存时不会因为重新生成 id 导致引用关系断裂。版本迁移的代码结构大概是这样typescript const migrations: Recordnumber, (old: any) any { 1: (d) ({ ...d, viewport: d.viewport || { x: 0, y: 0, scale: 1 } }), 2: (d) ({ ...d, edges: normalizeEdges(d.edges) }), }; export function migrate(data: any): DiagramModel { const version data.version ?? 0; let cur data; for (let v version; v LATEST_VERSION; v) { cur migrations[v](cur); } return cur; }5.2 静态 SVG 导出器从数据模型直接出图导出图片的第一版图省事直接调浏览器的html-to-image类库截图 DOM。效果一言难尽跨域图片 CORS 报错、字体加载不完全、节点阴影丢失、截图边缘有白边。后来我放弃了截图思路改成维护一个静态 SVG 生成器——直接从数据模型生成 SVG 字符串不依赖浏览器实时渲染。SVG 生成器做的事情和 DOM 渲染器一模一样遍历节点生成g标签 矩形 文本遍历边生成path 箭头最后组合成一张svg。好处是可编程、可测试、可服务端生成还能直接转 PNG。SVG 转 PNG 我放在后端做或者在前端用 Canvas 绘制把 SVG 放进ImagedrawImage到 canvas再toDataURL(image/png)。这里有个大坑是 canvas 污染如果 SVG 里有外部图片必须处理跨域或者转 base64 内联。所以静态生成器里所有图片资源我都主动转成 base64否则导出会偶发空白。转 PNG 的核心 snippettypescript async function svgToPng(svgStr: string, width: number, height: number) { const blob new Blob([svgStr], { type: image/svgxml }); const url URL.createObjectURL(blob); const img new Image(); await new Promise((resolve, reject) { img.onload resolve; img.onerror reject; img.src url; }); const canvas document.createElement(canvas); canvas.width width * 2; // 2x 导出保证高清 canvas.height height * 2; const ctx canvas.getContext(2d)!; ctx.drawImage(img, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(url); return canvas.toDataURL(image/png); }导出这里是另一个安全细节用户输入的文字内容要转义防止有人通过节点文本注入script。我在生成 SVG 时统一对文本节点做了escapeHtml写图编辑器的都知道这一点不能省。6. 真实性能数据与几个值得提前踩的坑6.1 从 100 节点到 3000 边的性能测试我在测试页上压了三组数据直接说结论。规模渲染表现瓶颈点优化手段100 节点 200 边60 fps丝滑基本无瓶颈无需特殊处理500 节点 1000 边拖拽偶尔掉帧节点 DOM 多滚动时全量重绘视口裁剪只渲染屏幕内节点3000 节点 3000 边明显卡顿SVG path 过多拖动时全部重算连线层改离屏 Canvas节点继续 DOM第三档是我项目中实际踩到的规模。边超过 2000 条之后SVG list 的 DOM 节点太多拖动时浏览器要重新计算所有 path 的属性压力很大。我的解决方案是把 SVG 连线层换成一个独立的 Canvas 画布放在 DOM 节点层的下面拖拽时只重绘 Canvas 里受影响的边。这样 DOM 层只需要管节点Canvas 管线两个图形系统配合反而比单用 SVG 更灵活。视口裁剪的规则也很简单获取当前视口矩形遍历所有节点只把与视口相交的节点挂载到 DOM 树里其余节点留在缓存 Map 中。这个策略对滚动和缩放都有效因为缩放相当于视口范围变化。6.2 我自己踩过且不希望你们再踩的三个坑第一个坑拖拽时误触发文本选择。节点头部如果不加user-select: none鼠标拖拽时很容易选中节点里的文字视觉上就是一坨蓝色高亮很难看。我给整个画布容器设置了user-select: none只在可编辑输入区域放开。第二个坑撤销重做把拖拽过程切成几十步。用户拖一个节点拖了 3 秒mousemove 产生了 60 个中间态如果不合并撤销一次等于撤销一步微位移体验非常反直觉。我的做法是 dragstart 时记录一个操作快照dragend 时只把这个快照压入历史栈中间过程完全不入栈。连续拖完再撤销一步回到拖拽前符合直觉。第三个坑框选时按下 Delete不仅删节点还顺手把选中状态里的临时数据删坏了。原因是删除逻辑没有区分当前处于框选拖拽中和已完成选择。我在交互状态机里加了一个isSelecting标志框选未结束时无视所有快捷键只在 mouseup 完成后才进入可操作选中集合状态。这个状态机的写法看着啰嗦但它把交互行为彻底捋顺了。最后分享一个小技巧diagram-design的数据模型不只在编辑器内部用我还把它扩展成一门内部 DSL同一份 JSON 可以在文档系统里渲染成静态图、在接口文档里当拓扑图、在报表里当流程图。图的编辑和渲染分离之后一个模型多端消费变得非常自然。如果你公司里也有类似的图表展示需求强烈建议把数据结构当作公共资产来设计而不是只当一个编辑器内部格式。

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

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

免费获取报价 →
↑