手把手实现高效的拖拽选择功能实战

极客星宇 交互 阅读 2,111
赞 20 收藏
二维码
手机扫码查看
反馈

先上代码,再聊细节

最近在搞一个文件管理器的前端功能,其中有个核心交互是“拖拽选择”——就是按住鼠标画个框,把里面的项目都选中。一开始我以为这种功能得靠第三方库,比如 Interact.js 或者 Draggable,结果折腾半天发现,其实原生 JS 加点技巧就能搞定,还更轻量、更可控。

手把手实现高效的拖拽选择功能实战

下面这个是最简实现,亲测有效,直接扔进项目里就能跑:

<div class="container">
  <div class="item">Item 1</div>
  <div class="item">Item 2</div>
  <div class="item">Item 3</div>
  <div class="item">Item 4</div>
  <div class="item">Item 5</div>
</div>
<div class="selection-box"></div>
.container {
  position: relative;
  width: 600px;
  height: 400px;
  border: 1px solid #ccc;
  overflow: hidden;
}

.item {
  width: 100px;
  height: 60px;
  background: #eee;
  margin: 10px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 4px;
  user-select: none;
}

.selection-box {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  background: rgba(0, 120, 255, 0.1);
  border: 1px dashed #0078ff;
  pointer-events: none;
  display: none;
}
const container = document.querySelector('.container');
const selectionBox = document.querySelector('.selection-box');
const items = document.querySelectorAll('.item');

let isDragging = false;
let startX, startY;

container.addEventListener('mousedown', (e) => {
  // 只有主键点击才触发,避免右键干扰
  if (e.button !== 0) return;

  const rect = container.getBoundingClientRect();
  startX = e.clientX - rect.left;
  startY = e.clientY - rect.top;

  isDragging = true;

  selectionBox.style.width = '0px';
  selectionBox.style.height = '0px';
  selectionBox.style.left = ${startX}px;
  selectionBox.style.top = ${startY}px;
  selectionBox.style.display = 'block';
});

document.addEventListener('mousemove', (e) => {
  if (!isDragging) return;

  const rect = container.getBoundingClientRect();
  let currentX = e.clientX - rect.left;
  let currentY = e.clientY - rect.top;

  // 计算宽高(考虑反向拖动)
  let width = Math.abs(currentX - startX);
  let height = Math.abs(currentY - startY);

  // 确定左上角坐标
  let left = Math.min(startX, currentX);
  let top = Math.min(startY, currentY);

  selectionBox.style.width = ${width}px;
  selectionBox.style.height = ${height}px;
  selectionBox.style.left = ${left}px;
  selectionBox.style.top = ${top}px;

  // 实时判断哪些 item 被框住了
  updateSelection(left, top, width, height);
});

document.addEventListener('mouseup', () => {
  isDragging = false;
  selectionBox.style.display = 'none';
});

function updateSelection(selLeft, selTop, selWidth, selHeight) {
  const selRight = selLeft + selWidth;
  const selBottom = selTop + selHeight;

  items.forEach(item => {
    const itemRect = item.getBoundingClientRect();
    const containerRect = container.getBoundingClientRect();

    // 相对于容器的 item 坐标
    const itemLeft = itemRect.left - containerRect.left;
    const itemTop = itemRect.top - containerRect.top;
    const itemRight = itemLeft + item.offsetWidth;
    const itemBottom = itemTop + item.offsetHeight;

    // 判断是否相交
    const intersect =
      itemLeft < selRight &&
      itemRight > selLeft &&
      itemTop < selBottom &&
      itemBottom > selTop;

    if (intersect) {
      item.classList.add('selected');
    } else {
      item.classList.remove('selected');
    }
  });
}

这个场景最好用:文件夹/图库/看板

上面这个方案特别适合做那种类似桌面系统的多选操作。我在一个内部工具里用了它,用户反馈说“终于不用 Ctrl+Click 了”。你要是做的是资源管理类的 UI,比如照片墙、任务卡片、文件列表,这招非常实用。

顺带提一句,如果你用的是 React,完全可以封装成一个 Hook,比如 useDragSelect,传入容器 ref 和 item 的 className 就行。我试过,逻辑几乎一样,只是状态管理换成了 useState 和 useEffect。

