在可视化需求越来越密集的开发环境里,我做了一个叫diagram-design的项目。它不是一个简单的绘图 Demo,而是一套偏底层的图表设计引擎,核心解决的是“如何在网页里快速搭建可拖拽、可连线、可缩放的图形编辑器”这件事。
先说一下背景:我手头有几个业务系统,都需要在网页里完成流程图、架构图、UML 图的绘制,比如运维同学画网络拓扑、产品同学画功能蓝图、开发同学画时序图。过去每次都是临时找现成的图库或者图表库,一顿集成下来,要么交互不够灵活,要么样式自由度太低,要么数据格式被绑死,改起来很痛苦。与其反复造轮子,不如老老实实沉淀一个自己的核心模块。于是就有了 diagram-design 这个项目。
这个项目教会我的核心一件事:图表编辑器的复杂度,70% 都在数据模型和渲染引擎的配合上,而不只是画几个矩形和箭头。你画出来的每一个节点、每一条连线,背后都需要一套稳定的数据结构和事件机制去支撑。这篇文章会完整拆解我在这个项目里踩过的坑、验证过的方案、以及核心实现细节,希望能帮你绕开那些弯路。
1. 项目整体设计与核心场景分析
1.1 这个项目到底在解决什么问题
先说场景。很多人觉得“画图”很简单,无非是一堆 SVG 节点加上鼠标事件。但真实业务里的图表编辑器,往往要同时满足几个硬性条件:
- 节点类型高度自定义:不同行业的图,节点长得完全不一样,有的要带图标、有的要带表格、有的需要内嵌表单。
- 连线逻辑复杂:不仅仅是点和点连起来,还要区分方向、线型、锚点位置,以及连线的避让和路由。
- 画布交互要求高:拖拽、缩放、框选、对齐辅助线、撤销重做,这些几乎是标配。
- 数据必须可序列化:图表最终要保存到后端、要能被解析回放,所以数据模型和视图必须解耦。
diagram-design 从立项第一周就明确了三个设计目标:一是把数据模型与渲染细节彻底分离,二是所有交互都走统一的事件总线,三是对外暴露的 API 尽量精简,让使用方不需要了解内部布局算法。
1.2 技术方案选型:我为什么没有直接用现成库
选型阶段是反复过度的,最终结论是用 SVG 作为渲染基础,自己实现交互层,自建轻量数据模型,并不引入庞大的图形库。
| 方案 | 优点 | 缺点 | 我的评价 |
|---|---|---|---|
| 纯 Canvas 手绘 | 性能上限高、灵活度大 | 事件命中检测繁琐、文本排版成本高 | 适合架构师用,但开发效率低 |
| 第三方库(如 AntV X6、LogicFlow) | 功能全、开箱即用 | 定制受限、依赖沉重、样式和数据格式绑定 | 原型验证用很香,长期维护不如自研 |
| SVG + 自定义交互 | 清晰度高、DOM 事件友好、样式自由 | 超大图性能不如 Canvas | 配合分片渲染足够应付 90% 业务 |
我之所以放弃现成库,不是说它们不好,而是因为业务方对节点样式的定制需求非常重度,几乎每周都会出现一种全新的节点类型。在第三方库的基础上做扩展,文档和源码的阅读成本很高。而在 SVG 之上自建架构,虽然前期多花了两三周,但后续扩展时极其顺畅。
1.3 架构分层:核心思想就四个字“渲染隔离”
diagram-design 的源码结构非常清晰,严格分为四层:
- 数据层:负责节点、连线、画布数据的定义、校验、序列化与反序列化。
- 逻辑层:负责所有状态变更,包括添加节点、删除节点、更新连线、移动坐标等。
- 渲染层:负责把数据层的数据映射成 SVG 元素,只做展示,不做业务判断。
- 交互层:负责监听鼠标事件、触摸事件,将用户操作转换为逻辑层的指令。
这个分层最关键的地方在渲染层和逻辑层之间。早期我试过让渲染层直接“改数据”,结果节点坐标和 DOM 位置经常对不上,排查问题要同时看渲染逻辑和状态逻辑,非常痛苦。后来强制规定:渲染层永远不直接修改业务数据,所有变更必须调用逻辑层的 action。这个约定坚持下来之后,整个项目的稳定性提升了一个数量级。
2. 核心数据模型与状态管理设计
2.1 节点数据结构的“一次到位”设计
节点是图表里最基础的元素,它的数据结构直接影响后续所有功能的复杂度。我沉淀后的节点数据结构大致是这样:
interface DiagramNode { id: string type: string x: number y: number width: number height: number zIndex: number rotation: number properties: Record<string, unknown> ports: Port[] metadata?: { createdBy: string updatedAt: number } } interface Port { id: string name: string position: 'top' | 'bottom' | 'left' | 'right' offset?: number type: 'input' | 'output' | 'both' }这里面有几个细节值得展开讲:
第一,为什么必须有 ports(连接点)。最早期我把连线的锚点直接绑定在节点的中心点上,简单是简单,但一旦连线多了,全从中心点出发,线条会互相叠压在一起,视觉效果非常差。后来引入 ports 的概念,每条连线明确指定连接的是节点的哪个端口,这样线段就有了确定的起止位置,也为后续做连线避让算法提供了基础。
第二,properties 用对象而不是硬编码字段。不同节点类型的属性差异太大,如果在数据结构层面把每种属性都写死,那每次新增类型都要改数据接口,并且旧的序列化数据会全面失效。用一个泛型的 properties 对象来承载,配合 Schema 描述,既灵活又可以做校验。
第三,metadata 只是辅助。真正需要的信息尽量放在 properties 里,metadata 不参与渲染也不参与业务逻辑,只供审计使用,避免让核心数据结构变得越来越臃肿。
2.2 连线的数据模型与锚点计算
连线比节点复杂,因为它既要表达拓扑关系,又要表达几何路径。我用的连线模型如下:
interface DiagramEdge { id: string sourceNodeId: string sourcePortId: string targetNodeId: string targetPortId: string type: 'straight' | 'bezier' | 'orthogonal' points?: Point[] label?: string properties: Record<string, unknown> }可以看到,连线并不直接存储“起终点坐标”,而是存储“起终点对应的节点与端口”。真正画线的时候,会根据端口的位置和节点坐标实时计算锚点坐标。这样做有一个巨大的好处:当节点被拖动时,连线会自动跟随,不用执行任何手动平移逻辑。
锚点计算的核心逻辑是以端口布局配置为基础:拿到节点坐标后,再根据 port 的 position 和 offset 计算绝对坐标。这个逻辑不复杂,但很容易出错的地方是旋转角度。当节点有 rotation 属性时,锚点位置必须乘上旋转矩阵,否则节点转了,线头还留在原位置,看起来就像断了一样。
2.3 状态管理:从“事件满天飞”到“Action 驱动”
图表编辑器的状态管理,最容易走偏的方向是把状态拆得太散。我一开始用的是组件内部各自维护状态,结果节点列表、画布缩放、选中态、连线草稿态,各自都有自己的一份数据,相互同步时全靠传参和回调,改到第三轮就快崩溃了。
后来痛定思痛,收敛成了三个共享 Store:
- nodeStore:节点数组 + 选中节点 ID
- edgeStore:连线数组 + 当前连线草稿
- canvasStore:缩放比例、平移偏移量、画布尺寸、历史记录栈
所有对 Store 的修改,统一走 action 函数。使用者永远不需要直接操作数组,而是调用addNode、deleteNode、moveNodes这些语义化方法。这个设计带来的直接收益是撤销重做特别好实现:只要每个 action 都返回一个反向 action,然后把 action 序列压栈即可。
2.4 序列化与反序列化:数据格式的“可迁移”设计
我专门花了一周时间设计了序列化格式的兼容方案。核心思路是:每个序列化对象都带一个 version 字段,反序列化时根据 version 做迁移升级。
{ "version": 2, "canvas": { "zoom": 1.2, "offsetX": 100, "offsetY": 200 }, "nodes": [...], "edges": [...] }这样一来,即使将来数据结构大改,老数据也可以通过迁移函数升级到新版本,不会出现“老图打不开”的尴尬。这一点作为长期维护项目几乎是必须的,因为业务数据远比代码活得久。
3. 渲染引擎实现与核心交互拆解
3.1 为什么选择 SVG 渲染而不是 HTML 渲染
市面上不少编辑器喜欢用 HTML + CSS 来渲染节点,理由是好排版。但我在实践中坚决选择了 SVG,原因主要有三个:
- SVG 原生支持坐标系统,viewBox 的存在让画布缩放、平移变得极其自然,不需要自己去算屏幕坐标和逻辑坐标的映射。
- SVG 与数据是一一对应的,每个节点可以对应一个
<g>元素,调试时直接在浏览器开发者工具里定位到对应结构,非常直观。 - SVG 的事件机制与 DOM 一致,
addEventListener、事件冒泡、>function screenToLogical(screenX: number, screenY: number) { return { x: (screenX - canvasStore.offsetX) / canvasStore.zoom, y: (screenY - canvasStore.offsetY) / canvasStore.zoom } }这个函数的逻辑极其简单,但凡是忘了在拖拽或缩放时任一步使用坐标转换,就会出现节点“越拖越偏”或者“闪烁跳动”的问题。我建议你在任何涉及鼠标坐标的地方,第一时间就调用这个转换,不要等到出 Bug 了才想起来。
拖拽过程中还需要注意“光标样式切换”和“多选节点同步移动”。多选移动的实现方式是:记录所有选中节点的原始坐标数组,鼠标移动时给每个节点统一加上 delta,而不是逐个重新计算。
3.3 连线交互:临时连线的渲染方案
连线交互是最容易写出脏代码的地方。用户需要用鼠标从一个端口拖到另一个端口,在拖拽过程中要实时画出一条“临时连线”。我第一次实现时,临时连线是直接新建一个 edgeStore 里的对象,拖完后还要记得清理。结果经常出现状态残留,比如取消拖拽后,临时连线没被删干净。
后来我把临时连线从正式的 edge 数据结构中拆出来,单独用一个
draftEdge状态管理,它只存在于交互层,不参与数据层的序列化和校验。拖拽正常结束时,才把 draftEdge 转成正式 Edge 并提交到 edgeStore。这样做让数据层始终是干净的,错误率大幅下降。3.4 缩放与平移:viewBox 还是 transform
画布缩放和平移的实现,有人用 viewBox 做缩放,有人用整个画布根节点的 transform,我采用的是后者。因为 viewBox 是整块重算的,当画布非常大时,它会让浏览器重新计算所有元素的坐标系,消耗明显。
采用
transform: scale(zoom) translate(offsetX, offsetY)的方式,好处是性能可控,坏处是要注意** transform 的书写顺序**。SVG 的 transform 是从右往左应用的,所以平移和缩放的次序不能搞反。我的经验是:先最后写缩放,再在前面写平移,然后用浏览器调试器直观验证,不要凭感觉。3.5 选中态与多选框:不能只靠样式撑
选中态的实现,看起来只是给节点加一个视觉框,但交互细节很讲究:
- 点击空白处:清空所有选中。
- 点击节点:如果按住 Shift 或 Cmd,则加入选中或取消选中;否则替换选中为当前节点。
- 拖拽空白处:出现矩形多选框,框内节点全部选中。
多选框的渲染要用
pointer-events: none,否则它会阻挡鼠标事件穿透到画布上。另外,多选框的命中检测不是看节点中心点,而是看节点的包围盒是否与选择框相交,四舍五入会漏掉边缘节点,做的时候要注意用“相交”而不是“完全包含”。4. 工具选型、代码结构与关键实现细节
4.1 技术栈与工程配置一览
diagram-design 的技术栈非常主流,后端同学也能快速上手:
模块 选型 理由 框架 React 18 + TypeScript 生态成熟、类型安全、团队熟悉 构建 Vite 开发调试启动快、HMR 稳定 样式 CSS Variables + 局部样式 主题换肤简单,不引入 CSS-in-JS 状态 自研轻量 Store + useSyncExternalStore 避免引入 Redux 的样板代码 测试 Vitest + Testing Library 与 Vite 同生态,配置成本低 TypeScript 在这个项目里不是可选,而是必要。图表编辑器涉及大量复杂的数据结构和函数签名,如果没有类型约束,改动一个字段要在五六处同步,极易出错。
4.2 插件化架构:如何做到新增节点类型不改核心代码
我将节点类型的渲染完全独立成“插件”。新增一种业务节点,只需要注册一个渲染组件和一个属性面板组件,核心引擎完全不动。
registerNodeType({ type: 'decision', component: DecisionNodeComponent, propertyPanel: DecisionNodePropertyPanel, schema: DecisionNodeSchema })这一步是整个架构里收益最高的投入。业务方每次提新的节点需求,我基本只需要写一个组件,不用摸引擎代码。这让我从“每次改功能如履薄冰”变成“轻松接需求”。
4.3 撤销重做的实现:不用 Redux 也能做历史记录
撤销重做很多人觉得难,其实核心就是一个命令栈。我的实现方式是:在 store 里维护一个 history 数组,每次执行 action 前,先把当前状态快照压栈,执行之后又压一个“重做快照”。撤销时弹出快照并恢复。
快照恢复要注意 deep clone 的问题,直接存引用会让后续修改同时影响历史记录。我用的是结构化克隆,虽然牺牲了一点性能,但状态正确性优先。
4.4 辅助线:为对齐体验增加那一层质感
辅助线这个功能看起来简单,做起来烦。它的本质是:在拖拽节点时,实时计算节点边缘与画布上其他节点边缘的差值,如果差值小于阈值(比如 5px),就自动吸附并显示一条参考线。
关键点位有四个:节点左边缘、水平中轴线、右边缘、上边缘、垂直中轴线、下边缘。每移动一次,都要遍历当前可见区域的所有节点,做一次距离比较。图一多,这个遍历会成为性能瓶颈,所以需要做空间索引,只计算当前视口内且距离相近的节点,大幅减少无效计算。
5. 常见问题与实战排查记录
5.1 问题一:SVG 事件冒泡导致的误拖拽
现象:在节点内部点击文本框并拖动,整个节点也跟着移动,而且拖动线条时偶尔会错误地触发背景画布的平移。
原因:事件冒泡让
mousedown同时触发了节点拖拽逻辑和画布平移逻辑。解决:在节点内部的文本、图片等子元素上调用
event.stopPropagation(),同时在绑定的指针事件里判断事件目标是否带有>
edasDome实战:离线跑通微服务治理,从注册发现到链路追踪
简介:一份面向Java开发者的阿里EDAS微服务示例工程,围绕在线汽车业务场景演示如何将单体应用拆分为独立服务,并借助EDAS完成应用托管、服务治理与发布流程,适合初探微服务架构的团队或中级开发者参考。压缩包共15个文件࿰…
Java应用接入本地大模型:VLLM+langchain4j实现工具调用
先说下我为什么折腾这个事。团队里有个 Java 后端项目,需要接入大模型做智能对话和自动化操作,但业务数据全部要求留在内网,云端 API 这条路直接堵死。我们就在内网 GPU 机器上部署了本地推理服务,模型推理用的是 VLLM,…
diagram-design:把架构图当作代码管理的工程化绘图工具
很多人刚开始接触“diagram-design”这个名字时,第一反应可能是:这不又是一个画图工具吗?市面上的流程图工具、架构图工具、在线白板多得数不过来,为什么还要自己折腾一个?但真正在技术团队里待过几年、被各种制图协作…
XblGameSaveTask.exe是什么?别乱下载!系统组件识别与修复全攻略
先说结论:XblGameSaveTask.exe 不是一款需要单独下载的第三方软件,它是 Windows 系统里游戏存档同步服务的一部分。前段时间有朋友拿着一张截图来找我,说启动器提示找不到 XblGameSaveTask.exe,网上搜了一圈全是“高速下载”链接&…
Android GPS底层驱动开发:HAL、JNI与串口NMEA解析实战
简介:围绕GPS Android底层驱动的中文资料包,面向Android系统开发者与驱动移植工程师,系统讲解JNI与HAL层的协同原理,并附关键源码与移植分析笔记。压缩包共5个文件,大小约9.28MB,内含2份Word讲解文档、1个R…
artcraft 工作流实战:从素材到成品的批量处理与配置复用
1. 从“artcraft”这个名字说起:它到底想解决什么问题第一次看到“artcraft”这个词,我脑子里蹦出来的不是某个具体工具,而是一种很朴素的组合感——art(艺术)加上 craft(手艺、工艺)。这两个词…