【React】首页悬浮球实现,点击出现悬浮框
首页悬浮球实现
- 实现效果
- 组件代码实现
- 使用
实现效果
悬浮球,可以鼠标选中然后移动到页面区域,不会超出页面区域,点击出现悬浮窗,如果再次点击悬浮球,悬浮窗关闭,不会销毁里面的元素

组件代码实现
import React, { useState, useRef, useEffect } from 'react';
import { Icon } from 'antd';
import styles from './FloatingWrapper.less';
import chatbg from '../../imgs/chatbg.png';
import robotbg from '../../imgs/robotbg.gif';const DRAG_CLICK_THRESHOLD_PX = 5;const FloatingWrapper = ({ children, onOpen, bottomPos = 20, rightPos = 20 }) => {const [isModalVisible, setIsModalVisible] = useState(false);const [position, setPosition] = useState({ x: 0, y: 0 });const [isDragging, setIsDragging] = useState(false);const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });const widgetRef = useRef(null);const rafIdRef = useRef(null);const pendingPosRef = useRef(null);const pointerIdRef = useRef(null);const dragStartRef = useRef({ x: 0, y: 0 });const didDragRef = useRef(false);const [modalSize, setModalSize] = useState({ width: 380, height: 580 });const modalResizeObserverRef = useRef(null);const measureOnceRafRef = useRef(null);const measureAndUpdateModalSize = () => {const selector = `.${styles.floatingModal}`;const modalEl = document.querySelector(selector);if (!modalEl) return;const rect = modalEl.getBoundingClientRect();const next = { width: Math.round(rect.width), height: Math.round(rect.height) };// 增加阈值,减少不必要的状态更新setModalSize(prev => (Math.abs(prev.width - next.width) > 5 || Math.abs(prev.height - next.height) > 5 ? next : prev));};// 初始化位置在右下角useEffect(() => {const updatePosition = () => {const widgetWidth = 60; // 组件宽度const widgetHeight = 60; // 组件高度const x = window.innerWidth - widgetWidth - rightPos; // 距离右边缘20pxconst y = window.innerHeight - widgetHeight - bottomPos; // 距离下边缘20pxsetPosition({ x, y });};updatePosition();window.addEventListener('resize', updatePosition);return () => window.removeEventListener('resize', updatePosition);}, []);// 处理拖拽开始const handleMouseDown = e => {e.preventDefault();setIsDragging(true);didDragRef.current = false;dragStartRef.current = { x: e.clientX, y: e.clientY };const rect = widgetRef.current.getBoundingClientRect();setDragOffset({x: e.clientX - rect.left,y: e.clientY - rect.top,});// 捕获指针,持续接收 pointermove 事件if (e.pointerId != null && widgetRef.current?.setPointerCapture) {pointerIdRef.current = e.pointerId;try {widgetRef.current.setPointerCapture(e.pointerId);} catch (_) {// ignore}}};// 处理拖拽移动const handleMouseMove = e => {if (!isDragging) return;if (isModalVisible) {setIsModalVisible(false);}const newX = e.clientX - dragOffset.x;const newY = e.clientY - dragOffset.y;const maxX = window.innerWidth - 60;const maxY = window.innerHeight - 60;const clampedX = Math.max(0, Math.min(newX, maxX));const clampedY = Math.max(0, Math.min(newY, maxY));// 判断是否发生了可视为拖拽的移动if (!didDragRef.current) {const dx = e.clientX - dragStartRef.current.x;const dy = e.clientY - dragStartRef.current.y;if (Math.abs(dx) > DRAG_CLICK_THRESHOLD_PX || Math.abs(dy) > DRAG_CLICK_THRESHOLD_PX) {didDragRef.current = true;}}// 使用 rAF 节流 setStatependingPosRef.current = { x: clampedX, y: clampedY };if (rafIdRef.current == null) {rafIdRef.current = window.requestAnimationFrame(() => {if (pendingPosRef.current) setPosition(pendingPosRef.current);pendingPosRef.current = null;rafIdRef.current = null;});}};// 处理拖拽结束const handleMouseUp = e => {setIsDragging(false);if (pointerIdRef.current != null && widgetRef.current?.releasePointerCapture) {try {widgetRef.current.releasePointerCapture(pointerIdRef.current);} catch (_) {// ignore}pointerIdRef.current = null;}};// 组件卸载时取消 rAFuseEffect(() => () => {if (rafIdRef.current != null) {window.cancelAnimationFrame(rafIdRef.current);rafIdRef.current = null;}pendingPosRef.current = null;}, []);// 拖拽时监听 document,避免鼠标移出元素丢失事件useEffect(() => {const onMove = e => handleMouseMove(e);const onUp = e => handleMouseUp(e);if (isDragging) {document.addEventListener('mousemove', onMove);document.addEventListener('mouseup', onUp);}return () => {document.removeEventListener('mousemove', onMove);document.removeEventListener('mouseup', onUp);};}, [isDragging, dragOffset]);// 计算弹窗位置const getModalPosition = () => {const widgetRect = widgetRef.current?.getBoundingClientRect();if (!widgetRect) return {};const modalWidth = modalSize.width;const modalHeight = modalSize.height;const margin = 15; // 增加屏幕边距保护const gap = 12; // 增加与触发组件之间的间隙const spaceLeft = widgetRect.left;const spaceRight = window.innerWidth - widgetRect.right;const spaceTop = widgetRect.top;const spaceBottom = window.innerHeight - widgetRect.bottom;// 水平优先:默认在左侧,不够则放右侧let left;if (spaceLeft >= modalWidth + margin) {// 放在左侧,向右偏移 gap,避免过度贴边导致“偏左”left = widgetRect.left - modalWidth + gap;} else if (spaceRight >= modalWidth + margin) {// 放在右侧,向左偏移 gapleft = widgetRect.right - gap;} else {// 两侧都不够,尽量贴边不越界left = Math.max(margin,Math.min(widgetRect.left - modalWidth + gap, window.innerWidth - modalWidth - margin));if (left < margin) {left = Math.max(margin,Math.min(widgetRect.right - gap, window.innerWidth - modalWidth - margin));}}// 垂直优先:默认在上方,不够则放下方let top;if (spaceTop >= modalHeight + margin) {top = widgetRect.top - modalHeight + gap;} else if (spaceBottom >= modalHeight + margin) {top = widgetRect.bottom - gap;} else {// 上下都不够,高度做夹紧const preferredTop = widgetRect.top - modalHeight + gap; // 尽量放上方top = Math.max(margin, Math.min(preferredTop, window.innerHeight - modalHeight - margin));// 如果仍然溢出,尝试放到下方再夹紧if (top + modalHeight > window.innerHeight - margin) {top = Math.max(margin, Math.min(widgetRect.bottom - gap, window.innerHeight - modalHeight - margin));}}// 最终防护:边界夹紧left = Math.max(margin, Math.min(left, window.innerWidth - modalWidth - margin));top = Math.max(margin, Math.min(top, window.innerHeight - modalHeight - margin));return { left, top };};// 监听并记录浮窗实际尺寸,驱动定位计算useEffect(() => {if (!isModalVisible) {if (modalResizeObserverRef.current) {modalResizeObserverRef.current.disconnect();modalResizeObserverRef.current = null;}return undefined;}const selector = `.${styles.floatingModal}`;const modalEl = document.querySelector(selector);if (!modalEl) return undefined;const updateSize = () => {const rect = modalEl.getBoundingClientRect();const next = { width: Math.round(rect.width), height: Math.round(rect.height) };// 增加阈值,减少不必要的状态更新setModalSize(prev => (Math.abs(prev.width - next.width) > 5 || Math.abs(prev.height - next.height) > 5 ? next : prev));};updateSize();if (typeof ResizeObserver !== 'undefined') {const ro = new ResizeObserver(() => updateSize());ro.observe(modalEl);modalResizeObserverRef.current = ro;return () => {ro.disconnect();modalResizeObserverRef.current = null;};}const intervalId = setInterval(updateSize, 250);return () => clearInterval(intervalId);}, [isModalVisible]);const showModal = () => {// NOTE 打开弹窗onOpen && onOpen();setIsModalVisible(true);// 等待浮窗挂载和布局,再测量一次,确保首次定位准确if (measureOnceRafRef.current != null) {cancelAnimationFrame(measureOnceRafRef.current);measureOnceRafRef.current = null;}measureOnceRafRef.current = requestAnimationFrame(() => {requestAnimationFrame(() => {measureAndUpdateModalSize();// 延迟一点时间确保位置稳定后再显示measureOnceRafRef.current = null;});});};const handleCancel = () => {// 延迟隐藏,让淡出动画完成setTimeout(() => setIsModalVisible(false), 300);};return (<><divref={widgetRef}className={styles.floatingWidget}style={{left: `${position.x}px`,top: `${position.y}px`,cursor: isDragging ? 'grabbing' : 'grab',transition: isDragging ? 'none' : undefined,}}onMouseDown={handleMouseDown}onMouseMove={handleMouseMove}onMouseUp={handleMouseUp}><imgclassName={styles.widgetIcon}onClick={e => {if (didDragRef.current || isModalVisible) {e.preventDefault();e.stopPropagation();return;}showModal();}}src={robotbg}alt=""/></div><divclassName={styles.floatingModal}style={{visibility: isModalVisible ? 'visible' : 'hidden',position: 'fixed',...getModalPosition(),zIndex: 1001,transition: 'opacity 0.3s ease-out',}}><div className={styles.modalHeader}><img src={chatbg} alt="robot" /><IconclassName={styles.closeButton}onClick={handleCancel}type="close"/></div><div className={styles.iframeContainer}>{children}</div></div></>);
};export default FloatingWrapper;
样式
.floatingWidget {position: fixed;width: 60px;height: 46px;z-index: 1000;
}.widgetIcon {max-width: 100%;height: auto;cursor: pointer;display: flex;align-items: center;justify-content: center;transition: all 0.2s ease;&:hover {transform: scale(1.1);}
}.floatingModal {min-width: 350px;max-width: 440px;width: 25vw;background: #f7fafc;border-radius: 12px;box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);overflow: hidden;will-change: opacity, transform;backface-visibility: hidden;transform: translateZ(0);
}.modalHeader {display: flex;align-items: center;justify-content: space-between;height: 40px;padding: 8px 12px;padding-bottom: 5px;border-radius: 8px 8px 0 0;position: relative;img {height: 100%;width: auto;}
}.closeButton {color: rgba(0, 0, 0, 0.8);font-size: 14px;
}.iframeContainer {min-width: 350px;min-height: 500px;max-width: 440px;max-height: 640px;height: 60vh;width: 100%;overflow: hidden;iframe {border-radius: 0 0 8px 8px;}
}
// 拖拽时的样式
.floatingWidget[style*='cursor: grabbing'] {transform: scale(1.05);
}
使用
import React, { useRef, useState } from 'react';
import FloatingWrapper from './FloatingWrapper';const FloatingWidget = props => {return (<FloatingWrapperonOpen={() => {// 点击打开弹窗内容}}><div className={styles.container}><Spin spinning={loading}><iframesrc={'www.baidu.com'}title="百度搜索"width="100%"height="100%"frameBorder="0"allowFullScreenonLoad={() => {setLoading(false);}}/></Spin></div></FloatingWrapper>);
};export default FloatingWidget;
内部放什么内容自己写,可以套iframe,也可以自己写其他的内容,这个框架
可以只要悬浮球widgetRef的部分,但是悬浮窗的实现也涉及了边界的计算,为了显示全,默认是悬浮球的左上方,对不同的边界进行了处理,所以把悬浮窗的实现也放了出来
