最近在 Vue3 项目中把一个原生 HTML 版知识图谱迁移成 Vue 组件。原始 HTML 使用 vis-network 实现节点关系图,点击节点可以正常展开下一级。但迁移到 Vue 项目后,图谱可以正常渲染,节点、连线、布局都没有问题,唯独点击节点时无法展开下一级。
最开始以为是 toggleNode 展开逻辑出问题,或者节点数据里没有 children。但经过排查后发现,真正的问题不在数据,也不在展开逻辑,而是在点击坐标命中发生了偏移。
问题现象
Vue 页面中,知识图谱可以正常显示:
-
节点正常渲染;
-
连线正常渲染;
-
拖拽画布正常;
-
缩放正常;
-
数据中的
tree和children都存在; -
HTML 原版运行正常。
但是点击节点时,控制台一直输出:
[VisNetworkGraph] 未命中节点: { x: 832, y: 390.38888931274414 }
也就是说,vis-network 的点击事件确实触发了,但 params.nodes 一直为空。
典型代码如下:
network.on('click', params => {
if (!params.nodes.length) return
const nodeId = params.nodes[0]
showDetail(nodeId)
toggleNode(nodeId)
})
这段逻辑本身没有错。问题在于:用户视觉上点中了节点,但 vis-network 内部判断没有点中任何节点,所以 params.nodes.length === 0,最终直接 return,导致 toggleNode() 根本没有执行。
排查方向
一开始重点检查了以下几类问题:
1. 节点数据是否有 children
检查 allNodesFlat 之后确认节点数据是存在的,children 也存在。所以不是数据结构问题。
2. addNodeToGraph 是否正常
初始化节点和边都能正常显示,说明 nodesDS.add() 和 edgesDS.add() 正常。
3. toggleNode 是否执行
加日志后发现,点击时甚至没有进入 toggleNode(),因为 params.nodes 是空的。
4. 是否被遮挡元素拦截
检查图例、详情面板等元素后,确认图例设置了:
pointer-events: none;
所以不是 DOM 遮挡导致的。
5. 是否和 Vue 项目大屏适配有关
项目中使用了大屏适配方案,例如 autofit.js,或者外层容器存在类似:
transform: scale(...)
这时问题就比较明确了。
根本原因
根本原因是:
Vue 项目外层布局 / autofit / transform 缩放导致 vis-network 点击坐标和 canvas 实际坐标不一致。
原生 HTML 页面正常,是因为页面没有经过 Vue 项目的大屏缩放、rem 适配、外层 transform 等处理。
迁移到 Vue 项目后,如果外层容器被缩放,浏览器中鼠标点击的 clientX/clientY 和 vis-network 内部 canvas 坐标之间就会出现偏差。
这时 params.pointer.DOM 可能已经不是准确的容器内坐标。结果就是:
network.getNodeAt(params.pointer.DOM)
拿不到节点。
params.nodes
也一直为空。
所以表现出来就是:看起来点中了节点,但实际内部命中检测点到了空白区域。
解决方案
不要直接使用 params.pointer.DOM,而是基于原始鼠标事件的 clientX/clientY,结合图谱容器的 getBoundingClientRect() 手动修正点击坐标。
核心代码如下:
const getNormalizedDomPointer = params => {
if (!networkRef.value) return params?.pointer?.DOM || null
const srcEvent = params?.event?.srcEvent || params?.event
const rect = networkRef.value.getBoundingClientRect()
const domWidth = networkRef.value.clientWidth
const domHeight = networkRef.value.clientHeight
if (
srcEvent &&
typeof srcEvent.clientX === 'number' &&
typeof srcEvent.clientY === 'number' &&
rect.width &&
rect.height
) {
return {
x: (srcEvent.clientX - rect.left) * (domWidth / rect.width),
y: (srcEvent.clientY - rect.top) * (domHeight / rect.height)
}
}
return params?.pointer?.DOM || null
}
这里的关键点是:
x: (srcEvent.clientX - rect.left) * (domWidth / rect.width)
y: (srcEvent.clientY - rect.top) * (domHeight / rect.height)
rect.width / rect.height 是经过 transform 后浏览器实际显示出来的尺寸。
clientWidth / clientHeight 是 DOM 元素自身的布局尺寸。
两者一换算,就可以把视觉点击位置转换回 vis-network 需要的真实 DOM 坐标。
增加节点命中兜底逻辑
仅仅修正坐标还不够稳,因为节点较小、缩放较远时,点击文字附近也可能命中失败。
所以又加了一层"最近节点兜底命中":
const getNearestNodeByPointer = domPointer => {
if (!network || !domPointer) return null
const nodeIds = nodesDS.getIds()
if (!nodeIds.length) return null
const canvasPoint = network.DOMtoCanvas(domPointer)
const positions = network.getPositions(nodeIds)
const scale = network.getScale() || 1
const maxDistance = 80 / scale
let nearestNodeId = null
let nearestDistance = Infinity
nodeIds.forEach(id => {
const position = positions[id]
if (!position) return
const dx = position.x - canvasPoint.x
const dy = position.y - canvasPoint.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance <= maxDistance && distance < nearestDistance) {
nearestDistance = distance
nearestNodeId = id
}
})
return nearestNodeId
}
逻辑是:
-
把修正后的 DOM 坐标转换成 canvas 坐标;
-
获取当前所有节点的位置;
-
找到距离点击位置最近的节点;
-
如果距离在容错范围内,就认为命中了该节点。
这样即使 params.nodes 没有直接命中,也可以通过最近节点补救。
最终点击逻辑
最终不要再直接写:
const nodeId = params.nodes[0]
而是封装一个统一方法:
const getClickNodeId = params => {
if (!network || !params) return null
if (params.nodes && params.nodes.length) {
return String(params.nodes[0])
}
const pointer = getNormalizedDomPointer(params)
if (!pointer) return null
const nodeAt = network.getNodeAt(pointer)
if (nodeAt !== undefined && nodeAt !== null) {
return String(nodeAt)
}
return getNearestNodeByPointer(pointer)
}
点击事件改成:
network.on('click', params => {
const nodeId = getClickNodeId(params)
if (!nodeId) {
console.log('[VisNetworkGraph] 未命中节点:', {
rawPointer: params.pointer?.DOM,
fixedPointer: getNormalizedDomPointer(params),
scale: network?.getScale?.()
})
return
}
showDetail(nodeId)
toggleNode(nodeId)
})
这样就能兼容:
-
原生命中节点;
-
transform 缩放后的坐标修正;
-
点击节点附近的容错命中。
另一个需要注意的点:ResizeObserver 不要频繁 fit
迁移成 Vue 组件后,一般会加 ResizeObserver 监听容器大小。如果在里面反复调用:
network.fit()
也容易导致画布重算、视图跳动、甚至 canvas 高度异常变大。
不建议这样写:
resizeObserver = new ResizeObserver(() => {
network.redraw()
network.fit()
})
更推荐只设置尺寸和重绘:
resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
const width = networkRef.value.clientWidth
const height = networkRef.value.clientHeight
network.setSize(`${width}px`, `${height}px`)
network.redraw()
})
})
fit() 只在初始化数据加载完成后执行一次即可。
总结
这次问题的关键结论是:
图谱显示正常,不代表点击坐标一定正常。
在 Vue 大屏项目里,如果页面外层使用了:
-
autofit.js -
transform: scale(...) -
rem / vw / vh 适配
-
大屏整体缩放容器
-
CSS zoom
-
自定义画布缩放布局
就要特别注意 canvas 类图形库的事件坐标问题。
vis-network、ECharts、Three.js、Fabric.js、Konva 等基于 canvas 或 WebGL 的库,都可能遇到类似问题。
排查这类问题时,不要只看:
click 事件有没有触发
还要看:
点击坐标是否和图形库内部坐标一致
最终解决思路是:
-
用
clientX/clientY获取浏览器真实点击位置; -
用
getBoundingClientRect()获取缩放后的容器位置和尺寸; -
用
clientWidth/clientHeight换算回图形库需要的 DOM 坐标; -
再交给图形库的命中检测方法;
-
必要时增加最近节点容错逻辑。
一句话总结:
HTML 原版正常,Vue 大屏版不正常,不一定是业务逻辑错了,很可能是外层缩放把 canvas 点击坐标搞偏了。