实现高效框选功能的前端技术方案与实战细节
框选功能又翻车了,touchstart/touchmove死活不触发
今天在给一个内部工具加「框选」功能——就是按住鼠标/手指拖拽,画个矩形,把里面的卡片全选中。PC端搞完测试没问题,一上真机(iOS Safari + 安卓 Chrome)直接歇菜:touchstart 都不进,更别说画框了。我以为是事件没绑定对,结果 console 里连个 log 都没有,整个人都懵了。
先说结论吧,省得你跟我一样折腾半天:不是事件没绑,是 默认行为被阻止了,但阻止得太早,导致 touchmove 根本不触发。具体点说,就是我在 touchstart 里写了 e.preventDefault(),想防页面滚动,结果 iOS 下这个操作直接让后续的 touchmove 变成“哑巴”——它压根不会派发,连监听器都进不去。
这里我踩了个坑:以为 preventDefault() 就是“阻止默认”,跟事件流没关系。其实不是。在 iOS Safari 和部分安卓 WebView 里,必须等 touchstart 后至少一次 touchmove 触发之后,才能安全调用 preventDefault(),否则整个触摸序列就废了。查 MDN 的时候看到一句轻描淡写的 “Calling preventDefault() in a passive event listener is ignored”,但我当时根本没意识到我的 touchstart 监听器是 passive 的(毕竟没显式写 { passive: false },浏览器就自动给你加了)。
后来试了下发现,只要把 touchstart 改成非 passive,再把 preventDefault() 挪到第一次 touchmove 里,立马就通了。但光这样还不够——你要框选,就得实时计算矩形位置,而 touchmove 的坐标得从 touches[0] 拿,不能用 changedTouches(那玩意儿只存当前变化的点,不一定有你要的起始点)。
另外还有个细节:PC 和移动端得共用一套逻辑,但 mousedown/mousemove 和 touchstart/touchmove 的事件对象结构不一样。我一开始傻乎乎地分别写两套,结果维护起来要命。后来统一抽象成一个「pointer」事件模拟层,核心就三件事:
- 统一监听
mousedown+touchstart(带{ passive: false }) - 在第一次
touchmove或mousemove里才preventDefault()(只防滚动,不影响框选) - 所有坐标统一取
clientX/clientY,并兼容touches[0]和原生 event
下面是我最后跑通的完整代码,删掉了业务逻辑,只留框选的核心骨架(React 函数组件里用的,但逻辑完全通用):
function useRectSelection(containerRef) {
const [isSelecting, setIsSelecting] = useState(false);
const [selectionRect, setSelectionRect] = useState(null);
const startPoint = useRef(null);
const handleStart = (e) => {
// 统一获取起始点
const point = e.touches ? e.touches[0] : e;
startPoint.current = { x: point.clientX, y: point.clientY };
setIsSelecting(true);
setSelectionRect({ left: point.clientX, top: point.clientY, width: 0, height: 0 });
// 关键:这里不能 preventDefault!
// 尤其不能在 touchstart 里调,否则 iOS touchmove 不触发
};
const handleMove = (e) => {
if (!isSelecting || !startPoint.current) return;
// 第一次 move 才 preventDefault,防滚动但保 touchmove 连续性
if (e.touches && e.cancelable) {
e.preventDefault(); // ✅ 放这儿才有效
}
const point = e.touches ? e.touches[0] : e;
const left = Math.min(startPoint.current.x, point.clientX);
const top = Math.min(startPoint.current.y, point.clientY);
const width = Math.abs(point.clientX - startPoint.current.x);
const height = Math.abs(point.clientY - startPoint.current.y);
setSelectionRect({ left, top, width, height });
};
const handleEnd = () => {
if (isSelecting) {
setIsSelecting(false);
setSelectionRect(null);
startPoint.current = null;
}
};
useEffect(() => {
const el = containerRef.current;
if (!el) return;
// 绑定所有 pointer 事件,且 touchstart 必须 non-passive
const options = { passive: false };
el.addEventListener('mousedown', handleStart);
el.addEventListener('touchstart', handleStart, options);
el.addEventListener('mousemove', handleMove);
el.addEventListener('touchmove', handleMove, options);
el.addEventListener('mouseup', handleEnd);
el.addEventListener('touchend', handleEnd);
// 清理
return () => {
el.removeEventListener('mousedown', handleStart);
el.removeEventListener('touchstart', handleStart, options);
el.removeEventListener('mousemove', handleMove);
el.removeEventListener('touchmove', handleMove, options);
el.removeEventListener('mouseup', handleEnd);
el.removeEventListener('touchend', handleEnd);
};
}, [isSelecting, containerRef]);
return { isSelecting, selectionRect };
}
然后在组件里用它:
const MyList = () => {
const containerRef = useRef(null);
const { isSelecting, selectionRect } = useRectSelection(containerRef);
return (
<div ref={containerRef} style={{ position: 'relative', userSelect: 'none' }}>
{/* 卡片列表 */}
{items.map(item => (
<div key={item.id} className="card">...</div>
))}
{/* 框选蒙层 */}
{isSelecting && selectionRect && (
<div
className="absolute border-2 border-blue-500 pointer-events-none rounded"
style={{
left: selectionRect.left,
top: selectionRect.top,
width: selectionRect.width,
height: selectionRect.height,
transform: 'translate(-50%, -50%)',
}}
/>
)}
</div>
);
};
注意几个关键点:
userSelect: 'none'必须加在容器上,不然文字会被误选(尤其 PC 端)- 蒙层用
pointer-events-none,不然会挡住下层点击 transform: 'translate(-50%, -50%)'是为了让 left/top 对齐矩形左上角(因为 selectionRect 是按 client 坐标算的,不是 relative 坐标)- 上面代码里没做边界限制(比如拖出容器外),实际项目里我加了个
Math.max(0, ...)防负值,但影响不大,就没放进来
还有一个小尾巴:Android Chrome 上偶尔会出现“松手后还残留一小段 selectionRect”,原因大概是 touchend 比 touchmove 少触发一次,导致最后一次坐标没更新。我懒得深究,就在 handleEnd 里加了 16ms 的 debounce 强制清空,反正用户感知不到。真实项目里这种小问题就先放过,上线再说。
顺带提一句,别信网上某些“用 getBoundingClientRect() + pageX 计算偏移”的方案——那玩意儿在缩放页面、有滚动条、或者用了 transform: scale() 的场景下全是坑。老老实实用 clientX/clientY + 容器 offsetTop/offsetLeft 最稳(不过我这次需求是全屏框选,所以直接用 client 就行)。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案(比如用 Pointer Events API 替代手动兼容?或者怎么优雅处理多点触控?),欢迎评论区交流。这个技巧的拓展用法还有很多,比如加虚线动画、支持 shift 多框选、甚至和 canvas 结合做图形框选,后续会继续分享这类博客。
