1. 关系拓扑图组件选型:为什么最后是visjs
1.1 一个真实需求复盘
我第一次接触“关系拓扑图”这个需求,是公司某个后台管理系统要做一张依赖关系图:几十个微服务互相调用,数据库、缓存、消息队列又各自挂在服务下面。需求方的原话是“把关系画出来,能自动排列,别挤成一团,鼠标点到谁就高亮谁”。我当时第一反应是用SVG手动画,结果连坐标计算、连线避让、缩放平移这些基础能力都要从头写,真要支持动态增删节点,工作量直接失控。
后来我静下心把市面上能画关系图的方案做了个选型对比。用的是同一份模拟数据,各写了一个小Demo,结论非常直接:
| 方案 | 上手速度 | 布局能力 | 交互定制 | 动态更新 | 我的评价 |
|---|---|---|---|---|---|
| ECharts关系图 | 快 | 有力导向布局,但密集场景一般 | 事件和API比较全 | 数据改动会整体刷新 | 适合轻量展示,不适合重度交互 |
| AntV G6 | 偏慢 | 布局算法丰富,定制性强 | 图交互模型完善 | 性能好,但概念多 | 适合长期投入的大型可视化项目 |
| D3.js | 很慢 | 全手动 | 全手动 | 全手动 | 适合做图表库的人,不适合业务交付 |
| visjs Network | 快 | 物理引擎内置,默认就不错 | 事件简单直接 | DataSet增量更新,天生适配 | 中小规模拓扑图的最佳性价比 |
我当时要的其实很明确:几百个节点、上千条边,要能拖、能缩放、能自动排版,还要能随时增删数据。visjs的Network模块几乎全中。它的定位就是“网络关系图”,不像ECharts那样偏图表组件,也不像G6那样需要先吃透一套复杂的数据模型和交互机制。
1.2 visjs解决的核心痛点
第一点是物理引擎内置。把节点和边扔进去,它默认用Barnes-Hut力导向算法自动计算位置。初始化那几秒,画面会像分子运动一样晃来晃去,等“力”达到平衡就自动稳定。对关系图来说这是最大的解放——我不用自己写布局算法,不用管节点重叠,它自己会弹开。
第二点是DataSet数据驱动模型。它把节点和边的数据维护做成类似一个小型数据库,调用add、update、remove就能精准操作某一条数据。这块在动态刷新、局部高亮、后台推送场景里非常节省成本。对比一下,静态图表类的方案一旦数据变了往往要整份重绘,而visjs可以只更新变化的那几个点。
visjs当然不是万能的。节点规模到几万、边特别多的时候,物理引擎会明显吃力;它也不适合做高度的定制化视觉效果。但如果需求是“快速交付一张可交互的关系拓扑图”,它确实是我用下来最顺手的方案。下面这篇整理会围绕visjs的Network模块展开,所有示例我都尽量贴了可直接运行的代码。
2. 最小可运行Demo:三步把第一张图跑出来
2.1 安装和引入
如果是正经项目,直接在命令行安装:
npm install vis-network然后在代码里引入:
import { DataSet, Network } from 'vis-network/standalone';如果只是临时验证或者写个HTML页面,也可以用CDN方式:
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>引入之后,vis这个全局对象会挂载在window上,直接用new vis.Network()就能创建关系图。
2.2 创建一个完整可运行的最小页面
下面这段代码是我建议的起点。先别急着加花哨的配置,把基础链路跑通再说:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>visjs关系拓扑图最小示例</title> <script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script> <style> html, body { margin: 0; height: 100%; } #graph-container { width: 100%; height: 600px; border: 1px solid #eee; } </style> </head> <body> <div id="graph-container"></div> <script> const container = document.getElementById('graph-container'); const nodes = new vis.DataSet([ { id: 'user', label: '用户中心' }, { id: 'order', label: '订单中心' }, { id: 'db', label: '订单数据库' } ]); const edges = new vis.DataSet([ { id: 'e1', from: 'user', to: 'order', label: '调用' }, { id: 'e2', from: 'order', to: 'db', label: '读写' } ]); const data = { nodes: nodes, edges: edges }; const options = {}; const network = new vis.Network(container, data, options); </script> </body> </html>这个页面打开后,三个节点会从散乱状态慢慢稳定下来,边的箭头、文字都会自动显示。你可以直接拖拽任何一个节点,其他节点会被弹簧和斥力带着动,这就是物理引擎在起作用。
2.3 我在起步阶段的实践建议
有几个细节想提醒一下。container必须有一个实际高度和宽度,否则canvas的高度是0,图会画成一片空白。有人喜欢把容器藏起来,等切换tab再显示,这在visjs里容易踩坑,后面避坑清单我会重点说。
还有一个容易忽略的点:节点id最好直接用有意义的字符串,比如'user'、'order',不要一上来就1、2、3。等数据量大起来,你要通过id去关联业务数据时,字符串id的可读性会帮你大忙。
这样一张图代表你已经把最核心的流程跑通了。再往下做,就是把数据模型、布局交互、动态更新这些能力逐个加进去。
3. 数据模型与Dataset机制:复杂关系图的正确打开方式
3.1 节点和边的字段模型
visjs的节点可以携带很多字段,实际项目里最常用的就这几个:
| 字段 | 作用 | 示例 |
|---|---|---|
| id | 节点唯一标识,字符串或数字都行 | 'service-user' |
| label | 节点上显示的文本 | '用户中心' |
| group | 分组名,配合groups统一设置样式 | 'service' |
| shape | 形状 | 'dot'、'box'、'image' |
| size | 节点大小 | 20 |
| font | 字体样式 | { size: 14, color: '#333' } |
| color | 节点颜色 | '#409eff' |
| opacity | 透明度,做高亮时很常用 | 0.2 |
边(edge)也有自己的字段:
| 字段 | 作用 |
|---|---|
| from / to | 起点和终点的节点id |
| arrows | 箭头方向,'to'、'from'、'middle' |
| label | 边上显示的文字,比如“调用”“依赖” |
| width | 线宽,可以用来表达权重 |
| dashes | 是否虚线,适合表达弱关联或备用链路 |
| smooth | 曲线类型,比如{ type: 'curvedCW' } |
3.2 为什么一定要用DataSet而不是普通数组
visjs最容易被低估的地方就是DataSet。很多人图省事写成普通数组:
const data = { nodes: [{ id: 'n1', label: '节点1' }], edges: [{ from: 'n1', to: 'n2' }] };这样写静态图没问题。但业务场景很少是静态的,总会有人要求“给某个节点换个颜色”“删掉某条边”“后台推来新节点要加进去”。如果用普通数组,只能把整份数据重新赋值,再让network整体重绘。一次两次还行,数据量大、频繁操作时,页面会出现明显闪烁,甚至白屏。
DataSet就像一张Excel表。你改某一格,它只更新那一格;你删除某一行,它只移除那行。它内部维护了数据索引,每次改动都能精准通知visjs去局部重绘。所以我的习惯是:凡是关系到拓扑图的节点和边,一律用DataSet维护。
3.3 增删改的标准姿势
新增节点:
nodes.add([ { id: 'gateway', label: '网关服务', group: 'gateway' } ]);新增边:
edges.add([ { id: 'e-order-cache', from: 'order', to: 'cache', label: '读写', arrows: 'to' } ]);修改已有节点或边:
nodes.update({ id: 'gateway', color: '#e6a23c' }); edges.update({ id: 'e-order-cache', width: 3 });删除节点时,记得把关联的边也删掉,否则图上会出现悬空边:
nodes.remove(['gateway']); edges.remove(['e-order-cache']);3.4 批量操作的小技巧
如果一次要更新很多节点,不要写一个循环调update。Dataset支持数组参数,一次性提交性能好得多:
const changes = allNodes.map(node => { return { ...node, opacity: isActive(node.id) ? 1 : 0.15 }; }); nodes.update(changes);清空重建的场景也常见。比如后台全量推送了一遍数据,那就用clear()加add():
nodes.clear(); nodes.add(newNodesList);但注意,clear()之后如果边数据还引用着旧节点id,图上会一直残留报错或空边。所以全量刷新时,节点和边最好一起按顺序处理。
4. 布局和物理引擎调参:从“一坨节点”到清晰拓扑
4.1 默认物理引擎为什么偶尔会翻车
visjs默认的力导向布局,模拟的是带电粒子和弹簧组成的系统:节点之间互相排斥,边像弹簧一样把两个节点拉近。这个方案对一般关系图很好用,但遇到几个典型场景就会翻车——节点全部挤在中心、图越弹越散收不住、或者某个区域一边是密密麻麻的边一边是空旷地带。
这时候就要自己调参数。visjs的物理引擎核心配置在physics字段里,我用得最多的是barnesHut模式:
const options = { physics: { enabled: true, solver: 'barnesHut', barnesHut: { gravitationalConstant: -4500, centralGravity: 0.25, springLength: 120, springConstant: 0.05, damping: 0.1 } } };几个参数的理解:
gravitationalConstant是节点间的斥力,负数表示排斥力;绝对值越大,节点之间越散得开。springLength是弹簧的原长,也就是边的理想长度。这个值调大,边会拉得更长,图整体更松散。springConstant是弹簧刚度,越大越不容易被拉伸,图越稳固,但如果太大,节点会死死贴在一起。centralGravity是把所有节点往画布中心拉的力。这个值太小,图会乱飘;太大会把图缩成一团。damping是阻尼,相当于空气阻力。阻尼太小,图会一直在那里晃动,半天稳不下来。
4.2 我的调参顺序
我一般从默认值开始,然后按照“散开—稳固—稳定”三步来:
- 如果节点都挤在一起,先调大
gravitationalConstant的绝对值,从-4500调到-6000或-8000试试。 - 如果节点之间隔得太远,边显得冗长,就把
springLength从120降到90。 - 如果图一直在抖动停不下来,把
damping调到0.15左右,或把springConstant稍微调大一点。
调了几次我发现,与其反复改参数,不如把物理引擎的结果“定格”住。也就是等图稳定后,直接把物理引擎关掉:
network.setOptions({ physics: { enabled: false } });这样节点位置不再变化,用户操作时也不会拖动一个点牵动整张图乱跑。这是很多演示系统里常见的处理方式——初始化时让力导向自动排版,排版完成就固定住。
4.3 层级结构用hierarchical布局
如果数据天然有层次,比如“调用链”里的一层层服务,用物理引擎硬排不一定好看。visjs提供了hierarchical层级布局:
const options = { layout: { hierarchical: { direction: 'LR', nodeSpacing: 120, levelSeparation: 180 } }, physics: { enabled: false } };direction可以选LR、RL、UD、DU,分别代表从左到右、从右到左、从上到下、从下到上。nodeSpacing控制同一层节点的间距,levelSeparation控制层与层之间的距离。
这个布局对于“上下游依赖关系”非常直观,但要注意它要求数据结构里能天然区分层级,否则会把所有节点都排成一条链,反而更难读。
4.4 固定关键节点
还有一个很实用的做法:把核心节点固定住,让其他节点围着它展开。比如网关这种谁都要经过的角色,如果它也参与力导向计算,会被拖得满屏跑。给它指定坐标并固定:
nodes.update({ id: 'gateway', x: 0, y: 0, fixed: true });fixed: true可以拆成fixed: { x: true, y: true },也就是只锁定某个方向。这个能力在做复杂依赖图时特别有用——你先手动摆好几个核心节点,剩下交给物理引擎自动展开。
5. 交互体验设计:点击、悬停、拖拽背后的细节
5.1 interaction配置项
visjs的一大优势是交互能力开箱即用。默认情况下,节点可以拖拽、画布可以平移缩放、节点可以选中。对于更精细的交互,可以配置interaction:
const options = { interaction: { hover: true, tooltipDelay: 300, dragNodes: true, dragView: true, selectable: true, selectConnectedEdges: true, multiselect: true, keyboard: true } };hover: true之后,鼠标悬停节点会触发事件并显示tooltip。multiselect配合Ctrl或Shift键可以多选。keyboard: true则支持键盘操作,比如用方向键平移画布。
5.2 常用事件监听
visjs的事件机制很直接,常用的几个:
click:画布上任何点击,参数里包含点击位置和命中的节点。selectNode:选中某个节点后触发,参数里有nodes和edges数组。deselectNode:取消选中时触发。hoverNode:鼠标悬停到节点上。stabilizationIterationsDone:物理引擎完成布局迭代后触发,这个事件很有用。
如果只是需要“选中节点后高亮它的邻居”,用selectNode比click更准确,因为click还包含点击空白区域的情况,需要额外判断。
5.3 一个完整的高亮实现
我在很多项目里做了一个“点到谁谁高亮,其他调暗”的效果,代码也不复杂:
function highlightNeighbors(selectedId) { const connectedNodeIds = network.getConnectedNodes(selectedId); const connectedEdgeIds = network.getConnectedEdges(selectedId); nodes.forEach((item) => { const isHighlight = item.id === selectedId || connectedNodeIds.includes(item.id); nodes.update({ id: item.id, opacity: isHighlight ? 1 : 0.15 }); }); edges.forEach((item) => { const isHighlight = connectedEdgeIds.includes(item.id); edges.update({ id: item.id, opacity: isHighlight ? 1 : 0.08 }); }); } network.on('selectNode', function (params) { if (!params.nodes.length) return; highlightNeighbors(params.nodes[0]); }); network.on('deselectNode', function () { nodes.forEach((item) => nodes.update({ id: item.id, opacity: 1 })); edges.forEach((item) => edges.update({ id: item.id, opacity: 1 })); });这里利用了getConnectedNodes和getConnectedEdges拿到与当前节点直接相关的节点和边,再通过DataSet的局部update改变透明度。实测下来,即使上千条边也不卡,因为只更新受影响的那些对象。
5.4 一个容易忽略的点:拖拽与布局
节点默认可以被拖拽,这在演示场景很直觉。但是如果你初始化时开了物理引擎,拖完节点松手后,图会继续因为重新计算受力而位移。很多用户第一次看到会疑惑“为什么我拖过去它又弹回去了”。
两种解法:一种是拖拽完成后把物理引擎关掉,让位置固定;另一种是在dragEnd事件里记录最终位置,同时把该节点设为fixed:
network.on('dragEnd', function (params) { if (!params.nodes.length) return; const draggedId = params.nodes[0]; const position = network.getPositions([draggedId]); nodes.update({ id: draggedId, x: position[draggedId].x, y: position[draggedId].y, fixed: true }); });这种逻辑对用户很友好——你手动摆放过的节点,就让它保持在你放的位置,其他节点继续自动排。
6. 动态更新与数据联动:真实业务下的图操作
6.1 典型业务场景:监控大屏的实时关系图
有一类需求是拓扑图要跟着后台数据实时变化。比如服务监控大屏:服务A挂了,节点要变红;新增了一个服务B,边上要多一个节点;某个服务下线了,连带着它的所有边都要消失。
这种场景最忌讳的做法是拿到全量数据就整图重绘。正确做法是用DataSet的增删改接口,只操作变化的部分。我在实际项目中维护了一个简单的数据同步函数:
const nodeMap = new Map(); function syncNodes(nodeList) { const newNodeIds = []; const updateItems = []; nodeList.forEach((item) => { newNodeIds.push(item.id); const old = nodeMap.get(item.id); if (!old) { // 新增节点 nodes.add({ id: item.id, label: item.label, group: item.group, color: item.color }); } else if (old.label !== item.label || old.color !== item.color) { // 只更新变化的字段 updateItems.push({ id: item.id, label: item.label, color: item.color }); } nodeMap.set(item.id, item); }); if (updateItems.length) { nodes.update(updateItems); } // 找出已经不存在于最新数据里的旧节点 const removedIds = [...nodeMap.keys()].filter((id) => !newNodeIds.includes(id)); if (removedIds.length) { nodes.remove(removedIds); nodeMap.forEach((value, key) => { if (removedIds.includes(key)) { nodeMap.delete(key); } }); } }这里维护了一个nodeMap来记住上次的节点状态,通过对比才知道到底哪些节点要新增、哪些要更新、哪些要删除。这套逻辑不需要改一行配置,visjs的DataSet会自动触发局部重绘。
6.2 批量操作时的性能策略
如果后台推送的频率很高,比如每三秒一次全量数据,我一般不会直接用clear()然后重建。因为clear()会导致画布清空一下,视觉上会闪。更稳的方式是:
function refreshData({ newNodes, newEdges }) { network.setOptions({ physics: { enabled: false } }); nodes.clear(); nodes.add(newNodes); // 先清边再清节点会导致短暂孤立,先清边再重新add边即可 edges.clear(); edges.add(newEdges); network.fit(); }关掉物理引擎是为了保证节点不会在新数据到达时重新“跑起来”,否则每次刷新都像在经历一场地震。fit()可以根据当前数据范围自动调整视口,把图缩放到合适大小。
6.3 节点新增时自动聚焦
还有一个体验细节:新增节点后,很多业务场景希望用户能立刻注意到它。我常用的做法是配合fit或者移动视口到新节点位置:
nodes.once('add', function (event) { const newId = event.items[0]; if (newId) { network.focus(newId, { scale: 1.2, animation: { duration: 500 } }); } });network.focus可以把视口中心移动到指定节点,并支持缩放动画。这个函数在节点很多、图很大的场景下特别实用,比用户自己找半天强多了。
6.4 动态更新背后的一个原则
我总结下来,动态更新最重要的一条原则是:能局部更新就不要全量重绘;能控制节点位置就不要让它随便乱蹦;能让用户看清楚变化就不要一次性改动太多。DataSet的存在让局部更新变得很容易,真正要做的是在业务代码里维护好数据对比逻辑,别把压力都丢给渲染层。
7. 避坑清单:这些坑我替你踩过了
7.1 容器隐藏导致空白画布
我最早在项目里遇到过一个问题:把画布放在tabs里,默认tab是第二个,结果切过去之后图是空的。原因就是初始化时容器宽度为0,canvas渲染出来的内容被裁掉了。
解决方法是尽量在容器可见的时候再初始化。如果确实需要隐藏初始化,在切换tab显示的时机再创建network实例,不要提前创建。这个坑排查起来很隐蔽,因为代码逻辑看起来完全没毛病。
7.2 切换页面不销毁实例,内存暴涨
在Vue或React单页应用里,如果组件销毁时没有调用network.destroy(),canvas和事件监听会一直留在内存里。来回切换几次,页面会明显变卡。
我的习惯是组件卸载前统一清理:
onBeforeUnmount(() => { if (network) { network.destroy(); network = null; } });这个习惯虽然简单,但对于长列表、大图的应用很关键。
7.3 中文标签渲染发虚
visjs默认字体在某些系统下渲染中文会发虚或显示不全,尤其是节点多了以后显得特别脏。我一般在全局options里配置字体:
const options = { nodes: { font: { face: 'Microsoft YaHei', size: 14, color: '#333' } }, edges: { font: { face: 'Microsoft YaHei', size: 12, align: 'middle' }, color: { color: '#999', highlight: '#ff7875', hover: '#999' } } };如果你想做跨平台一致,可以指定font.face为常见中文字体列表。节点多的时候,edge的字体显示很容易被忽略,但实际上边上的文字也是信息密度最大的地方,值得提前设定好。
7.4 大量边的卡顿优化
当边数量上到几千条,即使visjs也扛不住。我一般从几个方向下手:
- 关闭平滑边,用
edges.smooth: { enabled: false }减少计算量。 - 减少边的label,或者只在选中时显示label。
- 限制物理布局的迭代次数,比如设置
physics.stabilization.iterations: 200,避免无限迭代导致CPU持续占用。 - 尽量少用动态更新的阴影和渐变效果。
实测下来,把smooth关掉这个操作最立竿见影,尤其是边的数量大的时候。
7.5 注意click事件和selectNode事件的差异
刚开始写高亮逻辑时,我用的是click事件,结果发现点击空白区域也会触发,必须自己判断params.nodes是否为空。后来改成selectNode,代码干净很多。
还有一个小细节:selectNode的返回值里,params.nodes是选中的节点数组,params.edges是选中节点关联的边数组。如果你要拿关联边,直接用这个返回值比调用getConnectedEdges更省事。
7.6 缩放时控制手感
用户滚轮缩放时,如果速度太灵敏,很容易一下就缩到看不见图。visjs提供了interaction.zoomSpeed参数,默认值是1,我一般调到0.5左右:
const options = { interaction: { zoomView: true, zoomSpeed: 0.5 } };如果不希望用户缩放,直接把zoomView设成false。这个选项在大屏展示场景很常见——只允许用户拖动查看,不允许缩放破坏整体布局。
7.7 物理引擎稳定后的“定格”处理
有些图打开之后会一直缓慢漂移,怎么都不愿意稳定。这是因为有些节点之间形成了闭环,受力在局部无法达到平衡。这时候我会在stabilizationIterationsDone事件里把物理引擎关闭:
network.once('stabilizationIterationsDone', function () { network.setOptions({ physics: { enabled: false } }); });这里用once而不是on,表示只监听第一次稳定完成,之后由用户手动拖拽。这个技巧对于大量环形依赖关系的数据效果非常明显,图会变得非常安静,不再自己动来动去。
7.8 调试利器:独立配置面板
最后分享一个很实用的小工具。visjs的configure模块可以生成一个UI控制面板,实时调整物理和交互参数:
const options = { configure: { enabled: true, filter: 'physics,layout', showButton: false } };开发阶段开着它,调布局、调颜色、调文字样式都会立刻生效。等调好了再把这个选项去掉,正式环境就不需要了。这个功能帮我节省了大量“改代码-刷新-看效果”的时间,尤其是调物理参数时,效果几乎是即时的。
从我实际操作的经验来看,visjs做关系拓扑图的难点从来不在于“把图画出来”,而在于把数据模型设计好、把交互反应做对、把渲染性能控制住。只要这三层理顺了,后续不管是做服务依赖图、组织架构图还是知识图谱,都可以在这个基础上快速扩展。