这两年做前端可视化,不管是后台系统、数据中台还是大屏项目,总会撞上同一个需求:把人和人、服务和服务、数据之间的依赖关系画成一张能拖能点的拓扑图。翻了半天方案,最后留在项目里长期用的还是visjs。前前后后用它做了权限流转关系、服务调用链、组织架构映射好几个模块,踩了不少坑,整理一篇实操向的记录,给同样在用visjs画关系图的朋友做个参考。
visjs在关系拓扑图这个细分场景里,定位其实非常明确:零依赖、开箱即用、交互手感好,尤其适合那种“今天下午就要看到Demo、明天就要交付”的中后台需求。不需要像d3一样自己推演力学模型,也不像canvas自绘那么低层,本质上是一个高封装度的可视化引擎,内部已经实现了物理模拟、拖拽、缩放、点击事件这些底层逻辑,开发者只需要喂数据、写配置即可。适合的对象很宽:前端实习生能上手,老后端也能用它快速拼出可交互的拓扑页面,核心优势就是“快”。
项目里最常用的组合是vis-network加vis-data。最早的vis.js大包在4.21.0之后就不再整体维护了,官方把能力拆成了vis-network、vis-data、vis-timeline这几个独立模块,关系图的入口就是vis-network,而数据响应式管理通常交给vis-data。这一步不搞清楚,后面经常会出现“引入vis.js但是找不到Network构造函数”这种尴尬问题。
1. 为什么选visjs做关系拓扑图
1.1 需求场景与选型对照
先说场景。我这里的“关系拓扑图”不是画着好看的装饰页,而是要承载实际业务逻辑的:节点代表某个服务、某个角色或者某个数据资产,边代表调用、依赖、流转关系。用户需要拖拽节点、缩放视图、点击查看详情,甚至在线编辑连接关系,然后把编辑结果传回后端保存。
这类需求如果交给通用图表库,比如echarts的关系图,能画、能展示,但交互上限很低。节点的拖动是“先选中再拖”,拖完之后物理布局一旦重新计算,很容易把手工调整的位置打乱;点击事件、动态增删节点也不是它的强项。d3自由度最高,但想达到同样的交互体验,开发量至少是visjs的三倍,维护成本也高。antv G6其实是个不错的对手,功能全面,但SDK体积和学习曲线都偏重,对“快速交付、不折腾”的项目来说,visjs更轻。
用表格做一个直观对比:
| 维度 | visjs | echarts | d3 | antv G6 |
|---|---|---|---|---|
| 上手成本 | 极低,读一遍示例即可 | 低,适合标准图表 | 高,需理解enter/exit | 中高,概念较多 |
| 关系图交互(拖拽、缩放) | 内置且顺滑 | 较弱,拖拽需要自己维护 | 全部自行实现 | 内置较全 |
| 动态增删节点边 | DataSet支持,非常方便 | 需要setOption整体更新 | 需手写数据绑定更新 | 支持但代码量不小 |
| 物理引擎/布局算法 | 内置多种,可调参数 | 有,但调整空间小 | 自行实现,成本高 | 内置布局丰富 |
| 适用项目 | 中后台、可视化大屏 | 报表、通用图表 | 定制可视化、研究型项目 | 复杂图形应用 |
visjs最打动我的一点是它的物理引擎是真的“能感觉到手感”的。节点之间像有弹簧和斥力,拖动的时候会有一种自然回弹,这种交互细节很加用户体验分,在展示型项目里尤其吃香。而且它的缩放和拖拽整个图的性能,在几千条边的规模下依然能保持60帧左右的流畅度,对大多数业务拓扑图来说绰绰有余。
1.2 visjs模块拆分与版本坑
现在从npm装visjs,务必要认准拆分包。老教程里import vis from 'vis'的写法,在新版npm包里已经不再适用。正确姿势是:
npm install vis-network vis-data或者用CDN方式加载:
<link rel="stylesheet" href="https://unpkg.com/vis-network/styles/vis-network.min.css"> <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>注意这里vis-network的standalone版本已经内置了DataSet,所以如果走CDN就只需要引一个文件;走npm则通常要额外装vis-data来做数据管理。
版本上,当前稳定的主版本是9.x,也就对应着vis-network9系列。8.x早期会在一些浏览器上出现交互兼容问题,9.x修复了不少。建议直接上最新稳定版,不要被老博客里的4.21.0迷惑。
import { Network } from 'vis-network'; import { DataSet } from 'vis-data';这里有个容易忽略的点:vis-network的样式文件必须引。如果不引入CSS,默认的节点边框、字体大小都可能异常,尤其阴影和背景色会错乱,看起来像是丑化版。很多“为什么渲染出来这么丑”的提问,其实都是没引样式导致的。
2. 数据准备与样式配置
2.1 节点和边的基础数据结构
vis-network的数据模型很直观:节点是数组里的对象,边是另一个数组里的对象,两个数组各自至少要有 id 和 from/to 字段。
const nodes = [ { id: 1, label: '用户中心', group: 'system' }, { id: 2, label: '订单服务', group: 'service' }, { id: 3, label: '支付网关', group: 'service' } ]; const edges = [ { from: 1, to: 2, label: '调用', arrows: 'to' }, { from: 2, to: 3, label: '调用', arrows: 'to' } ];实际业务中,后端返回的往往是嵌套的JSON,比如菜单树、权限关系列表,需要前端做一层映射转换。我一般会写一个纯函数,把后端数据结构转换为vis需要的nodes和edges。
function transformPermissions(data) { const nodes = []; const edges = []; data.forEach((item) => { nodes.push({ id: item.id, label: item.name, group: item.type || 'default' }); if (item.parentId) { edges.push({ from: item.parentId, to: item.id, label: item.relation || '包含', arrows: 'to' }); } }); return { nodes, edges }; }核心思想是“扁平化”:后端给的树形结构,最终都要摊平成两张表,一张存节点信息,一张存节点之间的关联。这样后续动态增删、局部刷新都清晰。
用一个DataSet来管理数据是我强烈推荐的做法。DataSet是vis-data提供的响应式数据容器,当你对nodesData做add、update、remove操作时,画布会自动感应变化并重新渲染,不需要手动重绘整个network。
const nodesDataSet = new DataSet(transformedNodes); const edgesDataSet = new DataSet(transformedEdges);实际经验:你对DataSet做的每一次增删改,network都会执行一次局部更新,性能上优于整体重绘。尤其是那种“点击节点,右侧弹层出现详情”的场景,配合DataSet会很顺手。
2.2 样式、分组与字体图标细节
vis-network的样式体系分三层:全局限定、分组覆盖、单个节点覆盖。优先级从低到高,意味着你既可以在全局定一套颜色风格,又可以对某个分组定制形状和配色,最后还能对单个关键节点做特殊标记。
常见的全局配置块长这样:
const options = { nodes: { shape: 'dot', size: 18, borderWidth: 2, shadow: true, font: { size: 14, color: '#333', face: 'Microsoft YaHei' } }, edges: { width: 1, color: '#999', smooth: { type: 'dynamic' }, arrows: { to: { enabled: true, type: 'arrow', scaleFactor: 0.8 } } }, physics: false, interaction: { hover: true, tooltipDelay: 150 } };需要指出的是:节点的shape不只是dot、box、ellipse这些基础形状,还可以塞图片和图标字体。如果项目是内部前端框架,通常都会自带iconfont,可以这样用:
const options = { nodes: { shape: 'icon', icon: { face: 'iconfont', code: '\ue657', size: 42, color: '#409eff' } } };这条配置对做权限中心的模块特别有用,角色、菜单、按钮各配一个图标,画出来整体观感立刻不一样了。不过iconfont的字体文件要确保跨域加载正常,否则节点中心会是一片空白。
分组是另一个很省事的配置:
const options = { groups: { system: { shape: 'box', color: '#5470c6' }, service: { shape: 'dot', color: '#fac858' }, database: { shape: 'database', color: '#91cc75' } } };只要在节点数据里带上 group 字段,渲染时就会自动套用对应样式。这样“后端服务、前端应用、数据库、消息队列”不同类型节点就能一眼区分。
3. 布局与物理引擎的调参心得
3.1 树形布局与自由布局的选择
vis-network的布局方式大致分两类:带层级关系的hierarchical布局,和完全由物理引擎自由扩散的布局。选错布局是新手最容易翻车的地方。
如果数据天然是树,比如组织架构、权限父子关系,用hierarchical布局是最合适的:
const options = { layout: { hierarchical: { direction: 'LR', sortMethod: 'directed', levelSeparation: 150, nodeSpacing: 120 } }, physics: false };direction决定树的生长方向,LR是左到右,UD是上到下,DU是下到上。调试的时候注意,hierarchical布局下必须关闭物理引擎,否则会在初始化后把已经排好的树打乱,节点全部“飞走”。这是官方尚未完全规避的一个交互坑:当physics为true且layout为hierarchical时,树形结构可能先渲染后突然乱动。
如果数据是网状关系,比如服务调用链、知识图谱、人物关系,那么自由布局更合适:
const options = { layout: { randomSeed: 7 }, physics: { barnesHut: { gravitationalConstant: -8000, springLength: 120, springConstant: 0.03, damping: 0.4 }, stabilization: { iterations: 300 } } };这里的核心逻辑是:引力常量调节节点之间的斥力,弹簧长度影响边的理想长度,阻尼控制运动衰减速度。数值越大,图越散;数值越小,图越紧凑。每次初始化网络,物理引擎会先迭代几百轮让图“稳定下来”,这个过程的耗时跟节点数和边数成正比,所以当节点量很大时,开启stabilization就是一把双刃剑——虽然稳定后好看,但初始化期间会有几秒钟的“群魔乱舞”,用户容易误以为页面卡死。
3.2 怎么让节点不乱跑
动态增删节点是拓扑图里最常见的需求,比如点击“新增服务”按钮,前端往DataSet里push一个node,图马上就多了一个节点。但随之而来的问题是:新增的节点会影响整个图的受力平衡,导致已有节点全部重新排位,视觉效果上好像图在“抽搐”。
解决办法有两条路,取决于你的场景。
一种是:如果拓扑图只是纯展示,不需要用户拖拽调整位置,那就干脆直接关闭物理引擎:
const options = { physics: false, layout: { improvedLayout: true } };vis-network在关闭物理引擎后,依然会执行一次改进的布局算法,不需要手动定位,节点也会分布得比较均匀。这是最省心也最稳的做法。
另一种是:用户必须能拖拽,但拖完之后不希望新加节点干扰已有布局。可以在拖拽结束事件里把物理引擎立刻关掉:
network.on('dragEnd', function () { network.physics.enable(false); });拖拽过程中保留物理引擎是为了让拖动有粘性手感,一旦松手就冻结布局,后续再增节点也不会因为力学模拟导致全局重排。实测这个方法在业务系统里特别管用,用户拖出来的自定义排版不会因为一次数据刷新而丢失。
3.3 大数据量的预处理策略
当节点超过500个、边超过2000条时,即便开了物理引擎也卡。这时我会在渲染前做两件事。
第一件事是“裁剪”:只展示跟当前根节点或选中节点有关联的一层或两层路径节点。权限拓扑图里,一个部门挂几十个角色,全部画出来的意义不大,用户要的往往是“这个服务影响哪些下游服务”,做二级剪枝后数据量能少掉一半以上。
第二件事是“聚类”:把不需要展示内部结构的一组节点戴上一个“集群节点”的外壳。vis-network本身没有暴力的集群API,但可以直接用一个顶层节点替代其子节点,点击时再展开。这个方案实现简单,效果却很直观:
function clusterNodes(network, clusterId, memberIds) { network.cluster({ joinNodes: memberIds, clusterNode: { id: clusterId, label: `集群(${memberIds.length})`, shape: 'box', color: '#ff7875' } }); }4. 交互事件与外部联动
4.1 常用事件监听与回调参数
visjs的核心交互能力都暴露在network实例的事件上。最常用的几个事件:
network.on('click', function (params) { const { nodes, edges } = params; if (nodes.length > 0) { const nodeId = nodes[0]; const nodeData = nodesDataSet.get(nodeId); // 在这里用nodeData刷新右侧详情面板 } }); network.on('doubleClick', function (params) { // 双击画布空白处,重置视角 if (params.nodes.length === 0) { network.moveTo({ position: { x: 0, y: 0 }, scale: 1 }); } }); network.on('deselectNode', function () { // 清空高亮 });click事件的回调里params.nodes是选中节点id数组,params.edges是选中边id数组,params.pointer.DOM是鼠标相对于view的坐标。这些数据组合起来,足够做一整套完整的业务交互:点击节点右侧弹详情,点击边展示调用耗时,点击空白区域清空选中状态。
需要注意的是:vis-network内部默认就有“点击选中”的交互行为,如果同时配置了interaction.selectable为true,那么点击会触发选中效果和click事件,两者是并存的,不需要手动维护高亮状态。但也因为如此,如果你是纯“点击弹层”场景,不希望节点停留在选中高亮态,可以加上:
interaction: { selectable: false }这样点击事件依然触发,但没有持久的高亮选中态。这个细节在一些产品要求“点击不要变色”的项目里很有用。
4.2 自定义右键菜单与外部联动
vis-network没有任何内置的右键菜单能力,但实现起来也不复杂,核心是监听画布容器的contextmenu事件,拿到坐标后计算最近的节点,再弹出一个自绘的div菜单。
container.addEventListener('contextmenu', function (event) { event.preventDefault(); const pointer = network.getPointer({ x: event.clientX, y: event.clientY }); const { nodes } = network.getNodesOnCanvas([pointer.x, pointer.y]); if (nodes.length > 0) { rightMenu.style.left = event.clientX + 'px'; rightMenu.style.top = event.clientY + 'px'; rightMenu.style.display = 'block'; rightMenu.dataset.nodeId = nodes[0]; } else { rightMenu.style.display = 'none'; } });右键菜单的典型动作包括:查看详情、展开下游、删除当前节点、高亮所有关联路径。这些动作本质都是对DataSet和network的组合操作。比如“高亮所有关联路径”的实现,可以这样做:
function highlightPath(nodeId) { const connectedEdges = network.getConnectedEdges(nodeId); const connectedNodes = new Set(); connectedEdges.forEach((edgeId) => { const edge = edgesDataSet.get(edgeId); connectedNodes.add(edge.from); connectedNodes.add(edge.to); }); nodesDataSet.update(connectedNodes.map((id) => ({ id, color: { background: '#ffd666' } }))); }外部联动方面,最简单的做法是:业务页面左边是搜索框,右边是拓扑图,搜索框输入关键字,前端用nodesDataSet.get()做模糊查询,再通过network.focus(nodeId, { scale: 1.2, animation: true })将视角动画移动到目标节点上。动画参数animation支持{ duration: 500, easingFunction: 'easeInOutQuad' },实测在复杂图上动画依然顺滑。
这一套组合拳下来,整个拓扑图就不是一个静态展示物了,而是真正可以融入业务工作流里的交互工具。
5. 常见问题与排查速查
5.1 节点乱序和多父节点错乱
在层级布局里,如果某个节点有多个父节点,hierarchical布局会把它放在某一个父节点下面,视觉上可能不符合预期。排查方向是先确认数据模型是否符合“树形严格父子关系”,如果一个节点确实需要挂多个父节点,那就不能用严格树布局,应该改用自由布局并在边上做物理引擎的引力约束。
另外sortMethod参数也值得注意。directed会按照边的方向确定层级,而hubsize会优先按节点的连接数决定层级。如果发现同一层节点顺序不可控,可以尝试在节点数据上设置level字段,强制指定所在层级:
nodes: [ { id: 1, label: '入口', level: 0 }, { id: 2, label: '网关层', level: 1 } ]5.2 大数据量渲染卡顿
节点上千后,连续拖动画布会出现卡顿。我的排查顺序是:先关掉物理引擎,再看是否存在shadow阴影渲染开销,最后检查是否开启了interaction.hover。阴影是性能杀手,数据量大时把它关掉,性能提升肉眼可见。
还有一种隐蔽的开销来自频繁的DataSet批量操作。如果一次性要往DataSet里塞几百条边,建议用数组一次push,而不是一条一条add:
const bulkEdges = []; edgesList.forEach((e) => bulkEdges.push(e)); edgesDataSet.add(bulkEdges);5.3 箭头消失或显示方向错误
箭头不显示最常见的坑是没有在边数据里指定arrows字段,或者只在edges全局配置里写了arrows,但具体某条边的arrows被单独覆盖成了空对象。排查时可以直接打开控制台打印某条边的完整对象,确认有没有arrows.to.enabled。方向错误则通常是因为边数据里的from/to写反了,这只能靠业务层好好校验。
5.4 工具提示闪烁不停
title字段在vis-network里默认是用浏览器原生title实现的,特点是鼠标移到节点上要等一会儿才出现,移动速度稍快就会频繁闪烁。想要一个舒服的tooltip,可以开启interaction.tooltipDelay,但它依赖title字段,依然有原生title的延迟感。更好的方案是用自定义div浮层,在hoverNode和blurNode事件里做显示和隐藏:
network.on('hoverNode', function (params) { const nodeData = nodesDataSet.get(params.node); tipEl.innerHTML = `<b>${nodeData.label}</b><br/>${nodeData.description || ''}`; tipEl.style.left = params.pointer.DOM.x + 'px'; tipEl.style.top = params.pointer.DOM.y + 'px'; tipEl.style.display = 'block'; }); network.on('blurNode', function () { tipEl.style.display = 'none'; });自定义tooltip的优点是样式完全可控、响应速度零延迟,缺点是坐标要自己对齐,如果画布有缩放,还需要把DOM坐标转换为画布坐标。实际项目里我都是直接自定义tooltip,原生title只作为兜底。
5.5 动态增删后布局变形
部分场景在动态添加节点后布局突然“坍塌”。定位思路是:是否在执行增删后调用了network.stabilize()或者network.redraw()。前者会重新触发完整的物理学稳定过程,非常容易打乱现有布局;后者只是重绘,不会重新计算布局。所以如果只是想让新节点定位在合理位置,可以手动给新节点设定坐标,再关闭物理引擎微调。
const graph = graph.value.network; const nodesData = graph.nodesData; nodesData.add({ id: newId, label: '新服务', x: 300, y: 200 }); graph.network.body.emitter.emit('_requestRedraw');set节点的x、y坐标,相当于给新节点一个初始锚点,这样即使物理引擎还在运行,它也会落在指定位置附近,后续受力再小幅调整,不会整个图四散。
6. 我对visjs使用的一些个人建议
visjs不是那种“什么都能做”的重型框架,但它在关系拓扑图这个细分方向上的完成度确实非常高。实际项目中,我用它实现的权限流转图可以做到50毫秒内完成一次增删操作渲染,这在很多重型可视化库里都要做额外的性能优化才能达到。它的代码风格偏过程式,API设计也相对扁平,读起来不费劲,维护成本低。
在正式开始大项目前,我强烈建议先用官方示例里的Stabilization和Clustering两个例子跑一遍,一个验证布局稳定性,一个验证大数据量聚类展缩。这两个例子能覆盖八成以上业务场景的核心需求,看懂它们,比翻几十篇博客管用得多。
如果后续要做的是强编辑型的在线拓扑编辑器,visjs也会是一个不错的底层依赖,配上一个工具栏、一个属性面板、一个保存按钮,完全可以自研出一套轻量级的图编辑工具。至少在这条路上,它是我目前用过最顺手的起点。