【React】动态SVG连接线实现:图片与按钮的可视化映射
目录
- 前言
- 一、实现步骤
- 1. 准备DOM引用
- 2. 计算连接线路径
- 3. 渲染连接线
- 4. 添加交互效果
- 5. 响应窗口变化和滚动
- 6. 优化路径计算(曲线路径)
- 二、核心知识点解析
- 1. DOM位置获取
- 2. SVG路径绘制
- 3. React Refs系统
- 4. 事件处理
- 5. 性能优化
- 三、完整组件示例 + 运行效果
- 四、高级优化技巧
- 1. 性能优化
- 2. 动画效果
- 3. 响应设计
- 4. 虚拟化渲染
前言
本文基于React 18 + TypeScript环境,详细讲解如何在SVG图形和按钮之间实现动态连接线。这种连接线不是通过拖拽改变,而是基于数据配置自动生成。
核心在于根据用户在配置弹窗中的选择,动态生成并渲染图标与按钮之间的连接线。
提示:以下是本篇文章正文内容,下面案例可供参考
一、实现步骤
实现SVG连接线的关键步骤:
(1)准备DOM引用:为端口和出口创建Ref
(2)计算路径:基于元素位置计算SVG路径
(3)渲染连线:使用SVG元素
(4)添加交互:实现悬停高亮效果
(5)响应变化:监听窗口大小和滚动事件
1. 准备DOM引用
首先需要为每个端口和出口创建DOM引用,以便获取它们的位置信息:
// 创建Refs存储端口和出口的DOM元素
const portNodeRefs = useRef<Record<string, HTMLElement | null>>({});
const exitNodeRefs = useRef<Record<string, HTMLElement | null>>({});
const containerRef = useRef<HTMLDivElement | null>(null);// 在渲染函数中绑定Ref
{ports.map((port) => (<divkey={port.name}ref={(el) => (portNodeRefs.current[port.name] = el)}// ...其他属性>{/* 端口图标 */}</div>
))}{landingOptions.map((option) => (<divkey={option.value}ref={(el) => (exitNodeRefs.current[option.value] = el)}// ...其他属性>{/* 出口按钮 */}</div>
))}
2. 计算连接线路径
这是核心步骤,需要计算每个端口到其分配出口的连线路径:
const recomputeLines = () => {const container = containerRef.current;if (!container) return;const containerRect = container.getBoundingClientRect();const newLines: { path: string; port: string; exit: string }[] = [];// 遍历所有端口和其分配的出口Object.entries(portLandingMap).forEach(([portName, exits]) => {exits?.forEach((exitVal) => {const portEl = portNodeRefs.current[portName];const exitEl = exitNodeRefs.current[exitVal];if (!portEl || !exitEl) return;// 获取端口和出口的位置const portRect = portEl.getBoundingClientRect();const exitRect = exitEl.getBoundingClientRect();// 计算相对容器的坐标const startX = portRect.left - containerRect.left + portRect.width / 2;const startY = portRect.bottom - containerRect.top;const endX = exitRect.left - containerRect.left + exitRect.width / 2;const endY = exitRect.top - containerRect.top;// 计算路径(这里使用简单的折线,实际可优化为曲线)const path = `M ${startX} ${startY} L ${endX} ${endY}`;newLines.push({ path, port: portName, exit: exitVal });});});setLines(newLines);
};
3. 渲染连接线
在组件中渲染SVG路径:
<div ref={containerRef} style={{ position: 'relative' }}>{/* 端口和出口的渲染 */}{/* SVG容器用于绘制连接线 */}<svgstyle={{position: 'absolute',top: 0,left: 0,width: '100%',height: '100%',pointerEvents: 'none', // 避免连线拦截鼠标事件}}>{lines.map((line, index) => (<pathkey={index}d={line.path}stroke="#1f78ff" // 线条颜色strokeWidth="2" // 线条宽度fill="none"/>))}</svg>
</div>
4. 添加交互效果
实现鼠标悬停时高亮相关连线的效果:
// 状态记录当前悬停的连线
const [hoverLineKey, setHoverLineKey] = useState<string | null>(null);// 在渲染连线时添加交互
{lines.map((line, index) => {const lineKey = `${line.port}-${line.exit}`;return (<pathkey={index}d={line.path}stroke={hoverLineKey === lineKey ? '#ff0000' : '#1f78ff'}strokeWidth={hoverLineKey === lineKey ? 3 : 2}fill="none"onMouseEnter={() => setHoverLineKey(lineKey)}onMouseLeave={() => setHoverLineKey(null)}/>);
})}
5. 响应窗口变化和滚动
确保在窗口大小变化或容器滚动时重新计算连线位置:
useEffect(() => {const handleResize = () => recomputeLines();window.addEventListener('resize', handleResize);const container = containerRef.current;if (container) {container.addEventListener('scroll', recomputeLines);}return () => {window.removeEventListener('resize', handleResize);if (container) {container.removeEventListener('scroll', recomputeLines);}};
}, []);
6. 优化路径计算(曲线路径)
更美观的贝塞尔曲线实现:
// 计算曲线路径
const controlPointY = (startY + endY) / 2;
const path = `M ${startX} ${startY} C ${startX} ${controlPointY}, ${endX} ${controlPointY}, ${endX} ${endY}`;
二、核心知识点解析
1. DOM位置获取
使用getBoundingClientRect()获取元素位置和尺寸:
// 关键计算步骤:
const containerRect = container.getBoundingClientRect();
const portRect = portEl.getBoundingClientRect();// 计算相对容器位置的坐标
const px = portRect.left - containerRect.left + portRect.width / 2;
const py = portRect.bottom - containerRect.top;
2. SVG路径绘制
SVG
元素的d属性定义路径:
M x y:移动到指定坐标
L x y:画直线到指定坐标
C x1 y1, x2 y2, x y:三次贝塞尔曲线
// SVG路径命令:
// M = moveto (起点)
// L = lineto (直线)
// C = curveto (贝塞尔曲线)
// Z = closepath (闭合路径)const path = `M ${startX} ${startY} L ${midX} ${midY} L ${endX} ${endY}`;
3. React Refs系统
三种Ref使用场景:
DOM元素引用:useRef(null)+ ref={ref}
存储可变值:替代类组件的实例变量
转发Refs:forwardRef访问子组件DOM
// 动态ref管理模式
const refs = useRef<Record<string, HTMLElement | null>>({});// 在渲染循环中绑定
{elements.map(item => (<divkey={item.id}ref={(el) => {refs.current[item.id] = el;}}/>
))}
4. 事件处理
React合成事件系统:
使用onMouseEnter/onMouseLeave代替原生事件
事件委托提高性能
使用useEffect清理事件监听器
5. 性能优化
// 1. 使用useLayoutEffect避免闪烁
useLayoutEffect(() => {recomputeLines();
}, [dependencies]);// 2. 防抖处理
const debouncedRecompute = useMemo(() => debounce(recomputeLines, 100),[]
);// 3. 条件重计算
const shouldRecompute = useMemo(() => {return JSON.stringify(portLandingMap) !== lastMappingRef.current;
}, [portLandingMap]);
三、完整组件示例 + 运行效果
📢注意:我现在给的这个套完整示例,仅作为参考,跟上面的步骤还是不太一样的,比如缺少编辑弹窗等,但是为了方便直接运行,看到连线效果,改动后如下:
// App.tsx
import React, { useRef, useState, useLayoutEffect, useCallback, useMemo } from 'react';interface Port {name: string;isWan?: boolean;active?: boolean;
}interface LandingOption {value: string;label: string;
}interface ConnectionLine {path: string;port: string;exit: string;
}const PortConnectionDemo: React.FC = () => {// 状态管理const [ports] = useState<Port[]>([{ name: 'eth0', isWan: true, active: true },{ name: 'eth1', isWan: false, active: true },{ name: 'eth2', isWan: false, active: false },{ name: 'eth3', isWan: false, active: true },]);const [landingOptions] = useState<LandingOption[]>([{ value: 'LA', label: 'Los Angeles' },{ value: 'SG', label: 'Singapore' },{ value: 'NY', label: 'New York' },]);const [portLandingMap] = useState<Record<string, string[]>>({eth1: ['LA', 'SG'],eth3: ['NY'],});// DOM引用const portNodeRefs = useRef<Record<string, HTMLDivElement | null>>({});const exitNodeRefs = useRef<Record<string, HTMLDivElement | null>>({});const containerRef = useRef<HTMLDivElement>(null);const linesSvgRef = useRef<SVGSVGElement>(null);// 连线状态const [lines, setLines] = useState<ConnectionLine[]>([]);const [hoverLineKey, setHoverLineKey] = useState<string | null>(null);// 核心连线计算函数const recomputeLines = useCallback(() => {const container = containerRef.current;if (!container) {if (lines.length) setLines([]);return;}const containerRect = container.getBoundingClientRect();const basePortRadius = 5;const minExitRadius = 4;const exitRadiusCache: Record<string, number> = {};const newLines: ConnectionLine[] = [];const exitBusYCache: Record<string, number> = {};const exitConnections: Record<string, string[]> = {};// 建立出口连接关系映射Object.keys(portLandingMap).forEach((portName) => {const exits = portLandingMap[portName];exits?.forEach((exitVal) => {if (!exitConnections[exitVal]) exitConnections[exitVal] = [];exitConnections[exitVal].push(portName);});});// 计算每个出口的总线Y坐标const getBusYForExit = (exitVal: string): number => {if (exitBusYCache[exitVal] !== undefined) return exitBusYCache[exitVal];const exitEl = exitNodeRefs.current[exitVal];const eyTop = exitEl ? exitEl.getBoundingClientRect().top - containerRect.top : 0;const connectedPorts = Object.keys(portLandingMap).filter((portName) =>portLandingMap[portName]?.includes(exitVal));const portBottoms: number[] = [];connectedPorts.forEach((portName) => {const portEl = portNodeRefs.current[portName];if (portEl) {portBottoms.push(portEl.getBoundingClientRect().bottom - containerRect.top);}});const avgPortBottom = portBottoms.length? portBottoms.reduce((a, b) => a + b, 0) / portBottoms.length: eyTop - 50;// 40% 靠上位置,避免总线过低const busY = Math.round(avgPortBottom + (eyTop - avgPortBottom) * 0.4);exitBusYCache[exitVal] = busY;return busY;};// 为每个端口到出口计算路径for (const portName of Object.keys(portLandingMap)) {const exits = portLandingMap[portName];if (!exits || exits.length === 0) continue;const portEl = portNodeRefs.current[portName];if (!portEl) continue;const portRect = portEl.getBoundingClientRect();const startX = portRect.left - containerRect.left + portRect.width / 2;const startY = portRect.bottom - containerRect.top;// 检查端口是否在容器可见范围内if (startX < 0 || startX > containerRect.width) continue;for (const exitVal of exits) {const exitEl = exitNodeRefs.current[exitVal];if (!exitEl) continue;const exitRect = exitEl.getBoundingClientRect();const endX = exitRect.left - containerRect.left + exitRect.width / 2;const endY = exitRect.top - containerRect.top;// 检查出口是否在容器可见范围内if (endX < 0 || endX > containerRect.width) continue;const direction = endX >= startX ? 1 : -1;const busY = getBusYForExit(exitVal);// 计算分层偏移,避免重叠const connectedPorts = exitConnections[exitVal] || [];const laneIndex = connectedPorts.indexOf(portName);const laneCount = connectedPorts.length || 1;const laneSpread = Math.min(26, Math.max(8, laneCount * 5));const laneOffset = laneCount === 1 ? 0 : (laneIndex / (laneCount - 1) - 0.5) * laneSpread;const laneBusY = busY + laneOffset;// 计算圆角半径const downSpace = laneBusY - startY;const upSpace = endY - laneBusY;let portRadius = Math.min(basePortRadius,Math.max(3, Math.floor(Math.min(downSpace, upSpace) * 0.55)));if (Math.min(downSpace, upSpace) < basePortRadius * 1.6) {portRadius = Math.min(portRadius, Math.max(2, Math.floor(Math.min(downSpace, upSpace) * 0.45)));}const canUsePortArc = downSpace > portRadius * 1.8 && portRadius >= 2;const portVertEndY = Math.max(startY + 4, laneBusY - portRadius);const sweep1 = direction > 0 ? 0 : 1;const sweep2 = direction > 0 ? 1 : 0;// 出口侧圆角计算if (exitRadiusCache[exitVal] === undefined) {const upSpaceAtExit = endY - busY;let exitRadius = Math.min(Math.max(minExitRadius, portRadius), Math.floor(upSpaceAtExit * 0.55));if (upSpaceAtExit < portRadius * 1.6) {exitRadius = Math.min(exitRadius, Math.max(minExitRadius, Math.floor(upSpaceAtExit * 0.45)));}// 协调端口和出口圆角if (Math.abs(exitRadius - portRadius) > 3) {exitRadius = exitRadius > portRadius ? portRadius + 3 : exitRadius;}exitRadiusCache[exitVal] = Math.max(minExitRadius, exitRadius);}let exitRadius = exitRadiusCache[exitVal];const exitLaneUpSpace = endY - laneBusY;if (exitLaneUpSpace < exitRadius + 6) {const availableSpace = exitLaneUpSpace - 4;exitRadius = availableSpace >= minExitRadius ? Math.min(exitRadius, availableSpace) : 0;}const canUseExitArc = exitRadius >= minExitRadius;const midStartX = startX + direction * portRadius;const midEndX = endX - direction * (canUseExitArc ? exitRadius : 0);let useSimple = false;if (Math.abs(midEndX - midStartX) < portRadius * 2.2) {useSimple = true;}let path: string;if (useSimple) {const arcY = (startY + endY) / 2;if (canUsePortArc && canUseExitArc) {path = [`M ${startX} ${startY}`,`V ${arcY - portRadius}`,`A ${portRadius} ${portRadius} 0 0 ${sweep1} ${startX + direction * portRadius} ${arcY}`,`H ${endX - direction * exitRadius}`,`A ${exitRadius} ${exitRadius} 0 0 ${sweep2} ${endX} ${arcY + exitRadius}`,`V ${endY}`,].join(' ');} else {// 简化路径:直线连接path = `M ${startX} ${startY} V ${arcY} H ${endX} V ${endY}`;}} else {// 标准路径:带圆角的折线const arc1EndY = laneBusY;if (canUsePortArc && canUseExitArc) {path = [`M ${startX} ${startY}`,`V ${portVertEndY}`,`A ${portRadius} ${portRadius} 0 0 ${sweep1} ${startX + direction * portRadius} ${arc1EndY}`,`H ${midEndX}`,`A ${exitRadius} ${exitRadius} 0 0 ${sweep2} ${endX} ${laneBusY + exitRadius}`,`V ${endY}`,].join(' ');} else {// 降级为直角折线path = `M ${startX} ${startY} V ${laneBusY} H ${endX} V ${endY}`;}}newLines.push({ path, port: portName, exit: exitVal });}}// 只有当连线确实变化时才更新状态const sameLength = newLines.length === lines.length;let sameContent = sameLength;if (sameLength) {for (let i = 0; i < newLines.length; i++) {if (newLines[i].path !== lines[i].path) {sameContent = false;break;}}}if (!sameContent) {setLines(newLines);}}, [lines, portLandingMap]);// 初始化和响应式更新useLayoutEffect(() => {recomputeLines();}, [recomputeLines]);// 窗口大小变化监听useLayoutEffect(() => {const handleResize = () => {recomputeLines();};window.addEventListener('resize', handleResize);return () => {window.removeEventListener('resize', handleResize);};}, [recomputeLines]);// 颜色映射const exitColorMap = useMemo(() => {const baseColors = ['#1f78ff', '#10b981', '#f59e0b', '#6366f1', '#ef4444'];const map: Record<string, string> = {};landingOptions.forEach((opt, idx) => {map[opt.value] = baseColors[idx % baseColors.length];});return map;}, [landingOptions]);// 处理鼠标悬停const handlePortMouseEnter = useCallback((portName: string) => {setHoverLineKey(`${portName}::ALL`);}, []);const handlePortMouseLeave = useCallback(() => {setHoverLineKey(null);}, []);const handleExitMouseEnter = useCallback((exitVal: string) => {setHoverLineKey(`ANY::${exitVal}`);}, []);const handleExitMouseLeave = useCallback(() => {setHoverLineKey(null);}, []);const handleLineMouseEnter = useCallback((port: string, exit: string) => {setHoverLineKey(`${port}::${exit}`);}, []);const handleLineMouseLeave = useCallback(() => {setHoverLineKey(null);}, []);return (<div ref={containerRef}style={{ position: 'relative', padding: '40px 20px',background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',minHeight: '500px',borderRadius: '12px',border: '1px solid #e2e8f0'}}>{/* 标题 */}<div style={{ textAlign: 'center', marginBottom: '40px' }}><h2 style={{ color: '#1e293b', margin: 0 }}>网络端口连接示意图</h2><p style={{ color: '#64748b', margin: '8px 0 0 0' }}>展示端口与出口之间的动态连接关系</p></div>{/* 端口区域 */}<div style={{ display: 'flex', justifyContent: 'center',gap: '30px',marginBottom: '120px',flexWrap: 'wrap' as const}}>{ports.map((port) => (<divkey={port.name}ref={(el) => {if (el) portNodeRefs.current[port.name] = el;}}style={{padding: '16px',border: `2px solid ${port.active ? '#1f78ff' : '#cbd5e1'}`,borderRadius: '8px',background: port.active ? '#ffffff' : '#f8fafc',boxShadow: port.active ? '0 4px 12px rgba(31, 120, 255, 0.15)' : 'none',textAlign: 'center',minWidth: '80px',transition: 'all 0.3s ease',opacity: port.active ? 1 : 0.6,cursor: 'pointer'}}onMouseEnter={() => handlePortMouseEnter(port.name)}onMouseLeave={handlePortMouseLeave}><div style={{ fontSize: '24px', marginBottom: '8px',color: port.active ? '#1f78ff' : '#94a3b8'}}>{port.isWan ? '🌐' : '🔌'}</div><div style={{ fontWeight: '600',color: port.active ? '#1e293b' : '#94a3b8'}}>{port.name}</div>{port.isWan && (<div style={{ fontSize: '12px', color: '#64748b',marginTop: '4px'}}>WAN</div>)}{!port.active && (<div style={{ fontSize: '12px', color: '#ef4444',marginTop: '4px'}}>未激活</div>)}</div>))}</div>{/* 出口区域 */}<div style={{ display: 'flex', justifyContent: 'center',gap: '25px',flexWrap: 'wrap' as const}}>{landingOptions.map((option) => (<divkey={option.value}ref={(el) => {if (el) exitNodeRefs.current[option.value] = el;}}style={{padding: '12px 24px',background: exitColorMap[option.value],color: 'white',borderRadius: '6px',fontWeight: '600',boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',cursor: 'pointer',transition: 'all 0.3s ease',minWidth: '100px',textAlign: 'center'}}onMouseEnter={() => handleExitMouseEnter(option.value)}onMouseLeave={handleExitMouseLeave}>{option.label}</div>))}</div>{/* SVG连接线容器 */}<svgref={linesSvgRef}style={{position: 'absolute',top: 0,left: 0,width: '100%',height: '100%',pointerEvents: 'none',zIndex: 1}}>{lines.map((line, index) => {const lineKey = `${line.port}::${line.exit}`;const isHovered = hoverLineKey === lineKey ||hoverLineKey === `${line.port}::ALL` ||hoverLineKey === `ANY::${line.exit}`;const baseColor = exitColorMap[line.exit] || '#1f78ff';return (<pathkey={index}d={line.path}stroke={baseColor}strokeWidth={isHovered ? 3 : 2}fill="none"strokeLinecap="round"strokeLinejoin="round"style={{opacity: isHovered ? 1 : 0.8,filter: isHovered ? 'drop-shadow(0 0 6px rgba(0,0,0,0.3))' : 'none',transition: 'all 0.3s ease',pointerEvents: 'stroke' as const}}onMouseEnter={() => handleLineMouseEnter(line.port, line.exit)}onMouseLeave={handleLineMouseLeave}/>);})}</svg>{/* 图例说明 */}<div style={{position: 'absolute',bottom: '20px',left: '20px',background: 'rgba(255, 255, 255, 0.9)',padding: '12px 16px',borderRadius: '8px',border: '1px solid #e2e8f0',fontSize: '12px',color: '#64748b'}}><div style={{ fontWeight: '600', marginBottom: '4px' }}>图例说明:</div><div>• 实线边框:端口已激活</div><div>• 虚线边框:端口未激活</div><div>• 悬停效果:高亮相关连接线</div><div>• 圆角路径:自适应圆角计算</div><div>• 分层处理:避免连线重叠</div></div></div>);
};export default PortConnectionDemo;
四、高级优化技巧
1. 性能优化
// 使用防抖避免频繁重计算
const recomputeLines = useCallback(debounce(() => {// 计算逻辑...
}, 100), [portLandingMap, landingOptions]);// 使用React.memo避免不必要重渲染
const PortIcon = React.memo(({ port }) => {// 渲染逻辑
});
2. 动画效果
// 使用react-spring添加动画
import { useSpring, animated } from 'react-spring';const AnimatedPath = animated.path;// 在组件中使用
<AnimatedPathd={line.path}style={{stroke: colorSpring,strokeWidth: widthSpring}}
/>
3. 响应设计
/* 使用CSS媒体查询适应不同屏幕 */
@media (max-width: 768px) {.port-container {flex-direction: column;}.exit-container {flex-wrap: wrap;}
}
4. 虚拟化渲染
对于大量连线的情况,使用虚拟化技术:
import { useVirtual } from 'react-virtual';const virtualizer = useVirtual({size: lines.length,parentRef: containerRef,estimateSize: useCallback(() => 20, []),
});{virtualizer.virtualItems.map((virtualRow) => (<pathkey={virtualRow.index}d={lines[virtualRow.index].path}// ...其他属性style={{position: 'absolute',top: 0,left: 0,width: '100%',height: `${virtualRow.size}px`,transform: `translateY(${virtualRow.start}px)`}}/>
))}