手把手实现高效的拖拽选择功能实战
先上代码,再聊细节
最近在搞一个文件管理器的前端功能,其中有个核心交互是“拖拽选择”——就是按住鼠标画个框,把里面的项目都选中。一开始我以为这种功能得靠第三方库,比如 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 事件得重写。我试过用 touchstart、touchmove、touchend 替代,基本结构一样,但坑更多。
最大的问题是:默认 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 结合做自适应选择区,后续我会继续分享这类博客。
