diagram-design 这个项目,源头是一个很现实的问题:我们内部的流程文档、系统拓扑图、架构说明图越来越多,静态图改一次要重新画、重新截图、重新上传,几乎没人愿意维护。团队里也不是没试过现成的绘图工具,但接入之后总差那么一口气。后来我干脆自己写了一个浏览器端的图表设计内核,起名就叫 diagram-design,这才算把“图画完还能继续改、改完还能导出、导出后还能再编辑”这条链路彻底打通。
这个项目做下来,最大的感受是:市面上不缺画图工具,缺的是一个数据模型清晰、交互可预期、渲染性能可控的轻量图表设计器。diagram-design 解决的核心问题就是三件事:怎么组织图的数据,怎么让节点和连线编辑起来顺手,怎么在节点数量上来之后还不卡。适合谁参考?前端工程师、要做内部可视化平台或低代码搭建器的团队,以及所有被“改图改到崩溃”折磨过的人。
1. 为什么我会自己写一个 diagram-design:需求边界与选型取舍
1.1 现成方案看着能用,但接入就卡住
先交代一下背景。当时我们试过几类方案,每类都有各自的脾气。
第一类是白板类的在线绘图产品,功能非常全,各种模板都有。但问题也很明显:它是独立系统,我要把图嵌到我们自己的业务页面里,得走 iframe,数据拿不出来,图表里的节点也没法和我们的业务对象绑定。图画得再漂亮,和系统数据是断开的,换个环境就得复制粘贴,等于白画。
第二类是一些开源流程图库。这类库交互逻辑完整,比如拖节点、拉连线、框选、缩放大盘都帮你做好了。真正去接的时候会发现,它的数据模型往往是“黑盒”,节点样式、端口位置、连线路由这些关键行为,都被封装在内部。我想自定义节点外观、想在连线上挂业务字段、想让某些连线走特定颜色,就要去读源码、覆盖内部方法,每次升级都头疼。
第三类是以代码生成图表的方案。这个思路适合交付,写一段结构化文本就能生成一张图,版本管理也方便。但它天然不适合交互编辑,用户拖一下节点,位置数据往哪里回写?这是个问题。我们需要的恰恰是“人能上手拖”的编辑器,而不是“人写代码生成图”的编译器。
所以当时的结论是:成熟方案可以抄思路,但不能直接抄作业。我需要一套自己的、完全掌控数据流和渲染流的图表设计内核。
1.2 先划边界,再谈功能
diagram-design 从一开始就没打算做成通用图编辑平台。那种“什么图都能画、什么样式都能调、什么插件都能装”的想法,对一个小团队来说就是个无底洞。我们明确砍掉了三块:自动布局算法全家桶、复杂流程图校验引擎、多人实时协同。
这三块不是不重要,而是它们各自都能独立成为大项目。自动布局涉及分层、布线、力导向,随便一个算法调试起来都是以周为单位;流程图校验要懂 BPMN 语义,那是另一个领域的知识;实时协同背后的 CRDT 冲突合并足够写几篇论文。
所以 diagram-design 的核心边界定得很窄:画布、节点、锚点、连线、框选、缩放、平移、撤销重做、导入导出。听起来很少,但把这些做扎实了,一个内部流程图工具 80% 的日常需求都能覆盖。剩下的 20% 需求,通过暴露钩子和自定义渲染函数解决,而不是内置到核心里。
1.3 技术选型:数据驱动加 DOM/SVG 双渲染
选型的时候,我先把渲染方案过了一遍。当时有三个选项:全 Canvas、全 SVG、DOM 加 SVG 混合。
全 Canvas 的好处是性能上限高,几千个节点也扛得住;缺点是命中检测、事件分发、文本输入全都要自己实现,而且节点里想嵌一个复杂的弹层交互,Canvas 会让人抓狂。
全 SVG 的好处是矢量图形天然支持事件,缩放不失真;缺点是大量 DOM 节点在部分浏览器下性能衰减明显,尤其是连线多、节点多的时候,整个 SVG 树会变得非常庞大。
我最后选了 DOM 加 SVG 混合:节点用 HTML 元素定位渲染,连线用 SVG path 绘制。这样节点内部可以放输入框、下拉框、甚至任意 React 组件,连线则利用 SVG 的 path 语法和矢量缩放特性。两个渲染层通过同一条数据模型驱动,互不干扰。
这个选择的理由,用一句话说:节点是“重交互对象”,连线是“轻几何对象”,它俩的渲染需求天生不一样,硬塞进同一个方案里反而别扭。
2. 数据模型是根,图元关系先得说清楚
2.1 一张图,就是一份可序列化的 JSON
我见过很多图表工具项目死在数据模型没设计好。节点和连线的关系里掺杂了坐标缓存、临时选中态、拖拽偏移量,最后导出的时候一堆脏数据,撤销重做更是无从下手。
diagram-design 的数据模型非常朴素,一张图就是一个 JSON 对象,顶层只有四个字段:nodes、edges、viewport、meta。nodes 和 edges 是图的实体,viewport 记录画布当前的缩放比例和平移偏移,meta 放标题、创建时间、自定义业务字段。没有任何额外的“运行时字段”混在这里面。
这样做的好处,第一是序列化直接 toString,导入直接 parse,不用做任何数据清洗;第二是撤销重做可以退化成“保存历史 JSON 快照”;第三是后续如果要接后端、做多人协作,走的还是这一套结构,不需要额外写协议。
2.2 节点、锚点、连线、画布四个核心对象
节点(node)是最基本的图元,它的结构长这样:
{ "id": "node_1024", "type": "default", "x": 320, "y": 180, "width": 160, "height": 72, "data": { "title": "订单服务", "status": "running" }, "ports": [ { "id": "port_in_left", "side": "left", "align": 0.5 }, { "id": "port_out_right", "side": "right", "align": 0.5 } ] }注意 ports 数组。每个节点不是“一个矩形框”,而是“矩形框上加了一组锚点”。连线不允许随便连在节点任意位置,而是必须连在某个 port 上。这个设计是我在踩过坑之后才加进去的,后面会单独讲为什么。
连线(edge)的结构也很直接:
{ "id": "edge_3001", "source": { "nodeId": "node_1024", "portId": "port_out_right" }, "target": { "nodeId": "node_2048", "portId": "port_in_left" }, "router": "orthogonal", "data": { "label": "调用" } }画布(viewport)是一个对象:
{ "scale": 1, "tx": 0, "ty": 0 }所有坐标都保存为“世界坐标”,也就是未经过缩放和平移的原始坐标。渲染的时候再通过 viewport 换算成屏幕坐标。节点在拖拽过程中修改的永远是 x 和 y,而不是 transform。这种约定让缩放、居中、导出都变得非常简单。
2.3 为什么视图状态必须与数据模型分离
这是 diagram-design 里我在文档里反复强调的一条原则:凡是能重新计算出来的,就不要存到数据模型里。
最常见的反例是把“当前选中的节点 id”存到 nodes 数组旁边。看起来没什么,但一旦你把整张图导出给后端或协同端,选中态就会变成一串无意义的脏字段,别人拿到手还得猜这个 id 是否有效。
diagram-design 的处理方式是在内存里维护一个独立的 UI 状态对象,里面放当前选中节点、正在被拖拽的连线、鼠标悬停的 port、当前正在编辑的文本节点。这些状态只存在运行时,导出和持久化的时候从数据模型中剥离。
视图状态分离还有一个好处:可以稳定地做“只更新指定区域”的局部渲染。数据模型是纯粹的,视图状态是临时的,两边通过节点 id 和 edge id 关联,任何一方变化都不会污染另一方。撤销重做也因此简单,只需要快照数据模型,不需要快照 UI 状态。
3. 渲染与交互:事件粒度决定体验
3.1 DOM 节点加 SVG 连线,谁也别抢谁的活
渲染层我用了一个很朴素的思路:节点是节点,连线是连线,各走各的渲染管线。
节点用 DOM 元素渲染,定位方式就是绝对定位,left 和 top 通过世界坐标乘以 scale 计算出来。每个节点对应一个 React 组件,组件内部可以是任意自定义内容,包括表单、标签、图标甚至嵌套的子图表。这也是我坚持节点用 DOM 的原因,Canvas 画个圆和画一个带下拉框的富交互组件,复杂度完全不同。
连线用 SVG 渲染。每一条连线生成一条 path,d 属性根据起点锚点坐标、终点锚点坐标和 router 类型动态计算。把连线放到 SVG 而不是直接用 DOM 的 div 加 border 画线,是因为 SVG path 对贝塞尔曲线和正交折线的表达太自然了,CSS 想画出带拐弯且弯角平滑的线,麻烦得多。
DOM 层和 SVG 层通过同一个坐标容器叠加在一起。容器负责 transform: scale 和 translate,所有子元素都在世界坐标系里布局。缩放画布时,不需要逐个更新节点和连线,只要更新最外层容器的 transform。
3.2 拖拽、缩放、平移的事件处理逻辑
节点拖拽的事件链是:mousedown 在节点上触发 -> 记录起始鼠标位置和节点起始坐标 -> 在 document 上挂 mousemove 和 mouseup -> mousemove 中计算坐标差并更新节点 x/y -> mouseup 时解绑事件。
为什么监听挂在 document 上而不是节点上?因为拖拽过程中,鼠标移动往往超出节点本身的范围,如果只监听节点元素,一旦鼠标移出节点边界,移动事件就断了,拖拽体验会非常难受。挂在 document 上虽然多写几句绑定和解绑的代码,但换来的是拖拽的稳定性。
画布缩放我用的是鼠标滚轮加修饰键。滚轮本身在部分浏览器里会触发页面滚动,所以需要 preventDefault,并且目标缩放比例围绕鼠标指针所在位置进行缩放。这个细节很多人第一次实现时会漏掉,导致缩放后鼠标指着的那个点跑掉了。
平移的实现,则是监听画布背景区域的 mousedown,拖拽时更新 viewport 的 tx 和 ty。这里要注意区分:mousedown 在节点上就是节点拖拽,在空白处就是画布平移。实现上可以通过事件 target 的>源端口中心 -> 向右延伸 40px -> 垂直移动到目标端口所在 y -> 水平移动到目标端口中心
如果两个端口在同一侧或者上下相对,就相应调整延伸方向。这个算法写起来大概几十行,但已经能覆盖 80% 的常规连接场景。节点少的时候看起来比 A* 还利落,因为路径足够短。
遇到真正的障碍物怎么办?当前做法是:如果线段与某个节点矩形相交,就在线段中点位置插入转弯点,形成一个 U 型绕行。这是最粗糙的避障,但配合端口方向设置后,实际场景里很少触发。
如果后续要做面积更大的图,我会把 A* 网格寻路加进去,但那会带来网格粒度问题、绕路问题、路径平滑问题,复杂度是现在的数倍。对内部工具来说,这一层暂时不需要。
4.2 端口方向设计对交叉的抑制作用
很多人忽略了这样一个事实:交叉线的数量,很大程度上在节点建模阶段就已经决定了。
举个例子。一个节点要接收输入、发出输出,如果输入输出都定义在左侧,那所有连线都从左边进出,左半边必然挤成一团。我在数据模型的 ports 上做了方向约定:左侧端口专门用于输入,右侧端口专门用于输出,顶部端口用于特殊连接,底部端口默认禁用。
这个约定在功能上不强制,但编辑器会给出提示。用户建模时遵循约定,连线的整体交叉自然减少。代码上也可以通过计算自动给节点分配默认端口位置,比如左右各一个、均分半边,保证任何两个相邻节点之间都有天然的可连接空间。
这是我在实践中发现的规律:交叉问题靠路由算法硬解,效率低、效果差;靠端口方向规则来预防,成本极低、收益明显。
4.3 轻量分层布局:我实际采用的方案
自动布局是个大坑,这是我在做 diagram-design 时最深的体会之一。自动布局做得好,能节省大量人工拖拽;做得不好,比手动拖还难看。最终我没有引入通用自动布局引擎,而是做了一个小工具:分层排列。
分层排列针对的是“有向无环图”这一类常见结构。做法是先用 BFS 按层级给节点分组,比如宽度优先遍历,源节点在第 0 层,它直接指向的目标在第 1 层,依此类推。然后对每一层计算最大节点高度,按等间距垂直排列,水平方向按层内节点顺序排开。
这个算法不处理环、不处理交叉最小化,但胜在稳定、可预期。用户点击“自动排列”后,图会变成清晰的分层结构,再手动微调个别节点位置即可。对内部流程图来说,这个方案实用程度很高。
我需要补充说明:如果有一天要做专业的架构图编辑器,那分层布局远远不够,拉平连线交叉得上 Sugiyama 全流程算法。但那是另一个量级的工作量,不在 diagram-design 的第一阶段目标内。
5. 大数据量与性能:从拖动掉帧到接近零卡顿
5.1 性能瓶颈的根源:状态更新粒度太粗
diagram-design 第一版跑通后,我拿一个将近 400 节点、500 条连线的测试数据压了一下,结果拖一个节点,整个页面从 60fps 掉到十几帧。用性能面板一看,每次拖拽移动,触发了几乎所有节点和连线的重渲染。
原因不复杂:我把整张图的数据放到了一个全局状态里,任何节点坐标变化,都会让所有节点组件重新执行渲染函数。400 个节点,每个节点内部还有若干子元素,一次拖拽就是几百上千次组件更新,浏览器扛不住很正常。
优化方向是拆分状态。方法也直接:不再把整张图传给所有子组件,而是只传当前渲染所需的节点子集和连线子集。拖拽一个节点时,只有该节点自身以及连接到它的若干条连线订阅到更新,其他节点和连线完全不感知变化。粒度小了,更新面积自然就小了。
5.2 可视区域裁剪:不画看不见的内容
节点数量上来之后,还有一个影响性能的因素:画布外面有大量节点依然在渲染。用户只看得见视口范围内的部分,屏幕外几百个节点虽然折叠在角落,但渲染开销一点不少。
可视区域裁剪的做法是:在每个节点的渲染前,判断该节点矩形是否与当前视口矩形相交。不相交的直接返回 null,只在占位层记录一下“这里有节点”。视口发生变化时,重新计算可见集合并触发更新。
除了渲染裁剪,命中检测也做了同样优化。框选时不再遍历所有节点判断是否命中,而是先通过一个简单的四叉树或按 y 坐标排序的区间索引缩小候选集,再精确做矩形相交。一步优化到位的方案涉及较多代码,所以我先做了最简单的版本:按节点 id 区间建索引,框选时先过滤 y 范围,再精确判断。实测下来,千级节点以内已经没有明显的卡顿感。
5.3 撤销重做与内存管理的实际坑
撤销重做是图表编辑器的必备功能,也是内存杀手。第一版我用的方案很笨:每次操作结束就深拷贝一次整张图 JSON,存到历史栈里。拖个节点,移动 20 步,就是 20 份全量 JSON。测试图稍微大一点,浏览器内存轻松冲破几百 MB。
后来我改成了两个措施。第一是操作合并,比如拖拽节点时,mousemove 连续触发了几十次,只在 mouseup 时保存一次快照。第二是快照做结构化共享,借用不可变更新的思路,只有变化的那条路径生成新对象,未变化的部分引用不变。不过这更多是优化历史栈,真正常用的场景下,操作合并已经够用了。
避坑的另一件事是事件监听泄漏。document 上的 mousemove 和 mouseup 在拖拽结束时一定要解绑。组件卸载时也要把全局监听清掉,否则会出现图表页面已经销毁,但 mousemove 里的回调还在更新已卸载节点状态的情况,这种问题排查起来非常隐蔽。
我还发现一个现象:当图表中有大量图片背景或自定义字体时,内存占用会快速上升。后来排查是浏览器对解码后的图片数据的缓存策略,和编辑器本身关系不大,但做性能调优时要留意,不要把所有卡顿都归到框架头上。
最后的实际体会
diagram-design 这个项目做下来,对我来说最有价值的不是那几万行代码,而是把“图编辑器到底是怎么工作的”这件事彻底想清楚了。
我的体会是:一张图,表面上是一堆矩形、圆形和线条,本质上是一份数据和一个状态机。数据描述结构,状态机处理交互。把这两件事分开,后面加什么功能都顺。比如导出、导入、搜索、替换、批量选中、模板复用,全部是在数据上做文章;拖拽、缩放、吸附、框选,全部是在状态机里做文章。任何人要复制这套思路,我建议先从数据模型开始,而不是从画布渲染开始。
另外再分享一个工作习惯上的建议。我把“故意不做的事”写进了项目的说明文档,比如“不内置 BPMN 语义校验”“不做多人实时协同”“不提供插件市场”。这样做的好处是,后续接手的人不会因为这些功能缺失而指责项目不完整,也不会心血来潮去加一个超出边界的大功能。边界是保护,不是限制。diagram-design 要继续长大,方向也应该是把现有功能做深,比如连线段点的元信息标注、批量对齐、多画布嵌套,而不是无限堆新功能。