☰
从零自研图形编辑器:diagram-design的架构设计与实践全记录
2026/10/21 18:52:26 网站建设 项目流程

说实话,把“diagram-design”这个项目做完以后,我有一个很深的体会:画图工具的难点从来不是“画出来的图形好不好看”,而是“从零到一把图形编辑器的底层逻辑理顺”。最开始做这个项目,是因为内部运维平台需要展示网络拓扑关系,业务方提了一个需求:能不能不靠截图,直接在系统里拖拽节点、连线、标注信息,然后一键导出给汇报用。我们调研了一圈现成的开源方案,发现要么太重、要么太封闭,于是干脆自己写了一个可嵌入的图表设计器,项目代号就叫 diagram-design。

这篇文章我把整个项目从需求拆解、技术选型、核心实现,到性能优化和真实踩坑记录都聊一遍。内容面向两类人:一类是打算自研画布类编辑器的前端开发,另一类是正在做低代码平台、可视化大屏选型的同学。你可以把这篇当成一份“可复用的实现笔记”,也能拿去做内部技术分享的素材。我会尽量用平常话讲,但涉及关键原理的地方不会省细节。

1. 项目立项:先想清楚要做的到底是什么

1.1 需求场景与核心痛点

在动手前,我们把常见的“图表设计”场景拉了一张表对比,想确认自己的边界到底在哪。这个步骤特别重要,因为它决定功能清单、技术选型和工作排期。

场景用户核心诉求典型痛点
架构组画系统部署图后端/运维负责人层级清晰、连线不交叉修改节点后连线就乱,重画成本高
业务方画流程图产品/运营快速拖拽、自动对齐对排版不敏感,但要能存能改能分享
运维画网络拓扑图值班工程师支持实时更新、实时导出节点多,最大可能有几百个设备

三条场景叠在一起,共性非常明显。用户需要的不是一个专业排版工具,也不是一个只能看不能改的静态图片工具,而是一个能嵌入业务系统、以数据驱动图形生成、支持交互编辑的“画布组件”。所以 diagram-design 的定位一开始就定了:不是独立产品,而是类似一个前端可视化组件,附带编辑能力。

1.2 技术选型:为什么选择 SVG 而不是 Canvas

图表设计器最核心的渲染技术路线,一般三条:纯 DOM、SVG、Canvas。我们最终选的是 SVG,理由有三点。

第一,图形编辑场景里,命中检测(点击选中元素、判断鼠标落在哪个节点上)是高频操作。SVG 天然支持事件代理和精确命中,图形本身就是 DOM 元素,不需要手动实现像素级别的碰撞检测。Canvas 要实现同样的功能,得额外做四叉树、空间索引或者遍历所有图形判断坐标,开发量不在一个量级。

第二,业务图表里节点数量通常在一个量级内。运维拓扑图极端情况能到四五百个节点,我们把每个节点拆成 group + 内部子元素,总 DOM 数大概在三千到五千个。这个数量对现代浏览器来说压力不大,SVG 完全扛得住。真正需要上 Canvas 或者 WebGL 的,是那种百万级数据的实时大屏监控,不属于设计编辑器的典型范围。

第三,导出和多端复用方便。SVG 可以直接序列化成文本,嵌入 HTML、转 PNG 或者再做二次标注都顺理成章。

不过 SVG 也不是没有坑,后面我会单独写性能优化的部分。在这里想提醒一句:技术选型不是选“最先进”的,而是选“最匹配场景”的。如果早知道要支持五百个节点同时对单个节点做高频动画,我可能就会考虑 Canvas 做渲染层,再用 SVG 做交互覆盖层。

1.3 整体架构与模块划分

diagram-design 的架构可以分成四个层次,每个层次各管一件事,之间通过消息和事件解耦。

数据层负责维护图表的数据模型,包括节点、边、画布属性等。这个层不存 DOM 引用,也不处理 UI,只提供增删改查和序列化能力。渲染层负责把数据模型实时转换成视图,包括网格背景、节点、连线、临时交互图形。交互层处理用户的鼠标和键盘事件,包括拖拽、框选、缩放平移、连线等。命令层则承担更上一层的职责,把交互结果原子化,并入撤销重做系统。

当时为了避免代码越写越乱,我们定了一个死规矩:渲染层不直接访问业务数据,交互层不直接修改业务数据,而是通过命令层中转。这样拆完以后,后续往里加自定义节点类型、批量操作、自动布局,都要轻松很多。

2. 核心功能拆解:把“画图”变成工程能力

2.1 数据模型:给图形一张稳定的“底稿”

