实现高效框选功能的前端技术方案与实战细节

小艳珂 交互 阅读 1,913
赞 43 收藏
二维码
手机扫码查看
反馈

框选功能又翻车了,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/mousemovetouchstart/touchmove 的事件对象结构不一样。我一开始傻乎乎地分别写两套,结果维护起来要命。后来统一抽象成一个「pointer」事件模拟层,核心就三件事:

  • 统一监听 mousedown + touchstart(带 { passive: false }
  • 在第一次 touchmovemousemove 里才 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”,原因大概是 touchendtouchmove 少触发一次,导致最后一次坐标没更新。我懒得深究,就在 handleEnd 里加了 16ms 的 debounce 强制清空,反正用户感知不到。真实项目里这种小问题就先放过,上线再说。

顺带提一句,别信网上某些“用 getBoundingClientRect() + pageX 计算偏移”的方案——那玩意儿在缩放页面、有滚动条、或者用了 transform: scale() 的场景下全是坑。老老实实用 clientX/clientY + 容器 offsetTop/offsetLeft 最稳(不过我这次需求是全屏框选,所以直接用 client 就行)。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案(比如用 Pointer Events API 替代手动兼容?或者怎么优雅处理多点触控?),欢迎评论区交流。这个技巧的拓展用法还有很多,比如加虚线动画、支持 shift 多框选、甚至和 canvas 结合做图形框选,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
UX胜楠
UX胜楠 Lv1
读完这篇文章,我找到了更高效的学习方法,以后学习新技术会更有方向。
点赞 5
2026-02-13 10:25