前一阵子做一个后台管理系统,需要实现一个常见的功能:拖拽排序。当时第一时间想到的是用 Sortable.js,但项目里根本不想引入额外依赖,而且这个功能就那么一小块,杀鸡用牛刀。于是我就翻了翻 MDN,发现 HTML 原生的拖放 API 完全够用,除了在移动端上体验有点一言难尽,在桌面端它非常靠谱。
这篇文章不写虚的,直接带你从零实现两个功能:拖拽排序列表,以及拖拽文件上传到指定区域。全程原生 HTML + JavaScript,没有任何外部库。
先弄明白拖放 API 的几个核心事件
拖放操作有一堆事件,但新手只需要记住这几个就行:
dragstart:当用户开始拖拽元素时触发。dragover:当被拖拽元素悬停在一个可放置区域时触发。dragenter和dragleave:进入和离开放置区域时触发,通常用来加个高亮样式。drop:当用户松开鼠标,把元素放到放置区时触发。
还有一个重要的对象:DataTransfer。你可以通过它携带数据,比如被拖拽元素的 id,或者拖拽的文件对象。如果没有这个对象,拖放就像不知道运什么货的货车,毫无意义。
基础:让元素可拖拽
HTML 里并不是所有元素天生就能拖拽的。必须给元素加上 draggable="true" 属性。然后监听 dragstart,在触发时往 dataTransfer 里塞数据。
<div class="list-item" draggable="true" data-id="1">项目一</div>
对应的 dragstart 处理函数:
item.addEventListener('dragstart', function(e) {
e.dataTransfer.setData('text/plain', this.dataset.id);
// 设置拖拽时的半透明效果
this.classList.add('dragging');
});
拖拽结束的时候记得把那个半透明类移除,否则会一直有效。可以用 dragend 事件处理。
实现一个拖拽排序列表
最经典的场景就是列表排序。比如有一个待办事项列表,你可以把某个事项拖到另一个位置。我的做法是这样的:
- 每个列表项都可拖拽。
- 当某个项被拖动到另一个项的上方时,通过
dragover计算是否要交换位置。 - 在
drop事件里重新排列 DOM 顺序。
先看核心代码,我把它写成一个简单的 HTML 文件。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>原生拖拽排序</title>
</head>
<body>
<ul id="sortable-list">
<li draggable="true" data-id="1">⏰ 早上起床</li>
<li draggable="true" data-id="2">📖 背单词</li>
<li draggable="true" data-id="3">💪 健身</li>
<li draggable="true" data-id="4">📝 写博客</li>
</ul>
<script>
const list = document.getElementById('sortable-list');
let draggingItem = null;
list.addEventListener('dragstart', function(e) {
if (e.target.tagName === 'LI') {
draggingItem = e.target;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', e.target.dataset.id);
e.target.style.opacity = '0.5';
}
});
list.addEventListener('dragend', function(e) {
e.target.style.opacity = '';
draggingItem = null;
});
list.addEventListener('dragover', function(e) {
e.preventDefault(); // 必须调用,否则不允许 drop
e.dataTransfer.dropEffect = 'move';
const targetItem = e.target.closest('li');
if (!targetItem || targetItem === draggingItem) return;
// 判断鼠标是位于目标的上半部分还是下半部分
const rect = targetItem.getBoundingClientRect();
const mouseY = e.clientY - rect.top;
const before = mouseY li.dataset.id);
console.log('新的排序:', sortedIds);
});
</script>
</body>
</html>
这段代码的核心就是 dragover 事件里,通过比较鼠标位置与目标元素中心点,来决定是插入到目标的前面还是后面。调用 insertBefore 会实时把拖动中的元素移动位置,这样排序的预览效果非常直观,甚至不用写拖动的幽灵样式。
注意:在 dragover 中必须执行 e.preventDefault(),否则 drop 事件永远不会触发。这是新手最容易忘记的地方。
踩坑:为什么 drop 事件不生效?
我调试的时候发现,只要忘了在 dragover 里加 preventDefault(),你怎么放都没反应。其实这是浏览器默认行为:不允许将元素拖放到另一个元素内部。所以必须把默认行为禁止掉,才能激活 drop。
另外一个浪费了我半小时的坑:dragenter 和 dragleave 会疯狂触发,特别是你给放区域加上高亮 class 的时候,鼠标一抖,高亮就会像闪光灯一样闪烁。原因是当你拖动到一个子元素上时,dragleave 会从父元素上触发。解决办法是使用计数器,或者在 dragover 里直接处理样式,而不是依赖 dragenter/leave。我上面的排序代码就没有用 dragenter,而是直接在 dragover 里做判断,简单有效。
更进一步:拖拽文件上传
除了拖拽元素,拖放 API 最常用的场景就是把系统里的文件拖到浏览器窗口里完成上传。这也是 HTML5 时代就有的功能,用起来也不难。我做一个简陋的拖放区域,然后用 FormData 上传到服务器。
先看前端代码:
<div id="drop-zone">把文件拖到这里</div>
<script>
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('dragover', function(e) {
e.preventDefault();
dropZone.style.border = '2px dashed #007bff';
});
dropZone.addEventListener('dragleave', function(e) {
dropZone.style.border = '';
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
const files = e.dataTransfer.files;
if (!files.length) return;
// 我们这里假装直接调用上传接口,实际业务中会遍历 files
uploadFile(files[0]);
dropZone.style.border = '';
});
function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
fetch('/upload', {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => console.log('上传成功', data))
.catch(err => console.error(err));
}
</script>
就这样,你也可以把多个文件一起处理,只需要遍历 e.dataTransfer.files 数组即可。更骚的操作是,你可以把一张图片拖进页面,然后直接从 dataTransfer.files[0] 里获取文件对象,并且用 URL.createObjectURL(file) 生成预览图,完全不用 input file。
坑:只能在 drop 事件里拿文件
一开始我试图在 dragover 事件里就读取 e.dataTransfer.files,结果发现它是空的。这是因为浏览器出于安全考虑,只有在 drop 时才能真正访问文件列表。所以不要想着在拖拽过程中就预览,只能在松手那一刻去读取。
实现拖拽上传预览
既然说到了预览,我干脆做一个小的功能:把图片拖进去,立即在页面显示缩略图。这里就用到了 URL.createObjectURL,体验非常流畅。
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
const files = e.dataTransfer.files;
for (const file of files) {
if (file.type.startsWith('image/')) {
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.width = 120;
img.style.margin = '5px';
document.body.appendChild(img);
}
}
});
不过这里有个内存泄漏的问题:用过的 objectURL 需要手动释放。当你不再需要图片时,调用 URL.revokeObjectURL(url) 即可。在单页应用里,不注意这个会越用越卡。
更优雅的实现:使用 dataTransfer 的 setDragImage
默认情况下,拖动元素时,浏览器会生成一个半透明的元素副本跟着鼠标走。但你想要自定义拖拽时的“幽灵”图片,可以用 setDragImage。这个 API 接受三个参数:自定义元素、X偏移量、Y偏移量。
dragItem.addEventListener('dragstart', function(e) {
const ghost = document.createElement('div');
ghost.textContent = '正在移动...';
ghost.style.position = 'absolute';
ghost.style.top = '-9999px';
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 0, 0);
});
然后需要在下一次 tick 时移除这个元素。我一般用 setTimeout 或者在 dragend 里手动 remove。不过默认的拖拽效果其实已经够用了,我很少弄这个。
最终美化一下排序列表的交互
原生拖放真的特别像“素颜”,朴实但能用。为了提高体验,我习惯在 dragover 时给被拖拽的项加一个类,表示它正在被准备放下。比如在列表项下面画一条横线?这就得用 CSS,但为了不违背你这篇不写样式的要求,我就不展开实际代码。不过我可以告诉你思路:给 li 绑定 dragover,然后动态在它前/后插入一个空白占位元素,排序效果会非常平滑。
但我上面的排序代码已经可以直接用了,因为 insertBefore 会实时改变 DOM 位置,视觉上感觉像在“穿插”,其实也不差。
移动端:原生拖放的致命弱点
让我必须说明白:原生拖放 API 在移动端浏览器上基本不可用。所以如果你的项目需要支持手机触屏拖拽,还是老老实实引入像 vuedraggable 或 Sortable.js 这样的库。但是桌面端后台管理系统,用原生拖放完全足够。
另外,如果你要拖放的对象有滚动容器,注意在 dragover 时可能需要通过 element.scrollTop += ... 来自动滚动,不然拖到边缘时列表不会滚动到更远的位置。
一点小总结
HTML 原生拖放 API 看起来有点古老,但它依然是许多桌面端交互的基石。我用它给项目写过一个配置项拖拽排序,还写过一个批量文件上传区,都挺稳定。关键是代码量少,不需要额外学习库的接口。你只要记住了 dragover 要 preventDefault,drop 里才拿数据,基本就不会遇到太大障碍。
如果你正在纠结要不要为一个小功能引入拖拽库,不妨先试试原生方案。至少对我来说,它帮我避免了一次依赖体积膨胀。