图形编辑器最怕的事情是视图和业务强绑在一起。如果节点直接对应一个 React 组件,画一个节点就要塞一个组件实例,后续想要支持序列化、复制粘贴、撤销重做基本就没法做了。所以我们第一步是把“图”抽象成纯 JSON 数据。

基础模型分为三类:节点节点、边模型、画布模型。

节点模型的设计,我们没有用任何特殊方式,就是一个普通对象。每个节点包括 id、类型名、位置坐标、宽度高度、层级 zIndex,然后留一个 data 字段,给业务方塞自定义属性。连接端口(锚点)单独拆出来,也以数组形式挂在节点上,具体代码大概长这样:

{ "id": "node_001", "type": "server", "x": 120, "y": 80, "width": 180, "height": 80, "zIndex": 2, "ports": [ { "id": "p_in", "pos": "left", "offsetX": 0, "offsetY": 0.5 }, { "id": "p_out", "pos": "right", "offsetX": 1, "offsetY": 0.5 } ], "data": { "name": "支付网关", "status": "running" } }

边模型统一用 source 加 target 表达,source 和 target 后面直接跟端口 ID,而不是只跟节点 ID。这样做的好处是,连线位置不会因为节点移动而变乱,节点平移以后边会自动跟着锚点重新计算。

{ "id": "edge_001", "type": "bezier", "source": "node_001:p_out", "target": "node_002:p_in", "data": { "label": "HTTPS" } }

画布模型更简单,就保存缩放级别、平移偏移量、背景网格开关、画布宽高。导出和共享只需把这三类数据打包成一个 JSON,在别的编辑器实例里可以直接恢复成原图。这套数据模型,我后来用到了另一套大屏搭建项目里,几乎不需要改动,因为它做的不是图形存储,而是“业务实体 + 位置关系”的抽象。

2.2 拖拽与连线:交互设计的三个关键细节

交互层是编辑器体验最直观的部分,同时也是最容易出 bug 的地方。这里我挑三个关键细节展开聊。

第一个是拖拽节点的事件流。我们不建议直接用节点的原生 mousedown 去注册 mousemove 和 mouseup,因为一旦鼠标移出节点区域,甚至移出浏览器窗口,事件就会丢失。我当时采用的是在 document 级别代理拖拽事件。mousedown 发生时记录起始坐标,mousemove 时计算位移并更新节点,mouseup 时做一次数据提交。这样就算鼠标拖得再快、飞出画布,也不会出现节点停留在半路的状态。

移动过程中还涉及“坐标参照系”转换。假设用户在画布上拖拽了 10 个像素,但此时画布缩放比例是 125%,那么节点在数据层的真实位移就应该是 10 / 1.25 = 8 像素。忽略这个换算,是拖拽后节点错位的常见原因。

第二个是连线交互。连线不是简单地从鼠标 A 点画到鼠标 B 点。当用户从一个端口拖出连线时,编辑器需要实时寻找“目标锚点”。我做了这样一个规则:当前拖拽位置距离某个端口小于阈值(默认 12 像素)时,判断为吸附,并高亮端口;否则连线端点跟随鼠标。这个阈值不能太大,太大容易误吸附;也不能太小,太小用户操作成功率低。根据鼠标移动速度动态调整阈值,比固定值体验更好,但为了代码简单,上线初期先用固定值也是没问题的。

第三个是框选。框选看起来简单,实则有一个要命的问题:选区穿越节点边界时,到底按“完全包含”还是“部分相交”来计算?流程图场景中,用户框选通常期望部分相交就选中,因为节点往往连成片,完全包含的触发条件太苛刻。我们最终在选框的 mousemove 事件里,用矩形相交检测判断命中节点,同时按 zIndex 降序排列选中集合,保证层级高的节点优先被选中。

2.3 撤销重做:用命令模式把操作变成“可反悔的数据”

图形编辑器基本离不开撤销功能,不然用户一改错就烦躁。撤销重做系统容易做歪的地方在于:用户在操作十几个步骤之后,希望撤销的是“操作结果”,而不是“内存快照”。如果用快照实现,每一步都要深拷贝整个数据模型,五百个节点深度拷贝一次至少几十毫秒,撤销十几个步骤会让浏览器直接卡死。

我们的做法是:每个操作都改写成一条命令,命令里记录这次操作造成的差异,也就是旧值和新值。比如移动节点命令,会记录所有被移动节点的 id 和每个节点移动前后的坐标集合。执行命令时更新数据模型;撤销时把新的坐标替换回旧的坐标。连续操作比如拖动一组节点,mousemove 过程中不断更新预览,但只有 mouseup 那一下才生成一条命令压入栈,否则撤销一次只会回退几像素,毫无意义。