踩坑提醒:这三点一定注意

  • user-select: none 忘加就糟心:你不给 .item 加这个属性,拖的时候文字会被选中,视觉上很乱,体验直接崩。别问我怎么知道的,我调了十分钟才发现是这个原因。
  • container 要有 position: relative:selection-box 是 absolute 定位的,它的 top/left 是相对于 container 的。如果 container 没定位,那就会往上找,容易错位。这点尤其在复杂布局里容易翻车。
  • mousemove 不要绑定在 container 上:我最开始只给 container 绑 move,结果鼠标稍微一快,移出 container 就断了。后来改成绑在 document 上,才能保证拖到边缘也能继续画框。这是关键!

进阶一点:支持添加模式(Shift 多选)

有时候用户不想全清之前的选中项,而是想追加。这时候可以结合 Shift 键来处理:

function updateSelection(selLeft, selTop, selWidth, selHeight) {
  const selRight = selLeft + selWidth;
  const selBottom = selTop + selHeight;

  items.forEach(item => {
    const itemRect = item.getBoundingClientRect();
    const containerRect = container.getBoundingClientRect();

    const itemLeft = itemRect.left - containerRect.left;
    const itemTop = itemRect.top - containerRect.top;
    const itemRight = itemLeft + item.offsetWidth;
    const itemBottom = itemTop + item.offsetHeight;

    const intersect =
      itemLeft < selRight &&
      itemRight > selLeft &&
      itemTop < selBottom &&
      itemBottom > selTop;

    // 如果按了 Shift,只加不减
    if (intersect) {
      item.classList.add('selected');
    } else if (!e.shiftKey) {
      // 没按 Shift 才取消未交集的
      item.classList.remove('selected');
    }
  });
}

不过这里要注意,e.shiftKey 得从 mousedown 或 mousemove 事件里拿,我建议存在全局变量里,不然回调里拿不到上下文。

移动端?别急,能做但得改

这套逻辑在 PC 上没问题,但移动端 touch 事件得重写。我试过用 touchstarttouchmovetouchend 替代,基本结构一样,但坑更多。

最大的问题是:默认 touchmove 会触发页面滚动,导致 selection-box 错位。解决办法是在 container 上加 touch-action: none,或者在 touchstart 里调 e.preventDefault()

但注意:preventDefault() 会干掉所有默认行为,包括页面滚动。如果你的 container 在一个可滚动区域里,那就得小心处理,否则用户滑不动了。我的做法是只在真正开始拖拽后才阻止,默认允许滚动。

性能优化:别每帧都查所有元素

当 item 数量上千时,每次 mousemove 都遍历一遍很伤。我加了个节流:

const throttle = (fn, delay) => {
  let timer = null;
  return (...args) => {
    if (timer) return;
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, delay);
  };
};

// 使用
const throttledUpdate = throttle(updateSelection, 30);

然后在 mousemove 里调 throttledUpdate(...)。30ms 一帧足够流畅,又不会卡主线程。

API 接口联动?很简单

选完之后,你想把 selected 的 item 提交到后端,比如批量删除或移动。这时候可以直接收集 dom 元素的 data-id:

const selectedIds = Array.from(document.querySelectorAll('.item.selected'))
  .map(el => el.dataset.id);

fetch('https://jztheme.com/api/batch-delete', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ ids: selectedIds })
});

只要你在 .item 上写了 data-id="123",就能轻松拿到 ID 列表。

最后的小瑕疵

说实话,这个方案改完后还是有一个小问题:当你快速拖动时,selection-box 的边框偶尔会轻微抖动。我怀疑是 layout thrashing 导致的,但加了 transform: translateZ(0) 也没完全解决。不过用户几乎察觉不到,所以我就放着了 —— 毕竟不是每个 bug 都值得花三天去修。

以上是我对拖拽选择的实战总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如配合虚拟滚动、支持旋转元素检测、甚至和 ResizeObserver 结合做自适应选择区,后续我会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
Code°春景
这篇文章让我养成了阅读技术文档的好习惯,不再依赖别人的教程。
点赞
2026-03-22 09:26