学完这篇,你能用原生 HTML5 拖拽 API 做出两个真实可用的功能:把文件拖进网页上传、拖拽调整列表顺序,代码复制即可运行,不需要任何第三方库。
第一步:先认清拖拽的事件分工
拖拽分成两组事件,别搞混:
- 被拖的那个元素:
dragstart → drag(持续触发)→ dragend
- 接收放置的那个容器:
dragenter → dragover(持续触发)→ dragleave → drop
最核心的一条规则:在 dragover 里必须调用 e.preventDefault()。不调用,光标的形态不会变成「可放置」,drop 事件永远不会触发。90% 的「拖上去没反应」都是这一句漏了。
注意:drop 之后浏览器默认会尝试打开拖进来的文件(比如拖一张图,页面直接跳走)。所以要在 dragover 和 drop 里都 preventDefault(),把默认行为掐掉。
第二步:让元素变得可拖
<div class="card" draggable="true">拖我</div>
就一个属性。默认情况下文本、图片、链接是可拖的,其它元素必须显式写 draggable="true"。
注意:draggable 是枚举属性,写 draggable="false" 才是关闭,只删掉属性值没用。另外被拖元素要加 cursor: move 提示用户。
拖拽时想传数据,在 dragstart 里写:
el.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', el.dataset.id);
e.dataTransfer.effectAllowed = 'move';
});
注意:setData 必须在 dragstart 里同步调用,异步或延迟调用会让 Chrome 直接取消本次拖拽。
第三步:实现列表拖拽排序
思路是「拖动过程中实时移动 DOM」,松手时位置已经对了,不用再算一次。
<ul id="list">
<li draggable="true">第一项</li>
<li draggable="true">第二项</li>
<li draggable="true">第三项</li>
</ul>
const list = document.getElementById('list');
let dragging = null;
list.addEventListener('dragstart', e => {
dragging = e.target.closest('li');
dragging.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
list.addEventListener('dragend', () => {
dragging?.classList.remove('dragging');
dragging = null;
saveOrder(); // 把当前顺序提交到后端
});
list.addEventListener('dragover', e => {
e.preventDefault();
if (!dragging) return;
const after = getAfter(list, e.clientY);
if (after == null) list.appendChild(dragging);
else list.insertBefore(dragging, after);
});
// 找出光标位置下方最近的那个元素
function getAfter(container, y) {
const els = [...container.querySelectorAll('li:not(.dragging)')];
return els.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2; // 负值 = 光标在元素上半部
return (offset < 0 && offset > closest.offset)
? { offset, element: child } : closest;
}, { offset: Number.NEGATIVE_INFINITY, element: null }).element;
}
关键是 getAfter 里那个 offset:光标落在某项的上半部就该插到它前面,落在下半部就插到它后面。用 getBoundingClientRect() 取实时位置,滚动过页面也不会错位。
再加一点 CSS 减掉抖动:
li.dragging { opacity: .4; }
#list { user-select: none; }
注意:如果你在 dragover 里改了列表顺序,某些浏览器会有轻微闪烁,用 opacity 而不是 display:none 来标记正在拖的元素。
第四步:文件拖拽上传
<div id="drop" class="drop">把文件拖到这里</div>
const drop = document.getElementById('drop');
['dragenter', 'dragover'].forEach(t =>
drop.addEventListener(t, e => {
e.preventDefault();
drop.classList.add('over');
})
);
['dragleave', 'drop'].forEach(t =>
drop.addEventListener(t, e => {
e.preventDefault();
drop.classList.remove('over');
})
);
drop.addEventListener('drop', e => {
const files = [...e.dataTransfer.files];
upload(files);
});
上传直接用 FormData + fetch:
async function upload(files) {
const fd = new FormData();
files.forEach(f => fd.append('files[]', f));
const res = await fetch('/upload', { method: 'POST', body: fd });
console.log(await res.json());
}
几个必须知道的点:
e.dataTransfer.files 只在 drop 事件里能拿到。dragenter、dragover 里是空的,这是浏览器出于安全做的限制,不是 bug。
- 高亮样式会闪:鼠标划过子元素时会触发
dragleave,导致边框一闪一闪。简单解法是用计数器,进入 +1、离开 -1,归零才取消高亮。
- 想把整个页面变成投放区:在
document 上监听 dragover 和 drop 并 preventDefault(),否则拖偏一点浏览器就跳去打开文件了。
- 拖文件夹:
e.dataTransfer.items[i].webkitGetAsEntry() 可以判断是不是目录,普通场景直接忽略即可。
小结
- 两组事件:拖动源用
dragstart/dragend,投放目标用 dragover/drop。
dragover 里不写 preventDefault(),drop 一定不触发——这是最常见的问题。
- 列表排序用「实时 insertBefore + 中点判断」,松手时顺序已定,
dragend 里提交保存。
- 文件上传在
drop 里读 dataTransfer.files,只在这一刻拿得到;上传走 FormData。
- 页面级拖放要全局阻止默认行为,否则浏览器会打开文件。