拖拽布局实战:从原理到项目落地的完整指南

程序员晨旭 交互 阅读 1,388
赞 11 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

拖拽布局这东西,说简单也简单,说坑也真不少。我做过好几个带拖拽的后台系统,从一开始用原生 drag & drop API 被各种兼容性问题折磨,到后来用 react-dnd、sortablejs,再到最近自己手写一套轻量级方案,踩过的坑能填满一个泳池。

拖拽布局实战:从原理到项目落地的完整指南

现在我一般不用第三方库了(除非项目特别复杂),因为大多数场景其实就几个核心需求:能拖、能放、能感知位置、不卡顿。我自己封装了一套基于 pointer events 的方案,比 touch/mouse 事件分开处理清爽太多,而且桌面端和移动端基本通吃。

核心思路是:用 pointerdown 启动拖拽,pointermove 更新位置,pointerup 结束。关键是要在 pointerdown 里调用 setPointerCapture,这样即使鼠标移出元素甚至窗口,也能继续收到 move 事件。这点很多人不知道,结果一拖快就断连。

下面是我现在项目里用的精简版代码:

function useDraggable(ref, onDrag) {
  const startPos = useRef({ x: 0, y: 0 });
  const startOffset = useRef({ x: 0, y: 0 });

  const handlePointerDown = (e) => {
    if (e.button !== 0) return; // 只响应左键
    const rect = ref.current.getBoundingClientRect();
    startPos.current = { x: e.clientX, y: e.clientY };
    startOffset.current = {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
    };

    // 关键!绑定到 document,避免移出元素丢失事件
    document.addEventListener('pointermove', handlePointerMove);
    document.addEventListener('pointerup', handlePointerUp);
    ref.current.setPointerCapture(e.pointerId); // 这行救命
  };

  const handlePointerMove = (e) => {
    const dx = e.clientX - startPos.current.x;
    const dy = e.clientY - startPos.current.y;
    onDrag?.({ dx, dy, offsetX: startOffset.current.x, offsetY: startOffset.current.y });
  };

  const handlePointerUp = () => {
    document.removeEventListener('pointermove', handlePointerMove);
    document.removeEventListener('pointerup', handlePointerUp);
  };

  useEffect(() => {
    const el = ref.current;
    el.addEventListener('pointerdown', handlePointerDown);
    return () => {
      el.removeEventListener('pointerdown', handlePointerDown);
    };
  }, []);
}

配合 CSS 设置 touch-action: none 防止滚动冲突,基本就稳了。这个方案在 iOS Safari、Chrome、Firefox 上都跑得很顺,连 Edge 旧版都兼容(只要支持 pointer events)。

这几种错误写法,别再踩坑了

我见过太多人把拖拽搞成性能灾难,或者交互诡异。下面这些写法,真的别再用了。

  • 在 move 里疯狂 setState:比如每移动 1px 就更新一次 React 状态,页面直接卡成 PPT。正确做法是用 transform 直接操作 DOM,或者用 requestAnimationFrame 节流。我上面的 onDrag 回调里只传偏移量,由外部决定要不要更新状态。
  • 用 mousemove + touchmove 双写:早期为了兼容移动端,很多人同时绑两套事件。结果代码重复、逻辑分裂,还容易漏掉某些设备。现在直接用 pointer events,一套代码全搞定,浏览器自动映射。
  • 没阻止默认行为:在移动端,拖拽时页面会跟着滚动。必须在 pointerdown 里加 e.preventDefault(),或者全局设置 touch-action: none。但注意,preventDefault 在某些浏览器(比如 iOS)需要配合 CSS 才生效。
  • 拖拽时没加 cursor: grabbing:用户体验细节!鼠标按下时应该变成抓取状态,松开变回 grab。虽然不影响功能,但用户会感觉“这东西是不是卡了”。

还有一个经典坑:在拖拽过程中,如果页面有滚动条,计算位置时忘了考虑 scrollX/scrollY。结果一滚动,拖拽位置就偏移。我的做法是全部用 getBoundingClientRect,它返回的是视口坐标,天然包含滚动偏移,不用额外处理。

实际项目中的坑

最近一个项目要做类似 Notion 的块级拖拽,允许在段落间插入占位符。这里有几个实战细节:

第一,占位符的插入时机要精准。不能等鼠标完全覆盖某个区域才触发,那样用户会觉得“粘滞”。我用的是中心点判断:当拖拽元素的中心进入目标区域 50% 以上时,才激活占位符。这样拖动更流畅。

第二,避免频繁重排。每次插入占位符都会导致 DOM 结构变化,如果用普通的 div,浏览器会重新计算所有后续元素的位置。我改用绝对定位的占位符,只改变 top 值,其他元素不动。这样性能提升明显,尤其在长列表里。

第三,移动端的点击穿透。拖拽结束后,如果手指快速抬起,可能触发下方元素的 click 事件。解决方法是在 pointerup 后加个 300ms 的 flag,期间屏蔽所有点击。虽然有点糙,但有效。

还有个小问题是:在 iframe 或 shadow DOM 里,setPointerCapture 可能失效。这种情况我还没遇到过,但查资料时看到有人踩过,所以提一嘴。如果项目涉及这类环境,得提前测试。

对了,如果你用的是 React,记得在拖拽时暂停 transition。否则元素会带着动画乱飞,体验很怪。我通常在拖拽开始时加个 class .dragging { transition: none !important; },结束时移除。

要不要用第三方库?

很多人一上来就问“用 sortablejs 还是 dnd-kit”。我的建议是:先看需求复杂度。

如果只是简单的列表排序,sortablejs 足够,而且它对 touch 支持很好。但如果你要做跨容器拖拽、自定义预览图、或者和复杂状态管理集成(比如 Redux),那还是自己写更灵活。第三方库的抽象层有时候反而碍事,调试起来也费劲。

我之前用 react-dnd 做过一个看板系统,结果发现它的 connectDragSource 必须包裹组件,导致很多无谓的 re-render。后来换成手写,代码量少了一半,性能还更好。

不过,如果你赶时间,或者团队不熟悉底层实现,用成熟库也没问题。但一定要读文档,别瞎配。比如 sortablejs 的 animation 参数设太高,会导致拖拽卡顿,很多人以为是自己代码的问题。

结尾碎碎念

拖拽布局看起来是个小功能,但细节多到爆炸。我上面说的方案也不是完美的——比如在超低配安卓机上,快速拖拽偶尔还是会丢帧。但权衡之下,这是我目前能找到的最平衡的解法:代码少、兼容好、性能稳。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,或者你遇到什么奇怪的拖拽问题也可以留言,说不定我也踩过。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论