那种“所有命令都包含完整变更集”的设计会有一个副作用,就是某些高频操作可能导致命令对象变得很大。可实际上,一条命令记录几十个节点坐标,也就几个 KB 的内存,栈内保留五十条命令不过几百 KB,可以接受。真正要注意的是,命令对象必须可深拷贝,不要在里面塞 DOM 引用。

3. 性能优化实录:四五百个节点都不卡的排查之路

3.1 分层渲染:一次操作只动该动的那一层

SVG 的性能问题,绝大多数不是渲染慢,而是更新 DOM 的代价高。如果在一次交互里对坐标系、节点、连线、背景同时做位置刷新,即使只更新几十个节点,也会造成大量重排重绘。

我采用的方案是分层渲染。最底部放置背景 Grid 网格层,单独一个 SVG 画布,负责绘制网格点和浅色底。中间是主元素层,放置节点和连线。最上面是交互层,放置拖拽时的高亮框、临时连线、选中描边效果。

交互层只服务当前操作,平时清空或隐藏。拖拽一个节点的时候,节点在元素层做位置更新,但临时连线、吸附提示都画在交互层,这样既不污染主视图,又能保证响应速度。分层之后,可以针对不同层做不同的控制策略,比如背景层在平移缩放时可以用 CSS transform 整体位移,而不是逐格重绘。

3.2 批量更新与变更合并

操作过程中最容易忽略的性能细节是“频繁 setAttribute”。很多情况下,鼠标一移动就触发几十个节点的坐标刷新,如果即时更新每个节点位置,会制造大量微小的 DOM 修改,最终表现为拖拽有延迟感。

我的处理方式是:在拖拽的 mousemove 回调里,只修改数据模型,把“需要更新的节点 id 集合”暂存到一个队列中,然后通过 requestAnimationFrame 统一执行一次视图更新。换句话说,一帧内无论触发了多少次 mousemove,真正的 DOM 更新只发生一次。这是很经典的批量更新思想,但很多人实现时会漏掉“队列去重”,导致更新次数并没有真正减少。

还有个容易被忽略的小点:SVG 的 x、y 属性修改,和 transform 里的 translate 修改,性能差异不太明显,但更新稀疏节点时,把位移集中放在一个单独的 group 上,比分散改每个节点的坐标稳定。原因是 group 的 transform 是一条独立的合成器路径,不容易引发整棵子树的重排。

3.3 视口变换:从全局坐标到屏幕坐标的换算

画布编辑器一定要支持缩放和平移,否则节点一多,浏览器窗口显示不下,体验直接归零。我实现的方法是:主视图组 viewport 套了一个<g></g>,把缩放比例和平移参数应用在这个 group 上。这样不需要为每个节点单独计算缩放后的坐标,所有内部坐标都存“世界坐标”,只有渲染时才套一层视口矩阵。

麻烦的地方在于事件坐标的逆推。比如用户在屏幕上点击了一个点,需要知道点击位置对应的是世界坐标里的什么位置。如果缩放倍数是 scale,平移偏移量是 offsetX、offsetY,那么屏幕坐标转世界坐标的公式是:

worldX = (screenX - offsetX) / scale worldY = (screenY - offsetY) / scale

反向,将世界坐标转成屏幕坐标则反过来乘一下。这个公式看起来很简单,但很多人会写反。我见过不少图表库,在初始化时会设置 viewport 的 transform 和事件坐标转换,结果拖拽的时候节点总是偏移,一检查全是坐标系没统一。

另外,浏览器窗口 resize 和容器尺寸变化后,必须重新计算可视区域,否则右侧和下侧会出现“看不见但实际存在”的空白区域。我们给画布注册了 ResizeObserver,尺寸变化时自动调整视口范围,保证画布永远铺满容器。

4. 扩展与工程化:不只是“能画图”的演示 Demo

4.1 自定义节点:用注册表取代 if-else 分支

图表的业务属性千差万别。运维平台可能要有圆形的服务器节点,流程引擎要有带泳道描述的步骤节点,数据库设计工具要有表格形态的节点。如果编辑器里每种节点都写进核心判断逻辑,代码会迅速膨胀,改一个节点类型还要担心影响主链路。

所以我把节点类型做成可注册的插件。核心框架只提供基本矩形节点、圆形节点、文本节点三类内置类型,其他类型通过 registerNodeType 方法注入。每个类型只需要实现两个方法:render 负责将数据模型渲染成 SVG 元素,update 负责在数据变化时同步视图。

