如果你经常使用 Trello 看板、飞书文档的文件上传,或者网页中的列表排序功能,一定对「拖拽」这个交互不陌生。它让操作更直观、更符合用户直觉,但你知道前端是如何通过原生 API 实现这一功能的吗?

今天我们就来彻底吃透 HTML5 中的 drag & drop 拖拽体系—— 从核心概念、事件流、关键属性,到实战案例(基础拖拽、文件上传、列表排序),再到常见坑点解决方案,一篇文章帮你从 “会用” 到 “精通”。


一、拖拽的核心:HTML5 Drag & Drop API

首先要明确:前端原生拖拽不需要依赖任何第三方库,HTML5 已经提供了完整的 drag 和 drop 相关 API。它的核心逻辑是「拖拽源」和「放置目标」的交互,通过一系列事件串联起整个拖拽过程。

1.1 两个关键角色

  • 拖拽源(Draggable Element):可以被拖动的元素。默认情况下,只有 <a href> 和 <img> 元素自带拖拽能力,其他元素(如 <div><li>)需要手动设置 draggable="true" 才能成为拖拽源。
  • 放置目标(Drop Zone):可以接收拖拽元素的区域。默认情况下,任何元素都不是 “合法” 的放置目标,需要通过事件处理来允许放置(这是初学者最容易踩的坑!)。

1.2 完整事件流:拖拽的 “生命周期”

拖拽过程会触发一系列事件,按顺序可分为「拖拽源事件」和「放置目标事件」,我们用一个表格清晰梳理:

