React手动实现拖拽选择遇滚动失效问题求助
React拖拽选择滚动后失效问题解决
问题描述
从零实现React拖拽选择功能,元素数量较少时功能正常,但元素过多出现滚动条后,拖拽选择及遮罩均无法正常工作。
相关代码
Item生成代码
const items = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, ].map((i) => ({ item: i, selected: i === 1 }));
选择功能核心代码(原代码)
const [data, setData] = useState(items); const [isSelecting, setIsSelecting] = useState(false); const [start, setStart] = useState<Coords>({ x: 0, y: 0, screenX: 0, screenY: 0 }); const [end, setEnd] = useState<Coords>({ x: 0, y: 0, screenX: 0, screenY: 0 }); const ref = useRef<HTMLDivElement>(null); useEffect(() => { function handleMouseDown(e: any) { if (e.target.closest(".selectable")) return; setIsSelecting(true); setStart({ x: e.clientX, y: e.clientY, screenX: e.screenX, screenY: e.screenY }); setEnd({ x: e.clientX, y: e.clientY, screenX: e.screenX, screenY: e.screenY }); setData((data) => [...data.map((item) => ({ ...item, selected: false }))]); } ref.current?.addEventListener("mousedown", handleMouseDown); return () => { ref.current?.removeEventListener("mousedown", handleMouseDown); }; }, [ref]); function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) { if (!isSelecting) return; setEnd({ x: e.clientX, y: e.clientY, screenX: e.screenX, screenY: e.screenY }); const selected = [...data]; const elements = document.getElementsByClassName("selectable"); for (let i = 0; i < elements.length; i++) { const rect = elements[i].getBoundingClientRect(); const elementRect = { left: rect.left + window.pageXOffset, top: rect.top + window.pageYOffset, right: rect.right + window.pageXOffset, bottom: rect.bottom + window.pageYOffset, }; if ( ((elementRect.left >= Math.min(start.x, end.x) && elementRect.left <= Math.max(start.x, end.x)) || (elementRect.right >= Math.min(start.x, end.x) && elementRect.right <= Math.max(start.x, end.x))) && ((elementRect.top >= Math.min(start.y, end.y) && elementRect.top <= Math.max(start.y, end.y)) || (elementRect.bottom >= Math.min(start.y, end.y) && elementRect.bottom <= Math.max(start.y, end.y))) ) { selected[i].selected = true; } else { selected[i].selected = false; } } setData(selected); } function handleMouseUp() { setIsSelecting(false); reset(); } const reset = () => { setStart({ x: 0, y: 0, screenX: 0, screenY: 0 }); setEnd({ x: 0, y: 0, screenX: 0, screenY: 0 }); };
选择遮罩样式代码(原代码)
const overlayStyle: any = { position: "absolute", backgroundColor: colors.slate[800], opacity: 0.5, border: "1px dotted", borderColor: colors.slate[300], left: `${Math.min(start.x, end.x) - (ref.current?.offsetLeft || 0)}px`, top: `${Math.min(start.y, end.y) - (ref.current?.offsetTop || 0) + (ref.current?.scrollTop || 0)}px`, width: `${Math.abs(end.x - start.x)}px`, height: `${Math.abs(end.y - start.y) + (ref.current?.scrollTop || 0)}px`, display: isSelecting ? "block" : "none", pointerEvents: "none", };
JSX模板(原代码)
return ( <> <div onMouseUp={handleMouseUp} onMouseMove={handleMouseMove} className="relative p-4 overflow-auto bg-slate-600 h-96" ref={ref}> <ul className="flex flex-wrap gap-2"> {data.map((item) => ( <li onClick={() => { console.log("a"); }} className={cx( "flex items-center justify-center w-24 text-white hover:border-4 hover:border-slate-500 rounded-lg select-none aspect-square bg-slate-700 selectable cursor-pointer", { "border-sky-500 border-4 hover:border-sky-500": item.selected } )} key={item.item} > Item {item.item} </li> ))} </ul> <div style={overlayStyle}></div> </div> <button onClick={() => { console.log(ref); }} > 1a </button> </> );
问题预览