registerNodeType('database', { getShape(node) { return `<g class="node-database">const editor = new DiagramDesign({ container: document.getElementById('app'), readonly: false, onChange(data) { // 实时同步到业务系统 } }); editor.setData(savedJson); editor.exportPNG().then(url => { // 下载或预览 });

多实例管理是容易踩坑的。因为编辑器内部包含大量全局状态,比如当前选中节点、剪贴板、缩放比例,不同实例之间如果共用同一套状态对象,会出现“在 A 画布里选中节点,切到 B 画布发现也有选中高亮”的问题。我们的做法是创建一个工厂函数,每次创建实例时都 new 一套独立状态栈,彻底隔离实例之间的数据。

5. 实践中遇到的典型问题与排查技巧

5.1 拖拽坐标错位:看起来简单,根因却藏在滚动容器里

第一次把 diagram-design 嵌入某个管理后台时,出现了一个诡异现象:拖到一半节点就飞掉了。排了好久,最后发现根因是宿主管项目的容器出现了滚动条,而我们的拖拽事件直接用鼠标的 clientX/clientY 计算坐标,没有减去父容器的滚动偏移。

SVG 里有一个 getScreenCTM 方法可以用来获取基于屏幕的坐标变换矩阵,但比它更常用的还是 getBoundingClientRect,计算鼠标相对于画布容器的位移。修复方式很简单,在 mousemove 中取坐标差的时候,把 scrollLeft 和 scrollTop 减掉。问题不大,但特别隐蔽,建议一开始就统一封装一个 getPosition 函数。

5.2 SVG 边框阴影渲染不一致:跨浏览器体验差异

同样是写一条 CSS 规则,在 Chrome 里给 SVG 节点加 box-shadow,效果基本正常,但在另一个内核的浏览器里,要么不生效,要么阴影被裁切。后来我们改用 SVG 自己的 filter 元素来实现阴影效果,虽然代码量更大,但渲染一致性明显好了很多,导出图片也不会出现阴影缺失。

5.3 撤销栈无限增长:内存占用悄悄变大

有段时间测试反馈,编辑器用久了会越来越卡,打开任务管理器一看内存占用高达几百兆。排查后发现,每次操作生成命令之外,我还额外把整份数据模型加入历史记录作为快照备份,导致内存增长翻倍。

后来调整策略:历史记录里只存命令描述和差异数据,不再存完整模型。同时给撤销栈设了一个上限,超过五十条就丢掉最早的记录。用户一般只在意能撤销最近几步,不需要无限回退。真需要深度回退,那就用“恢复到指定版本”的强制快照功能,而不是靠撤销栈。

还有一个相关小坑:命令对象里不要直接引用外部数组。如果 push 进撤销栈的是一个包含引用的数组,后续数据一旦被修改,历史记录里的值也会跟着变,撤销结果就不对了。我的做法是入栈前深拷贝一次命令数据,序列化步骤的开销虽然存在,但换来的是撤销可靠性,值。

5.4 自动布局:手工连线永远比不上算法辅助

我在使用过程中发现,拓扑图和思维导图的用户对布局要求依然很高。虽然编辑器支持用户手拖节点,但散乱的手动排版很难达到专业效果。所以后来我们内置了简单的树形布局和力导向布局能力,不追求专业布局引擎的效果,只求一键整理时不会交叉得太离谱。

自动布局最常见的坑是循环依赖的应用场景。树形布局遇到环就会死循环,处理方式是先做一次拓扑排序,把环内节点标记后单独处理。布局算法不追求花哨,能用起来最重要,不要为一个演示效果消耗过多迭代时间。

写在最后

diagram-design 这个项目做下来,我最大的收获不是技术,而是认清了一个道理:图形编辑器这类项目,表面上是拼各种华丽特效,实质拼的是数据模型设计得够不够稳、交互链路顺不顺、边界情况处理得够不够兜底。好的图表设计器,用户感觉不到“底层存在”,一切操作都是直觉式的;而底层一旦设计得糟糕,所有上层功能都会像盖在沙地上的楼,随时倒。

如果后面我再做类似的画布类编辑器,第一件事会把“事件代理 + 图层隔离 + 命令模式”三位一体的架构直接复用起来,这套模型在这类项目里足够通用,也能省下大量重构时间。如果你也是做嵌入式可视化组件的,建议从 SVG 分层渲染入手,先把最基础的拖拽与连线打磨顺畅,再往后扩展自定义节点、协作、自动布局,这样项目节奏会比较稳。希望这篇记录能给你一些可以直接落地的思路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询