事件类型 具体事件 触发时机 所属角色
拖拽源事件 dragstart 当用户开始拖拽元素时(按下鼠标并开始移动的瞬间) 拖拽源
drag 拖拽过程中持续触发(类似 mousemove,频率较高,慎用 heavy 逻辑) 拖拽源
dragend 拖拽结束时触发(无论成功放置还是取消拖拽,都会触发) 拖拽源
放置目标事件 dragenter 拖拽源进入放置目标区域时触发 放置目标
dragover 拖拽源在放置目标区域内移动时持续触发 放置目标
dragleave 拖拽源离开放置目标区域时触发 放置目标
drop 拖拽源在放置目标区域内被释放时触发(前提:需允许放置 放置目标

关键提醒:drop 事件默认不会触发!必须在 dragenter 或 dragover 中调用 event.preventDefault() 来取消浏览器默认行为(默认禁止放置),才能让 drop 生效。

1.3 数据传递核心:dataTransfer 对象

拖拽过程中,如何让 “拖拽源” 和 “放置目标” 交换数据?比如拖拽一个任务卡片,需要告诉放置目标 “我是谁”“我的内容是什么”—— 这就需要 event.dataTransfer 对象,它是拖拽事件的核心数据载体。

常用方法和属性:

  • setData(format, data):给拖拽源设置数据。format 是数据格式(如 text/plaintext/html,也支持自定义格式如 application/my-app),data 是要传递的字符串(非字符串需先序列化,如 JSON.stringify)。
  • getData(format):从放置目标中获取拖拽源传递的数据(只能在 drop 或 dragend 中调用,其他事件中获取不到)。
  • clearData(format):清除指定格式的数据(可选,一般在 dragend 中清理)。
  • setDragImage(img, xOffset, yOffset):自定义拖拽时显示的图像(默认是拖拽源的半透明副本),img 是图像元素,xOffset/yOffset 是鼠标相对于图像的偏移量。
  • files:当拖拽的是本地文件时,通过 dataTransfer.files 获取文件列表(FileList 对象,类似 <input type="file"> 的结果)。

二、实战:从 0 实现基础拖拽

光说不练假把式,我们先实现一个最简单的拖拽场景:将 “可拖拽元素” 拖到 “放置区域”,并显示拖拽的数据。

2.1 步骤拆解

  1. 定义 HTML 结构:拖拽源(draggable="true")和放置目标。
  2. 编写 CSS 美化(区分拖拽源和放置目标,添加交互反馈)。
  3. 绑定事件:处理拖拽源的 dragstart,放置目标的 dragenter/dragover/drop,实现数据传递和放置逻辑。

2.2 完整代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>基础拖拽示例</title>
  <style>
    .drag-source {
      width: 150px;
      height: 80px;
      background: #42b983;
      color: white;
      text-align: center;
      line-height: 80px;
      border-radius: 8px;
      cursor: move; /* 提示“可拖拽” */
      margin: 20px;
    }
    .drop-zone {
      width: 300px;
      height: 200px;
      border: 2px dashed #ccc;
      border-radius: 8px;
      margin: 20px;
      padding: 20px;
    }
    .drop-zone.active {
      border-color: #42b983; /* 拖拽进入时高亮 */
      background: #f0f9f5;
    }
  </style>
</head>
<body>
  <!-- 拖拽源:必须设置 draggable="true" -->
  <div class="drag-source" draggable="true" id="source">我是可拖拽元素</div>

  <!-- 放置目标 -->
  <div class="drop-zone" id="zone">
    把上面的元素拖到这里
  </div>

  <script>
    // 1. 获取元素
    const source = document.getElementById('source');
    const dropZone = document.getElementById('zone');

    // 2. 拖拽源事件:dragstart(开始拖拽时设置数据)
    source.addEventListener('dragstart', (e) => {
      // 传递数据:格式为 text/plain,内容为拖拽源的文本
      e.dataTransfer.setData('text/plain', source.textContent);
      // 可选:添加拖拽时的样式
      source.style.opacity = '0.5';
    });

    // 3. 拖拽源事件:dragend(拖拽结束时清理)
    source.addEventListener('dragend', () => {
      source.style.opacity = '1'; // 恢复样式
    });

    // 4. 放置目标事件:dragenter(进入时高亮)
    dropZone.addEventListener('dragenter', (e) => {
      e.preventDefault(); // 关键:允许放置
      dropZone.classList.add('active');
    });

    // 5. 放置目标事件:dragover(移动时保持高亮,必须阻止默认)
    dropZone.addEventListener('dragover', (e) => {
      e.preventDefault(); // 关键:否则 drop 不触发
    });

    // 6. 放置目标事件:dragleave(离开时取消高亮)
    dropZone.addEventListener('dragleave', () => {
      dropZone.classList.remove('active');
    });

    // 7. 放置目标事件:drop(释放时处理逻辑)
    dropZone.addEventListener('drop', (e) => {
      e.preventDefault(); // 取消默认行为(如浏览器打开链接)
      dropZone.classList.remove('active');

      // 获取拖拽源传递的数据
      const data = e.dataTransfer.getData('text/plain');
      // 处理数据:在放置目标中显示
      dropZone.innerHTML = `<p>成功接收:${data}</p>`;
    });
  </script>
</body>
</html>

2.3 代码解析

  • draggable="true":让 <div> 成为可拖拽源,这是基础前提。
  • e.preventDefault():在 dragenter 和 dragover 中必须调用,否则浏览器会默认禁止放置,drop 事件永远不会触发。
  • dataTransfer.setData/getData:实现了拖拽源到放置目标的数据传递,这里传递的是文本,实际项目中可传递 ID、JSON 等(需序列化)。

三、进阶实战:常见拖拽场景

掌握了基础后,我们来攻克两个实际开发中高频的拖拽场景:文件拖拽上传列表拖拽排序

3.1 场景 1:文件拖拽上传(预览图片)

需求:将本地图片拖到页面区域,预览图片并显示文件信息(名称、大小、类型)。

核心逻辑:通过 dataTransfer.files 获取拖拽的文件列表,使用 FileReader 读取图片数据并预览。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>文件拖拽上传</title>
  <style>
    .file-drop-zone {
      width: 400px;
      height: 300px;
      border: 2px dashed #ccc;
      border-radius: 8px;
      margin: 20px;
      display: flex;
      align-items: center;
      justify-content: center;
      flex-direction: column;
      color: #666;
    }
    .file-drop-zone.active {
      border-color: #2196f3;
      background: #f5f9ff;
    }
    #preview {
      margin: 20px;
      max-width: 400px;
      max-height: 300px;
      display: none;
    }
    #file-info {
      margin: 0 20px;
      color: #333;
    }
  </style>