解决方案
问题根源是坐标计算未考虑容器内部滚动的偏移,以下是修正后的关键代码:
1. 修正鼠标坐标计算(基于容器相对位置)
// 修改handleMouseDown中的坐标设置 useEffect(() => { function handleMouseDown(e: MouseEvent) { if (e.target.closest(".selectable")) return; const container = ref.current!; const containerRect = container.getBoundingClientRect(); // 计算鼠标相对于容器的坐标 const relativeX = e.clientX - containerRect.left; const relativeY = e.clientY - containerRect.top; setIsSelecting(true); setStart({ x: relativeX, y: relativeY, screenX: e.screenX, screenY: e.screenY }); setEnd({ x: relativeX, y: relativeY, screenX: e.screenX, screenY: e.screenY }); setData(prev => prev.map(item => ({ ...item, selected: false }))); } ref.current?.addEventListener("mousedown", handleMouseDown); return () => ref.current?.removeEventListener("mousedown", handleMouseDown); }, [ref]); // 修改handleMouseMove中的坐标设置 function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) { if (!isSelecting) return; const container = ref.current!; const containerRect = container.getBoundingClientRect(); const relativeX = e.clientX - containerRect.left; const relativeY = e.clientY - containerRect.top; setEnd({ x: relativeX, y: relativeY, screenX: e.screenX, screenY: e.screenY }); // 使用函数式更新避免 stale state setData(prevData => { const selected = [...prevData]; const elements = document.getElementsByClassName("selectable"); for (let i = 0; i < elements.length; i++) { const element = elements[i] as HTMLElement; const rect = element.getBoundingClientRect(); // 计算元素相对于容器的位置(结合容器滚动偏移) const elementRect = { left: rect.left - containerRect.left + container.scrollLeft, top: rect.top - containerRect.top + container.scrollTop, right: rect.right - containerRect.left + container.scrollLeft, bottom: rect.bottom - containerRect.top + container.scrollTop, }; const selectStartX = Math.min(start.x, end.x); const selectEndX = Math.max(start.x, end.x); const selectStartY = Math.min(start.y, end.y); const selectEndY = Math.max(start.y, end.y); // 判断元素是否与选择框相交 const intersects = elementRect.left <= selectEndX && elementRect.right >= selectStartX && elementRect.top <= selectEndY && elementRect.bottom >= selectStartY; selected[i].selected = intersects; } return selected; }); }
2. 修正遮罩样式计算
const overlayStyle: any = { position: "absolute", backgroundColor: colors.slate[800], opacity: 0.5, border: "1px dotted", borderColor: colors.slate[300], left: `${Math.min(start.x, end.x)}px`, top: `${Math.min(start.y, end.y)}px`, width: `${Math.abs(end.x - start.x)}px`, height: `${Math.abs(end.y - start.y)}px`, display: isSelecting ? "block" : "none", pointerEvents: "none", };
3. 全局监听鼠标抬起事件(避免移出容器后无法停止选择)
useEffect(() => { function handleGlobalMouseUp() { setIsSelecting(false); reset(); } if (isSelecting) { window.addEventListener('mouseup', handleGlobalMouseUp); return () => window.removeEventListener('mouseup', handleGlobalMouseUp); } }, [isSelecting]);
修正后的JSX模板
移除容器上的onMouseUp,改用全局监听:
return ( <> <div onMouseMove={handleMouseMove} className="relative p-4 overflow-auto bg-slate-600 h-96" ref={ref}> <ul className="flex flex-wrap gap-2"> {data.map((item) => ( <li onClick={() => console.log("a")} className={cx( "flex items-center justify-center w-24 text-white hover:border-4 hover:border-slate-500 rounded-lg select-none aspect-square bg-slate-700 selectable cursor-pointer", { "border-sky-500 border-4 hover:border-sky-500": item.selected } )} key={item.item} > Item {item.item} </li> ))} </ul> <div style={overlayStyle}></div> </div> <button onClick={() => console.log(ref)}>1a</button> </> );
关键说明
- 所有坐标计算改为容器相对坐标,不再依赖window的滚动偏移,适配内部滚动场景
- 使用函数式更新state避免 stale state 问题
- 全局监听鼠标抬起事件,确保拖拽在任何区域松开都能停止
内容的提问来源于stack exchange,提问作者Soumen Khara
相关产品推荐
相关产品推荐

