ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

有段时间我一直在折腾一个叫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: Record<string, 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: Record<string, DiagramNode>; edges: Record<string, DiagramEdge>; viewport: { x: number; y: number; scale: number }; }

这里有几个关键取舍值得说。节点用Record<string, 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连线,并解释为什么不选纯Canvas

3.1 三种渲染路线的实测结论

图表渲染主流有三条路:纯 DOM、SVG、Canvas。我最后选了"DOM 节点 + SVG 连线"的混合方案。说一下为什么。

单节点内部的内容通常很复杂:表单、图标、状态色块、缩略图。用 SVG 或 Canvas 画这些,等于自己实现一套 DOM 的布局和富文本能力,成本极高。而且 DOM 天然支持事件绑定、CSS 样式、无障碍访问,对业务开发最友好。所以节点用 DOM,每一个节点头部是一个绝对定位的div,transform 负责位移。

连线则是典型的"路径密集型"图形,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 终点方向自动对齐,但用markerUnits="userSpaceOnUse"时箭头大小不受缩放影响,才不会出现放大后箭头大得离谱的问题。

4. 交互层拆解:从拖拽到框选到对齐辅助线

图编辑器的交互层是最劝退的部分。用户觉得"不就是拖一下吗",真实现起来全是细节。我这边的落地顺序是:拖拽、框选、对齐辅助线、快捷键。

4.1 坐标转换与单次拖拽的更新合并

拖拽节点看起来简单,核心是两件事:正确换算坐标,避免高频更新 DOM。换算问题前面已经解决,高频更新才是隐患——鼠标 move 事件一秒能触发几十次,如果每次都同步改节点位置、重新计算所有相连边的路径,低端机器上必然掉帧。

我的做法是把"数据即时变、DOM延迟变"拆开。拖拽过程中,节点数据在每一次 mousemove 里照常更新,但 DOM 更新用一个requestAnimationFrame合并,同一帧内的多次变更只触发一次重绘。

typescript let rafId = 0; const pendingNodes = new Map<string, 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: Record<number, (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 放进Image,drawImage到 canvas,再toDataURL("image/png")。这里有个大坑是 canvas 污染:如果 SVG 里有外部图片,必须处理跨域或者转 base64 内联。所以静态生成器里所有图片资源我都主动转成 base64,否则导出会偶发空白。

转 PNG 的核心 snippet:

typescript async function svgToPng(svgStr: string, width: number, height: number) { const blob = new Blob([svgStr], { type: "image/svg+xml" }); 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 可以在文档系统里渲染成静态图、在接口文档里当拓扑图、在报表里当流程图。图的编辑和渲染分离之后,一个模型多端消费变得非常自然。如果你公司里也有类似的图表展示需求,强烈建议把数据结构当作公共资产来设计,而不是只当一个"编辑器内部格式"。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进