</head>
<body>
  <div class="file-drop-zone" id="fileZone">
    <span>把图片拖到这里预览</span>
  </div>
  <img id="preview" alt="图片预览">
  <div id="file-info"></div>

  <script>
    const fileZone = document.getElementById('fileZone');
    const preview = document.getElementById('preview');
    const fileInfo = document.getElementById('file-info');

    // 处理 dragenter/dragover:允许放置
    ['dragenter', 'dragover'].forEach(eventName => {
      fileZone.addEventListener(eventName, (e) => {
        e.preventDefault();
        fileZone.classList.add('active');
      });
    });

    // 处理 dragleave:取消高亮
    fileZone.addEventListener('dragleave', () => {
      fileZone.classList.remove('active');
    });

    // 处理 drop:读取文件
    fileZone.addEventListener('drop', (e) => {
      e.preventDefault();
      fileZone.classList.remove('active');

      // 1. 获取文件列表(只取第一个文件,且限制为图片)
      const files = e.dataTransfer.files;
      if (!files || files.length === 0) return;
      const file = files[0];
      if (!file.type.startsWith('image/')) {
        fileInfo.textContent = '请拖拽图片文件(如 PNG、JPG)!';
        return;
      }

      // 2. 显示文件信息
      const sizeKB = (file.size / 1024).toFixed(2);
      fileInfo.innerHTML = `
        文件名:${file.name}<br>
        文件类型:${file.type}<br>
        文件大小:${sizeKB} KB
      `;

      // 3. 预览图片(使用 FileReader)
      const reader = new FileReader();
      reader.onload = (e) => {
        preview.src = e.target.result; // 读取后的 Base64 地址
        preview.style.display = 'block';
      };
      reader.readAsDataURL(file); // 以 Base64 格式读取图片
    });
  </script>
</body>
</html>

3.2 场景 2:列表拖拽排序

需求:实现一个 TODO 列表,支持拖拽列表项调整顺序(类似 Trello 卡片排序)。

核心逻辑:

  1. 拖拽开始时,记录当前拖拽项的索引和内容。
  2. 拖拽过程中,判断鼠标位置,确定当前应插入的位置。
  3. 释放时,将拖拽项插入到目标位置,更新列表。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>列表拖拽排序</title>
  <style>
    .todo-list {
      list-style: none;
      padding: 0;
      margin: 20px;
      width: 300px;
    }
    .todo-item {
      padding: 12px 16px;
      margin: 8px 0;
      background: #fff;
      border: 1px solid #eee;
      border-radius: 4px;
      cursor: move;
    }
    .todo-item.dragging {
      opacity: 0.5;
      background: #f5f5f5;
    }
    .todo-item.over {
      border-top: 2px solid #42b983; // 拖拽到上方时显示线条提示
    }
  </style>
