diagram-design 这个项目,是我去年花了大半年时间打磨的一套浏览器端图表渲染与编辑组件。它主要面向拓扑结构、流程编排、架构图这类以节点和连线为核心的场景,核心目标是把数据模型、布局算法、渲染层和交互层做成可配置的模块,避免每次接新需求都要从零造轮子。如果你正在做可视化的配置界面、运营中台里的依赖关系图、低代码平台里的流程设计器,这套思路可以直接抄走一部分,哪怕只抄数据模型那层,也能省下不少重构的功夫。
做这类东西最容易被低估的不是“把图画出来”,而是“让一张图能持续地被编辑、被查看、被序列化”的全套链路。很多业务场景一开始只要展示,后面一定会冒出来编辑、拖拽、缩放、撤销重做、保存回放这些需求,如果前期没有留对接口,后期几乎等同于推翻重写。我在这套组件里踩过的坑,从坐标转换、布局算法到拖拽性能、数据一致性,每个环节都有值得展开讲的经验。
1. 项目背景:为什么放着现成方案不用,非要自己做图表组件
最早触发我做这件事的,是业务里一个依赖关系图的需求。当时的做法是先找现成的开源方案,试了一圈之后发现一个问题:看普通图表,饼图、折线图、柱状图这类自带渲染能力的库确实够用,但只要进入拓扑图、流程图、可拖拽画布这个领域,通用方案的定制成本就变得极高。
现成方案的核心问题不是画不出来,而是三件事没法权衡。第一,交互深度不够,很多图库支持点击、悬浮,但不支持节点拖拽、连续框选、多选后批量连线,强行改源码的成本比新写一个还高。第二,主题体系和业务绑定太深,每个项目的节点样式都不一样,节点上挂的角标、状态色、端口出口、折叠标识需要完全自定义,通用库通常给你一层基础样式就算完事,细节定制得上 patch。第三,数据结构被锁死,业务数据常常是树或者图,通用方案一般要求你把它先摊平成固定的数组结构,联调阶段两边都在互相迁就,改一处坏一处。
当时的开发团队评估之后得出了一个共识:与其在每个项目里对付不同图库的行为差异,不如沉淀一个自己的组件层,专门解决图表类页面的表达与交互。这个组件层就是 diagram-design。它的定位很明确:输入是图结构数据和一份样式配置,输出是一个可缩放的交互画布,支持节点拖拽、连线编辑、自动布局、数据序列化,并且预留充足的自定义入口。
在动手之前,我还明确了边界:绝对不做一个完整通用的图形引擎,不碰图像处理、不碰复杂粒子、不碰地图切片这类关联度低的能力。图表组件的价值在于“图结构”这一件事上做到足够深,范围收得越紧,接口才能越稳定。后面几个月的开发验证了这个边界定的合理,所有新需求进来的时候,第一件事是确认是否属于节点、边、画布这三个核心概念,如果属于,就在这套体系里扩展;如果属于,就在这套体系里扩展;如果不属于,再另开新组件。作为团队内部工具,这种收敛让后续接手的人不用读完整文档就能猜出新增功能应该放在哪里。
2. 整体设计方案:把“图”拆成数据模型、布局、渲染三层
2.1 数据模型:节点、边、端口各管各的事
图表组件最容易做坏的地方就是数据模型。很多半成品把节点的位置、尺寸、颜色、文字全部塞进业务对象里,画到一半业务方要改字段,渲染逻辑跟着改一遍,序列化再改一遍,最后没人敢动数据结构。diagram-design 从一开始就把数据模型分成了三层。
第一层是节点描述,只包含 id、名称、类型、坐标、宽高。坐标宽高这种绝对的几何信息必须放在图层里,不能由布局算法临时算。因为用户手动拖过之后,这个坐标就是权威数据,任何重排算法都不能覆盖它。第二层是边描述,只包含 id、source、target、类型、样式标识。边的几何路径不持久化,每次渲染时由计算逻辑根据两个节点的最新位置实时生成,这样节点一移动,边自动跟着走,不需要手动去衍生一堆中间坐标。第三层是端口描述,解决的是连线“从哪里出去、从哪里进来”的问题。拓扑图里经常出现一个节点有多个出口的情况,如果连线的起点只用节点中心点,图形一密集就乱成一团;有了端口概念,每条边可以指定绑定到哪个端口,路径计算时锚点从端口中心开始,视觉上会干净很多。
这种分离带来的直接好处是序列化简单。保存画布的时候,只需要把节点数组、边数组、端口配置、视图变换参数写成 JSON,其他所有东西都可以在加载时重建。业务方接数据时也不用关心内部坐标逻辑,把业务字段映射到节点字段即可。我后来在另一个流程引擎项目里复用这套模型,只改了节点的字段扩展,渲染和交互一行没动。
2.2 布局算法:自动布局和手调位置如何共存
布局算法是所有图表组件里讨论度最高的部分,也是最容易导致项目失控的部分。我见过不少团队把力导向、分层布局全部做完之后才开始做交互,结果用户手动拖一个节点,布局算法马上把它拉回去,体验非常怪异。
diagram-design 里把布局分成三种模式,并且严格排序。第一种是初始自动布局,数据从接口加载完成、画布首次渲染时使用。此时业务方通常还没有手动调整过,适合跑一次分层布局或者力导向布局,让整张图先有个可读的形态。第二种是手动微调,用户可以直接拖拽节点到任意位置,这种操作产生的位置数据要立刻写入模型,并且标记为“已手动覆盖”,之后任何自动布局运行到这些节点时都要跳过。第三种是显式重排,只有用户点击“自动整理”按钮时才触发,运行前给一个全局确认,因为重排会覆盖之前的手动微调。
数据里需要有个标志位记录手动状态,我用一个 Set 存用户拖过的节点 id。这个取舍很关键:如果没有这个标记,自动布局和手动布局就会互相打架;有了它之后,用户可以在一个已整理过的图上继续个性化调整,更适合真实工作流。
具体布局算法本身,我采用了分场景的策略而不是只依赖一种通用算法。树状结构优先用从根开始递归计算子树尺寸的分层布局,复杂度低且稳定;网状关系图才使用力导向,并且把力学参数开放成可调项。力导向布局有很多参数要调,弹簧系数、斥力半径、迭代次数、冷却速度,我建议不要一次性调到完美,先默认跑五百个迭代得到一个“能看”的形态,再根据实际数据微调半衰期和斥力系数。算法的目标不是数学上的最优排列,而是避免节点完全重叠、避免长边穿过节点中心、保持同层节点对齐,达到这三个要求就足够给人用了。
2.3 渲染层选型:SVG、Canvas 还是 WebGL
渲染层选型这件事,我在最开始做了比较完整的对比,也给后来踩坑留下了余地。SVG 最大的优势是每个图形都是 DOM 元素,天然支持事件绑定和 CSS 样式,定制节点的时候写几行代码就能开箱即用,调试也直观。它的劣势是节点数量上去之后 DOM 节点增多,渲染和事件响应都会变慢。Canvas 的优势是批量绘制性能好,几千上万个节点也扛得住,但事件命中需要自己算,文本排版、局部动态更新都要额外实现,开发成本明显更高。WebGL 适合真正的海量节点和复杂动画,但工程复杂度对普通前端团队来说有点重,为了一个拓扑图引入 WebGL 并不划算。
diagram-design 最终选择的是“SVG 为主、Canvas 兜底”的双层方案。节点数不超过两千时,全部用 SVG 组节点渲染,数据模型和 DOM 之间做了一层简单的映射,样式、文字、图标都可以用模板灵活控制。节点数超过两千时,切换到 Canvas 批量绘制,此时交互降级为只有框选缩放、拖拽平移,节点内部细节不再全部绘制,只画缩略的方块和圆点。这个阈值不是拍脑袋定的,我实际压测过,以普通业务节点大约 60 到 120 个 DOM 元素计算,两千个节点就是十二万到二十四万个元素,再往下走浏览器明显吃力。阈值要按本项目的节点复杂度去测,不同项目差别很大,但建议保留一个可配置的开关,让使用者可以按自己的设备性能调整。
双渲染层的切换逻辑最需要小心的不是“怎么画”,而是“切换之后状态是否一致”。视角坐标、选中节点、框选范围必须从模型层读出,而不是从渲染层反推。我在实现时把画布状态单独抽了一个对象,SVG 渲染时状态对象驱动 DOM 更新,Canvas 渲染时状态对象驱动绘制循环,两套渲染逻辑都只消费同一个状态,因此切换时不会出现节点位置对不上或者选中状态丢失的情况。
3. 核心细节与实操实现:坐标、连线、交互一个都不能少
3.1 坐标转换是画布交互的基石
图表组件的所有交互,鼠标点击、拖拽、框选、缩放,全部绕不开坐标转换。很多人一开始觉得坐标转换就是把 CSS 像素转换成画布坐标,但实际上中间隔了两层:一层是画容器的偏移,另一层是缩放比例,还有一层可能是画布本身的平移。拖拽空白区域移动视野之后,鼠标和图形的坐标关系会整体发生变化,如果不能准确换算,点击就会点偏,连线就会连错端口。
我采用的是标准做法:视图变换用四个参数表示,scale、translateX、translateY、以及画布容器的左上角偏移。整个画布内容包在一个 transform group 里,DOM 里的坐标就是世界坐标,用户看到的屏幕坐标是经过 transform 后的结果。交互层收到鼠标事件后,先取屏幕坐标,再减掉容器偏移,再减掉平移量,最后除以缩放比例,得到世界坐标。写成代码就是:
function screenToWorld(clientX: number, clientY: number) { const rect = viewport.getBoundingClientRect(); const sx = clientX - rect.left - state.translateX; const sy = clientY - rect.top - state.translateY; return { x: sx / state.scale, y: sy / state.scale }; }反过来,要把世界坐标画到屏幕上:屏幕坐标 = 世界坐标 × scale + translate + 容器偏移。这里有个非常重要的经验,就是不要让 SVG 的 viewBox 自动参与缩放计算。viewBox 确实能缩放整个坐标系,但缩放后所有 stroke-width 也会跟着变,如果不在样式里反向补偿,就会出现缩放越大线条越粗的毛病。我是用手动 transform 而不是 viewBox 来控制缩放,线条宽度始终用固定值除以当前缩放比例,让用户在任何缩放级别下都能看到一致的线宽。
拖拽节点时还要注意一个问题,节点的坐标在模型里存的是世界坐标,而鼠标移动事件返回的是屏幕坐标,所以拖动过程中每次都要做一次 screenToWorld 换算再更新节点位置。我一开始直接在 mousemove 里更新节点的 CSS transform,结果保存图纸时发现模型里的坐标完全没变,因为视图坐标和模型坐标没有同步。后来改成拖动事件发生时先记录起始屏幕坐标和起始世界坐标,移动时计算增量,再换算成世界坐标的增量写入模型,问题就解决了。
3.2 连线路径计算:曲线、正交线、锚点避让
连线是图表组件里视觉上最容易出彩、逻辑上最容易出错的部分。diagram-design 支持两种路径类型,一种是贝塞尔曲线,另一种是正交折线。贝塞尔曲线适合概念图,看起来柔和;正交折线适合流程和架构图,更符合工程表达的直觉。
曲线路径的公式并不复杂,关键在于控制点高度的确定。我用的是以两端连线距离的一半作为控制点偏移量,如果起点终点在同一水平线就居中偏上,如果在垂直方向则根据均在水平或垂直方向进行偏移。但真正麻烦的是锚点。节点有多个端口,起点终点都必须绑定到端口上,路径不能从节点内部穿出去。方向比较简单的端口,比如只有左侧出口的端口,起点很好确定;方向复杂后,就需要在端口上配置一个出口方向向量,路径的初始方向必须沿着这个向量走一段固定距离,再进行曲线计算,否则就会出现线从节点内部直接戳出来的情况。
正交折线要处理的情况更细。标准的正交线从一个端口水平出去,经过两次垂直转向到达目标端口,但如果两个节点距离太近或者位置关系特殊,两段路径就会重叠。我实现的算法是先收集端口锚点、出线方向、目标锚点、目标入线方向,然后生成一条由若干个路径点组成的折线。折线的拐点数量不是固定不变的,当两个节点完全上下对齐时,路径会自动退化成一条竖线段;当两个节点左右交叉时,算法还要检测折线是否会穿过某个节点的矩形区域,如果会,就在中间插入一个额外拐点绕开节点区域。这段逻辑最开始只有六十多行,后来不断修 bug 长到了三百多行,核心原因是真实图表里的空间关系组合太多,靠 if-else 列举根本列不完。后来我用一个简单的规则统一了处理:正交线的每一段只能沿水平或垂直方向走,每次方向变化必须经过一个偏移点,偏移点的坐标由两端的端口方向共同决定,剩下所有特殊情况的规避规则都在这条前提下做局部调整。
3.3 事件交互:拖拽、缩放、框选怎么组织
交互层是图表组件体验的生命线,也最容易写出混成一团的代码。我的原则是所有交互都用事件委托,只在整个画布容器上注册事件监听,不逐个给节点元素绑事件。这样节点数量多大都不会有监听器数量膨胀的问题,动态新增和删除节点也不需要重新绑定事件。
节点拖拽的逻辑是从 mousedown 开始的,mousedown 时记录点击到的节点和起始坐标,然后进入拖拽状态。接下来 mousemove 里更新节点坐标,mouseup 时结束拖拽并触发一次变更通知。这里面最容易踩的坑是 mousemove 频率,拖拽过程中鼠标每秒会触发几十次甚至上百次事件,如果每次都做坐标计算、DOM 更新、布局重算,一定会卡。我用 requestAnimationFrame 把 mousemove 的实际更新合并成每帧最多一次,即便鼠标连发多次事件,渲染也只在下一帧执行,拖动的流畅度会明显改善。
拖拽空白区域时,要区分两种情况:单机空白是取消选择,按下并移动是平移画布。这个区分用一个小参数就能解决,mousedown 时记录坐标,mousemove 发生时先判断移动距离是否超过四个像素,超过就认定是平移,否则认定是单击。别再小看这几个像素,没有这个缓冲的话,用户想取消选择的时候常常被误判成平移,体验很糟糕。
框选的实现逻辑是在空白区域按下并拖动时显示一个半透明矩形,松手后把矩形范围内的所有节点选中。判断节点是否在矩形里的算法用的是矩形相交检测,要注意用世界坐标判断还是用屏幕坐标判断。世界坐标判断更准确,因为拖拽缩放后屏幕矩形和世界矩形之间有一层缩放变换,直接用屏幕坐标判断会导致选多或者选少。框选事件结束后要同步更新选中集合,并触发一次外部监听,让工具栏里的复制、删除按钮知道自己是否可用。
4. 性能优化与质量保障:一千个节点以后才算开始
4.1 渲染层降级与按需绘制
图表组件在数据量小的时候,性能问题几乎不存在,等节点数量上来才发现之前没做的性能准备都要补。diagram-design 的性能策略遵循一个原则:绝不渲染看不见的东西。
第一种手段是视口裁剪,把当前可视区域的世界坐标范围算出来,渲染时只处理这个范围内可见的节点。实现思路很直接,遍历所有节点时快速判断节点矩形和视口矩形是否有交集,没有交集就直接跳过。这个优化在缩放级别比较低时收益很大,因为整张图可能有一万多节点,但屏幕上只能看到一小部分。第二种手段是聚合渲染,当缩放级别非常低,节点缩小到只有几个像素大时,再逐个绘制节点内部细节毫无意义,这时可以用网格聚合把距离相近的节点合并成一个聚合节点绘制,只显示数量信息。这个功能做起来稍复杂,但绝不是可有可无,一张几千节点的集群拓扑图,没有聚合层时缩放操作会卡顿得让人想放弃。
第三种手段是分层绘制,把静态背景、网格线、节点、边、选中框分成几个独立图层。静态网格不随节点变化,可以绘制到离屏画布缓存;边和节点层单独更新,拖拽一个节点时只需要重绘边层和小部分节点,不需要触发整张画布重绘。我最初实现的是每次操作都全量渲染,后来加了个 dirty 标记,只有节点集合、边集合、视口参数发生改变时才重绘对应图层。
组件内部还做了一层批量更新合并。业务方传入的更新可能会一次修改几百个节点的数据,如果每个 set 操作都触发渲染,性能会崩。我在模型层维护了一个事务机制,外部传入一批变更,模型收集完以后再统一通知渲染层刷新一次。这样批量加载数据时可以做到一次重绘,不会出现图里显示了一半节点的中间态。
4.2 测试策略:数据流、交互序列和视觉回归
图表组件的测试比普通页面组件难搞,因为核心逻辑分布在不同层,直接测试渲染结果容易脆弱。我给 diagram-design 设计了三个层面的测试,覆盖面基本能兜住主要回归风险。
第一个层面是数据模型测试,验证图结构数据的增删改查、手动覆盖标记、序列化反序列化是否一致。这一层不涉及 DOM,纯跑逻辑,写起来快,维护成本也低。比如节点移动后,边是否需要重新计算路径,以及移动后模型里的边路径是否保持只读,这些都通过模型测试保证了行为稳定。第二个层面是交互序列测试,用模拟事件脚本驱动画布,模拟点击、拖拽、缩放、框选,然后检查状态对象的输出是否符合预期。交互序列测试最适合用来发现时序问题,比如缩放过程中节点位置是否异常跳动、选中状态是否会丢失。第三个层面是视觉回归测试,用截图对比的方式确保样式没有被无意改坏。视觉测试的前提是必须有固定宽高的测试容器、固定主题和固定种子数据,否则每次截图都会有细微差异,流水线会一直亮红。
我在测试里经常用的一个基准数据是带三十个节点、五十条边的依赖图,覆盖了树状结构、环状结构、孤立节点,以及两个节点之间多条边的情况。这个基准数据基本能把常见路径计算、锚点绑定、布局算法跑一遍,任何一次改动如果让图结构异常,第一个报警的往往就是这条用例。
5. 常见问题与排查实录
5.1 边跑到了节点下面,路径计算为什么“翻车”
一个 I 看到的现象是连线路径明显穿过节点矩形内部,视觉上像线路从节点身体里钻过去。排查之后发现根本原因不是绘制顺序问题,而是锚点和方向向量设置不当。路径计算从端口中心出发后,第一段方向如果没和端口出口方向对齐,曲线就会直接穿过节点区域。解决方法是路径计算前先校验端口锚点的世界坐标是否位于节点的边界带上,同时确保首尾两段路径的方向向量严格沿着端口配置的方向走,不能依赖默认值。
还有一类路径翻车是节点移动后,边的路径没有及时重算。出现这个问题的原因很常见,节点坐标更新后只是触发了“节点层重绘”,没有触发“边层重绘”。我在模型层把节点移动事件和边重算事件做成了条件联动:只要节点世界坐标发生变化,相关联的边自动加入 dirty 集合。这样能避免每次移动节点都把全量边重算一遍,性能和正确性之间的平衡会更舒服。
5.2 缩放平移之后事件全部错位
震动右接连接到用户反馈说缩小画布后,点击节点的位置和实际选中的位置明显出现了偏差。当时第一个反应是坐标换算公式错了,后来排查发现公式本身没问题,问题出在容器偏移量获取的时机:如果视口容器的尺寸在缩放动画过程中发生变化,getBoundingClientRect 获取到的值是动画中间态,瞬间的偏移与真实最终偏移不一致,点击就会偏。解决方法是把容器偏移量的计算放在每次缩放动画结束后统一刷新,而不是每次鼠标事件都实时获取。另外要确保监听器和坐标换算用的是同一个状态快照,不要在事件回调里读到一半新值、一半旧值。
事件错位还有一种情况是滚轮缩放时默认锚点不对。滚轮缩放通常应该以鼠标所在位置为缩放中心,否则每次缩放画面都会向某个角落跑去。做法是先换算鼠标所在的世界坐标,缩放完成后把新的视口平移到让同一个世界坐标仍然落在鼠标位置下。这个逻辑听起来简单,但细节处理容易遗漏,尤其是缩放动画有 easing 的情况下,每一帧都要重新计算平移量,否则画面会跳。
5.3 拖拽卡顿和“幽灵连线”
拖拽卡顿的根因绝大多数不是计算量大,而是事件触发频率过高造成的重复渲染。我统计过,一次三秒的拖拽大约会触发一百五十次 mousemove,如果每次都重建路径、更新若干 DOM、再跑一次同步布局,帧率自然会掉下去。requestAnimationFrame 合并是第一步,第二步是把 DOM 操作和几何计算分离,mousemove 处理器只更新模型数据,渲染器统一在下一帧读取模型数据并更新 DOM。这样就算一帧来了十次 mousemove,模型也只保存最后一次坐标,渲染只执行一次,性能消耗可以降低一个数量级。
与卡顿同时出现的还有一个很尴尬的 bug:快速拖拽时偶尔会在图上产生一条不属于任何边的“幽灵连线”。排查后发现问题出在连接器抓取逻辑上,拖拽起点和终点之间产生了一个回调事件,接收方没有判断回调事件是否有效就创建了边。解决方式是在创建边之前增加一个合法性校验,起点和终点必须都指向有效的端口对象,并且不能形成自环,否则丢弃这条边。后来我还加了一个“拖拽完成后 150 毫秒忽略重复创建”的防抖,彻底避免了连点两次产生冗余边的问题。
为了方便对照,我把这段时间收集到的排查经验整理成了表格:
| 现象 | 主要原因 | 解决思路 |
|---|---|---|
| 连线穿过节点 | 路径首段方向未对齐端口锚点 | 校验锚点坐标,强制首末段方向与端口方向一致 |
| 缩放后点击错位 | 容器偏移量取到了动画中间态 | 动画结束后统一刷新偏移量,状态快照保持同步 |
| 拖拽卡顿 | 高频事件导致重复渲染 | requestAnimationFrame 合并渲染,只更新模型不直接操作 DOM |
| 快速连线产生幽灵边 | 回调事件未做合法性校验 | 校验端口有效性、禁止自环、增加创建防抖 |
| 节点移动后边不变 | 边层 dirty 集合未联动 | 节点坐标变更后自动将关联边加入重绘集合 |
5.4 性能瓶颈的常见认知误区
性能优化里经常有人说“把 SVG 换成 Canvas 就快了”,但真实案例中大量性能问题来自代码实现而不是渲染技术。比如在 mousemove 里重复调用 getBoundingClientRect,这种方式会引起浏览器强制同步布局,一次拖动过程可能触发几十次重排,比渲染本身更消耗时间。再比如边路径计算时反复读取节点 DOM 的宽高,不如提前在模型里缓存一份节点尺寸。所以每次遇到性能问题,我会先开性能面板看函数耗时分布,确认瓶颈是不是真的出在渲染数量上,而不是顺手就换渲染技术。换个渲染层能解决节点太多的问题,但解决不了事件处理写得低效的问题,切记不要混淆。
6. 设计复盘:哪些决定踩对了,哪些留了尾巴
6.1 拆分数据模型是一开始最值得做的一件事
项目初期最正确的决定就是把数据模型、布局算法、渲染层、交互层拆开,尤其是数据模型独立这件事,后续所有功能都从这个基础受益。节点位置和边的路径分开存储,手动拖拽和自动布局互不干扰,序列化只关心纯数据——这些看起来都是基础设计常识,但在具体开发中很容易被“反正现在数据量小,直接塞一起算了”的想法带偏。我在中途接了一个新需求,节点需要支持折叠,当时只改模型层加了一个 expanded 字段和相应的递归展开算法,渲染层几乎没有任何改动,这里就能看出拆层的价值。
6.2 手工调整与自动布局的优先级必须分开
自动布局跑得很漂亮只是第一步,用户不能手动调整的话就失去了编辑器的意义。手动调整与自动布局共存的优先级设计,是这轮开发中被验证最牢靠的一处。用户拖过哪些节点、哪些节点保持了默认位置,这个状态非常重要,通过“手动覆盖标记”将两种布局模式隔离开以后,没有再出现过“拖完又被算法拉回去”的投诉。反过来,当初没有做好的是布局算法的参数配置,我把大量力导向参数硬编码在布局模块里,外部无法细粒度调整。后续如果有人接入一个节点数特别多、深度特别深的树,想要调小垂直间距,就不得不改代码重新打包。
6.3 后续扩展还能做什么
diagram-design 目前还留了几个可以继续扩展的点。第一个是撤销重做,目前做好的是数据变更的序列化和通知,但事务级的撤销栈还没完整接入,这是任何编辑型图表组件都绕不开的高价值功能。第二个是剪贴板操作,复制、粘贴、跨画布拖动这些能力其实和选中状态、序列化格式直接相关,可以实现得比较顺。第三个是多人协同,由于数据模型已经可以完整序列化,理论上可以用同步协议把坐标变更和边变更推给其他人,但冲突处理还要专门设计。第四个是导出能力,导出成图片是最简单的,复杂的是导出成可读的 HTML 片段或者其他工程格式,这需要再做一层格式适配器。
如果你也要趟这趟水,我给的第一建议不是先选渲染技术,而是先把数据模型想清楚,把节点 id 的唯一性、边引用关系、手动位置标记设计到位。这层地基一旦稳了,后面换 SVG 还是 Canvas、加布局算法还是定制交互,都只是选择不同的施工方案而已。而数据模型如果乱了,后面每加一个功能都会牵一发动全身,届时再回头重构的代价,远比你最初想象的大得多。