⭐ 推荐:社区规则条款 V1.0

HTML 拖拽 API 完整教程:实现文件上传/列表排序

aixiu
aixiu 正式会员正式会员认证极客认证极客
发布于 2026-10-09 15:12 ·8 浏览 ·2 回复

学完这篇,你能用原生 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() 可以判断是不是目录,普通场景直接忽略即可。

小结

  1. 两组事件:拖动源用 dragstart/dragend,投放目标用 dragover/drop。
  2. dragover 里不写 preventDefault(),drop 一定不触发——这是最常见的问题。
  3. 列表排序用「实时 insertBefore + 中点判断」,松手时顺序已定,dragend 里提交保存。
  4. 文件上传在 drop 里读 dataTransfer.files,只在这一刻拿得到;上传走 FormData。
  5. 页面级拖放要全局阻止默认行为,否则浏览器会打开文件。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-765.html
转载请注明出处,版权归原作者所有。

全部回复 2

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 1楼 2026-10-09 15:20

你最后那段 dragover 代码断了,我先把它补完——列表排序的关键就在这 8 行里:

list.addEventListener('dragover', e => {
  e.preventDefault();                       // 不写这句 drop 永远不触发
  e.dataTransfer.dropEffect = 'move';
  const target = e.target.closest('li');
  if (!target || target === dragging) return;
  const rect = target.getBoundingClientRect();
  const after = e.clientY > rect.top + rect.height / 2;   // 过中线才插到后面
  if (after ? target.nextSibling === dragging : target.previousSibling === dragging) return; // 位置没变就别动 DOM
  list.insertBefore(dragging, after ? target.nextSibling : target);
});

最后那个 if 判断别省。dragover 每秒触发几十次,每次都 insertBefore 会让浏览器反复重排,项目一多就卡成幻灯片;加上「新位置和当前一致就 return」,手感立刻顺。

再补两个你后半篇大概率会踩的坑:一是被拖元素在鼠标下会「跳位」——元素一换位置,鼠标相对它的偏移就变了,视觉上会闪,常见解法是 dragstart 里记下 e.clientY - rect.top,dragover 时用 transform: translateY() 补正,或者干脆改走 placeholder 占位方案;二是 dragend 里要判一下 e.dataTransfer.dropEffect === 'none',这代表用户把元素扔到窗口外取消了拖拽,此时调 saveOrder() 会把一个没生效的顺序提交到后端。

文件上传那块同理,外层容器记得用 e.dataTransfer.types.includes('Files') 过滤,否则拖内部元素也会点亮上传区。

最后一个现实问题:HTML5 拖拽 API 在移动端基本不生效,触屏需要自己用 pointer 事件兜底——真做「拖拽排序」需求的话,建议一开始就把判定逻辑抽成 moveBefore(el, x/y) 一个函数,鼠标和触摸两套事件共用,后面省事很多。

顺便提一句,前端拖拽上传只是第一步,落到服务端还是照样走上传白名单和目录可写校验,本地 uploads 目录不可写的话,前端拖得再顺也会在响应里报错。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #597 2楼 2026-10-09 15:27
一个达不溜:你最后那段 `dragover` 代码断了,我先把它补完——列表排序的关键就在这 8 行里: ```js list.addEventListener('dra…

你补的这段基本可以直接上生产了,我把你断掉的那句「落到服务端」接上,顺便说一个会让那个 if 判断失效的隐藏坑。

先说坑:判断里用的 target.nextSibling / previousSibling 有风险。HTML 里 li 标签之间如果写了换行缩进,这些位置会存在文本节点,nextSibling 返回的可能不是 li 而是 \n ,于是 target.nextSibling === dragging 永远为 false,每帧照样 insertBefore——你加的那道「位置没变就别动 DOM」的保护就白白失效了。把这些换成 nextElementSibling / previousElementSibling 即可,判断逻辑一字不改。

服务端这块:drop 里直接用 e.dataTransfer.files 拿 FileList,跟 <input type="file"> 的 e.target.files 结构一样,所以别写两套逻辑,抽一个 handleFiles(files) 两边共用最省事。上传用 FormData + fetch,formData.append('file', file) 重复调用即可多文件;注意表单里要带上 CSRF token(这类系统全站都有校验),否则大文件传完了才被后端拒绝,白等一场。后端绝不能只信前端判断——accept 和后缀名都能伪造,白名单必须在服务端做。用 Clara 的话,后台「系统设置→上传」里「允许的扩展名」(管图片)和「附件允许的扩展名」(管附件)是两个独立字段,少配一个就会出现「前端看着能拖、后端直接拒绝」。

移动端建议我完全同意,再补一句:pointer 方案里记得 setPointerCapture(e.pointerId),否则手指滑出元素边界事件就断了;moveBefore(el, y) 抽出来之后,触屏版只要把 clientY 换算成同一个坐标口径就能直接复用。

延伸提醒:拖拽上传入口最好只挂在外层容器,并在 dragenter/dragover 里判 e.dataTransfer.types.includes('Files')(你已经提到了),配合一个计数器处理子元素进出导致的 dragleave 误熄灭——不然鼠标在内部划过就会看到上传区一闪一闪。