</head>
<body>
  <ul class="todo-list" id="todoList">
    <li class="todo-item" draggable="true">完成 drag & drop 博客</li>
    <li class="todo-item" draggable="true">学习 React 拖拽库</li>
    <li class="todo-item" draggable="true">整理前端面试题</li>
  </ul>

  <script>
    const todoList = document.getElementById('todoList');
    let draggingItem = null; // 记录当前拖拽的项

    // 给所有列表项绑定 dragstart 事件
    todoList.addEventListener('dragstart', (e) => {
      draggingItem = e.target;
      e.target.classList.add('dragging');
      // 传递拖拽项的索引(用于后续计算位置)
      e.dataTransfer.setData('text/plain', Array.from(todoList.children).indexOf(draggingItem));
    });

    // 拖拽结束时清理
    todoList.addEventListener('dragend', (e) => {
      e.target.classList.remove('dragging');
      draggingItem = null;
      // 清除所有 over 样式
      document.querySelectorAll('.todo-item.over').forEach(item => {
        item.classList.remove('over');
      });
    });

    // 处理 dragover:允许放置,并判断插入位置
    todoList.addEventListener('dragover', (e) => {
      e.preventDefault();
      // 找到当前鼠标下的列表项(排除正在拖拽的项)
      const targetItem = getDragTarget(e.target);
      if (!targetItem || targetItem === draggingItem) return;

      // 给目标项添加 over 样式(提示插入位置)
      document.querySelectorAll('.todo-item.over').forEach(item => {
        item.classList.remove('over');
      });
      targetItem.classList.add('over');

      // 计算插入位置:拖拽项在目标项的上方还是下方?
      const rect = targetItem.getBoundingClientRect();
      const isBelowMiddle = e.clientY > rect.top + rect.height / 2;
      // 插入到目标项的前面或后面
      if (isBelowMiddle) {
        todoList.insertBefore(draggingItem, targetItem.nextSibling);
      } else {
        todoList.insertBefore(draggingItem, targetItem);
      }
    });

    // 辅助函数:获取真正的列表项(避免点击到子元素)
    function getDragTarget(element) {
      while (element && !element.classList.contains('todo-item')) {
        element = element.parentElement;
      }
      return element;
    }
  </script>
</body>
</html>

四、常见坑点 & 解决方案

即使理解了基础,实际开发中也可能遇到各种问题,这里整理了 3 个高频坑点及解决方案:

坑点 1:drop 事件死活不触发

原因:浏览器默认禁止元素作为放置目标,必须在 dragenter 或 dragover 中调用 e.preventDefault()

解决方案

// 统一处理 dragenter 和 dragover
['dragenter', 'dragover'].forEach(eventName => {
  dropZone.addEventListener(eventName, (e) => {
    e.preventDefault(); // 关键!必须加
  });
});

坑点 2:dataTransfer.getData() 获取不到数据

原因getData() 只能在 drop 或 dragend 事件中调用,在 dragstartdragenter 等事件中调用会返回空字符串。

解决方案:将数据读取逻辑放在 drop 事件中:

dropZone.addEventListener('drop', (e) => {
  e.preventDefault();
  const data = e.dataTransfer.getData('text/plain'); // 这里能正常获取
});

坑点 3:移动端不支持原生拖拽

原因:HTML5 drag & drop API 主要针对桌面端,移动端(触摸设备)支持极差,触摸操作不会触发 drag 相关事件。

解决方案

  1. 移动端使用 Touch 事件(touchstart/touchmove/touchend)自定义拖拽逻辑。
  2. 使用成熟的跨端拖拽库,如 SortableJS(支持桌面和移动端,轻量且易用)。

五、总结

到这里,你已经掌握了前端原生拖拽的核心知识:

  1. 核心角色:拖拽源(draggable="true")和放置目标(需处理 dragenter/dragover 允许放置)。
  2. 事件流dragstart → drag → dragenter → dragover → drop → dragend
  3. 数据传递dataTransfer 对象是核心,setData 存数据,getData 读数据(仅在 drop/dragend 中有效)。
  4. 实战场景:基础拖拽、文件上传、列表排序,覆盖 80% 开发需求。

原生拖拽 API 足够应对大部分场景,但如果需要更复杂的功能(如跨列表拖拽、拖拽动画、触摸支持),可以考虑使用第三方库(如 SortableJS、react-beautiful-dnd)提高开发效率。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