自研浏览器端图表设计器:数据模型、渲染交互与性能优化实战

📅 发布时间:2026/10/19 14:42:11
自研浏览器端图表设计器:数据模型、渲染交互与性能优化实战
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 渲染。每一条连线生成一条 pathd 属性根据起点锚点坐标、终点锚点坐标和 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 要继续长大方向也应该是把现有功能做深比如连线段点的元信息标注、批量对齐、多画布嵌套而不是无限堆新功能。