HTML5 拖拽 API 使用指南

小飞兽 AI应用开发 254 次阅读 2026-06-12

HTML5 拖拽 API 使用指南:原生 Drag and Drop 详解

一、Introduction(简介)

HTML5 拖拽 API(Drag and Drop API)是浏览器原生提供的拖拽交互能力,无需引入任何第三方库,即可实现元素在页面内的拖拽排序、文件上传拖放、列表项移动等功能。在它出现之前,开发者只能靠鼠标事件(mousedown/mousemove/mouseup)自己计算碰撞检测,手动维护拖拽状态,代码既难写又不跟手。

HTML5 拖拽 API 的核心思想是:源元素(dragable)发起拖拽,目标元素(drop target)接收。浏览器负责底层的事件分发、视觉反馈(默认半透明效果)和数据传递,我们只需要关注业务逻辑即可。

本文涵盖所有常用的拖拽场景:列表排序、文件上传、多选拖拽、跨容器移动,并给出完整可运行的代码示例。学完以后,你完全可以在项目中抛弃那些又重又难维护的拖拽库。

二、基础语法与核心概念

2.1 使元素可拖拽

只需给元素加上 draggable="true" 属性,它就变成了可拖拽源。注意:默认情况下,链接(<a>)和图片(<img>)本身就是可拖拽的,其他元素需要手动声明。

<!-- 将 div 变为可拖拽元素 -->
<div draggable="true">拖拽我</div>

<!-- 图片和链接天然可拖拽,无需 draggable 属性 -->
<a href="#">链接天然可拖拽</a>
<img src="photo.jpg" alt="图片天然可拖拽">

2.2 拖拽事件全解析

一次完整的拖拽操作涉及以下事件,分布在拖拽源和目标元素上:










事件名触发时机常见用途
dragstart开始拖拽时(鼠标按下并移动)设置拖拽数据(setData)、修改拖拽视觉效果
drag拖拽过程中持续触发实时预览、计算位置
dragend拖拽结束(松手或中途取消)清理状态、还原样式
dragenter鼠标进入目标元素时添加高亮、显示放置指示器
dragover鼠标在目标元素上移动时阻止默认行为(否则不允许 drop)
dragleave鼠标离开目标元素时移除高亮、隐藏指示器
drop在目标元素上释放鼠标时读取拖拽数据(getData)、执行放置逻辑

重要: dragoverdrop 事件默认是"不允许放置"的。要让一个元素成为有效的放置目标,必须在 dragover 事件中调用 e.preventDefault(),否则浏览器会拒绝放置操作。

// ❌ 没有 preventDefault,drop 事件永远不会触发
dropTarget.addEventListener('dragover', (e) => {
    // 浏览器默认行为:拒绝放置
});

// ✅ 阻止默认行为,允许放置
dropTarget.addEventListener('dragover', (e) => {
e.preventDefault(); // 这一行必不可少!
});

2.3 拖拽数据传递(DataTransfer)

拖拽过程中传递的数据通过 e.dataTransfer 对象来管理,核心方法有两个:

    • setData(format, data):设置拖拽数据,format 通常是 'text/plain''text/html''application/json'
    • getData(format):读取拖拽数据
    // 拖拽源:dragstart 时写入数据
    source.addEventListener('dragstart', (e) => {
        e.dataTransfer.setData('text/plain', 'Hello Drop');
        e.dataTransfer.setData('application/json', JSON.stringify({ id: 1, name: 'Alice' }));
        // 同时支持多种格式,接收方按需读取
    });
    

    // 放置目标:drop 时读取数据
    target.addEventListener('drop', (e) => {
    e.preventDefault();
    const text = e.dataTransfer.getData('text/plain');
    const obj = JSON.parse(e.dataTransfer.getData('application/json'));
    console.log(text); // 'Hello Drop'
    console.log(obj); // { id: 1, name: 'Alice' }
    });

    2.4 拖拽视觉效果

    默认情况下,拖拽时浏览器会在鼠标旁显示元素的半透明副本。可以通过 dataTransfer.setDragImage() 自定义这个图标:

    dragSource.addEventListener('dragstart', (e) => {
        // 自定义拖拽图标:图片 + 偏移位置
        const img = new Image();
        img.src = '/drag-icon.png';
        // 偏移量:以图片左上角为基准的偏移
        e.dataTransfer.setDragImage(img, 20, 20);
    

    // 设置允许的拖拽效果
    e.dataTransfer.effectAllowed = 'move'; // move / copy / copyMove
    });

    三、代码示例

    3.1 基础拖拽:列表项移动

    最常见的场景——把一个列表项拖到另一个位置。这是任务管理、文件排序等应用的基础。

    <!DOCTYPE html>
    <html lang="zh">
    <head>
    <meta charset="UTF-8">
    <style>
      .container {
        display: flex;
        gap: 20px;
        font-family: sans-serif;
      }
      .list {
        width: 200px;
        padding: 10px;
        background: #f5f5f5;
        border-radius: 8px;
        min-height: 200px;
      }
      .list h3 {
        margin-top: 0;
        color: #333;
      }
      .item {
        background: #fff;
        border: 1px solid #ddd;
        border-radius: 4px;
        padding: 10px 14px;
        margin-bottom: 8px;
        cursor: grab;
        user-select: none;
        transition: box-shadow 0.2s, background 0.2s;
      }
      .item:hover {
        background: #f0f7ff;
        box-shadow: 0 2px 8px rgba(0,0,0,0.1);
      }
      .item.dragging {
        opacity: 0.4;
        border: 2px dashed #4a90e2;
      }
      .item.drag-over {
        border-color: #4a90e2;
        background: #e8f4ff;
      }
      .drop-zone.drag-over {
        background: #e8f4ff;
        border: 2px dashed #4a90e2;
      }
    </style>
    </head>
    <body>
    <div class="container">
      <div class="list" id="todo">
        <h3>待办</h3>
        <div class="item" draggable="true" data-id="1">📋 写日报</div>
        <div class="item" draggable="true" data-id="2">📋 回复邮件</div>
        <div class="item" draggable="true" data-id="3">📋 更新文档</div>
      </div>
      <div class="list drop-zone" id="done">
        <h3>已完成</h3>
      </div>
    </div>
    

    <script>
    const items = document.querySelectorAll('.item');
    const dropZones = document.querySelectorAll('.drop-zone');

    let draggedItem = null;

    items.forEach(item => {
    // 开始拖拽:记录当前元素
    item.addEventListener('dragstart', (e) => {
    draggedItem = item;
    item.classList.add('dragging');
    // 传递被拖拽元素的 ID
    e.dataTransfer.setData('text/plain', item.dataset.id);
    e.dataTransfer.effectAllowed = 'move';
    });

    // 拖拽结束:清理样式
    item.addEventListener('dragend', () => {
    item.classList.remove('dragging');
    draggedItem = null;
    });
    });

    dropZones.forEach(zone => {
    // 进入目标区域:高亮显示
    zone.addEventListener('dragenter', (e) => {
    e.preventDefault();
    zone.classList.add('drag-over');
    });

    // 在目标区域上移动:必须 preventDefault 否则 drop 不会触发
    zone.addEventListener('dragover', (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    });

    // 离开目标区域:取消高亮
    zone.addEventListener('dragleave', (e) => {
    // 只有真正离开(比如离开子元素)才移除
    if (!zone.contains(e.relatedTarget)) {
    zone.classList.remove('drag-over');
    }
    });

    // 放置:执行移动逻辑
    zone.addEventListener('drop', (e) => {
    e.preventDefault();
    zone.classList.remove('drag-over');

    const id = e.dataTransfer.getData('text/plain');
    if (draggedItem && zone !== draggedItem.parentElement) {
    // 从原容器移除,加入新容器
    zone.appendChild(draggedItem);
    console.log(任务 ${id} 已移至: ${zone.id});
    }
    });
    });
    </script>
    </body>
    </html>

    3.2 文件上传:拖拽文件到页面

    HTML5 拖拽 API 最实用的场景之一——文件上传区域。监听 drop 事件,从 e.dataTransfer.files 读取文件列表。

    <!DOCTYPE html>
    <html lang="zh">
    <head>
    <meta charset="UTF-8">
    <style>
      #drop-zone {
        width: 400px;
        padding: 60px 20px;
        border: 3px dashed #aaa;
        border-radius: 16px;
        text-align: center;
        color: #666;
        font-family: sans-serif;
        transition: border-color 0.3s, background 0.3s;
        margin: 20px;
      }
      #drop-zone.drag-over {
        border-color: #4a90e2;
        background: #f0f7ff;
        color: #4a90e2;
      }
      #file-list {
        font-family: monospace;
        margin-top: 20px;
        text-align: left;
      }
      .file-item {
        padding: 8px;
        background: #f9f9f9;
        border-radius: 4px;
        margin-bottom: 6px;
      }
    </style>
    </head>
    <body>
    <div id="drop-zone">
      <p>📂 将文件拖拽到此处上传</p>
      <p style="font-size:14px;color:#999">支持多文件</p>
    </div>
    <div id="file-list"></div>
    

    <script>
    const dropZone = document.getElementById('drop-zone');
    const fileList = document.getElementById('file-list');

    // 进入区域
    dropZone.addEventListener('dragenter', (e) => {
    e.preventDefault();
    dropZone.classList.add('drag-over');
    });

    // 必须阻止 dragover,否则 drop 不触发
    dropZone.addEventListener('dragover', (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'copy';
    });

    dropZone.addEventListener('dragleave', (e) => {
    if (!dropZone.contains(e.relatedTarget)) {
    dropZone.classList.remove('drag-over');
    }
    });

    // 核心:drop 事件读取文件
    dropZone.addEventListener('drop', (e) => {
    e.preventDefault();
    dropZone.classList.remove('drag-over');

    const files = e.dataTransfer.files;
    fileList.innerHTML = '';

    if (files.length === 0) return;

    Array.from(files).forEach(file => {
    const div = document.createElement('div');
    div.className = 'file-item';
    const sizeKB = (file.size / 1024).toFixed(1);
    div.textContent = 📄 ${file.name} (${sizeKB} KB, ${file.type || 'unknown type'});
    fileList.appendChild(div);

    // 这里可以调用上传接口
    // uploadFile(file);
    });
    });

    // 防止浏览器默认打开拖拽的文件
    ['dragenter', 'dragover', 'dragend', 'dragleave'].forEach(evt => {
    document.addEventListener(evt, (e) => e.preventDefault());
    });
    </script>
    </body>
    </html>

    3.3 拖拽排序(列表内换位)

    同一个列表内上下拖拽换位,使用 insertBefore / insertAfter 实现,比跨容器移动稍复杂,需要计算鼠标位置来决定放置在哪个元素之前。

    <!DOCTYPE html>
    <html lang="zh">
    <head>
    <meta charset="UTF-8">
    <style>
      .sortable {
        list-style: none;
        padding: 0;
        width: 300px;
        font-family: sans-serif;
      }
      .sortable li {
        background: #fff;
        border: 1px solid #ddd;
        padding: 12px 16px;
        margin-bottom: 4px;
        border-radius: 6px;
        cursor: grab;
        user-select: none;
        transition: transform 0.1s, box-shadow 0.1s;
      }
      .sortable li.dragging {
        opacity: 0.4;
        transform: scale(0.98);
      }
      .sortable li.drag-over-top {
        border-top: 3px solid #4a90e2;
        margin-top: -3px;
      }
      .sortable li.drag-over-bottom {
        border-bottom: 3px solid #4a90e2;
        margin-bottom: -3px;
      }
    </style>
    </head>
    <body>
    <ul class="sortable" id="sortable">
      <li draggable="true" data-order="0">🎯 第一项(点击拖拽排序)</li>
      <li draggable="true" data-order="1">🎯 第二项</li>
      <li draggable="true" data-order="2">🎯 第三项</li>
      <li draggable="true" data-order="3">🎯 第四项</li>
      <li draggable="true" data-order="4">🎯 第五项</li>
    </ul>
    <p id="order-display" style="font-family:monospace;color:#666;margin-top:20px;"></p>
    

    <script>
    const sortable = document.getElementById('sortable');
    const orderDisplay = document.getElementById('order-display');
    let draggedEl = null;

    function updateOrder() {
    const items = Array.from(sortable.querySelectorAll('li'));
    const order = items.map((el, i) => ${i + 1}. ${el.textContent.trim()});
    orderDisplay.textContent = '当前顺序:
    ' + order.join('
    ');
    }

    sortable.addEventListener('dragstart', (e) => {
    if (!e.target.classList.contains('sortable')) {
    draggedEl = e.target;
    e.target.classList.add('dragging');
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', e.target.dataset.order);
    }
    });

    sortable.addEventListener('dragend', (e) => {
    if (draggedEl) {
    draggedEl.classList.remove('dragging');
    draggedEl.classList.remove('drag-over-top', 'drag-over-bottom');
    draggedEl = null;
    }
    // 移除所有拖拽指示器
    sortable.querySelectorAll('li').forEach(li => {
    li.classList.remove('drag-over-top', 'drag-over-bottom');
    });
    updateOrder();
    });

    sortable.addEventListener('dragover', (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';

    const afterEl = getDragAfterElement(e.clientY);
    sortable.querySelectorAll('li').forEach(li => {
    li.classList.remove('drag-over-top', 'drag-over-bottom');
    });

    if (afterEl === null) {
    sortable.appendChild(draggedEl);
    } else {
    sortable.insertBefore(draggedEl, afterEl);
    }
    });

    // 根据 Y 坐标判断应该插入到哪个元素之前
    function getDragAfterElement(y) {
    const draggableEls = [...sortable.querySelectorAll('li:not(.dragging)')];

    return draggableEls.reduce((closest, child) => {
    const box = child.getBoundingClientRect();
    // 计算元素中心到鼠标的距离
    const offset = y - box.top - box.height / 2;
    if (offset < 0 && offset > closest.offset) {
    return { offset, element: child };
    } else {
    return closest;
    }
    }, { offset: Number.NEGATIVE_INFINITY }).element;
    }

    updateOrder();
    </script>
    </body>
    </html>

    3.4 拖拽数据跨窗口传递

    HTML5 拖拽 API 支持跨标签页(窗口)传递数据,甚至可以在浏览器和桌面应用之间传递内容。

    // 窗口 A(发送方)
    source.addEventListener('dragstart', (e) => {
        e.dataTransfer.setData('text/x-custom', '这是跨窗口的数据');
        e.dataTransfer.setData('text/plain', '在不支持自定义格式的浏览器降级显示此文本');
    });
    

    // 窗口 B(接收方)— 同一个页面监听 drop
    document.addEventListener('drop', (e) => {
    e.preventDefault();
    const custom = e.dataTransfer.getData('text/x-custom');
    if (custom) {
    console.log('收到跨窗口数据:', custom);
    }
    });

    四、运行效果

    • 列表移动示例:把"待办"区的任务卡片拖到"已完成"区,卡片会移入新容器,控制台打印 任务 1 已移至: done
    • 文件上传示例:把桌面文件拖入虚线框区域,区域内边框和文字变蓝色高亮,松开鼠标后文件列表显示在下方
    • 列表排序示例:在列表内上下拖动任意项,拖拽项变半透明,其他项上方或下方出现蓝色指示线,松开后顺序实时更新,底部文字同步刷新
    • 拖拽时:浏览器鼠标旁显示被拖拽元素的半透明副本(默认行为)

    五、常见问题与注意事项

    Q1:dragover 必须 preventDefault,否则 drop 不触发

    这是最容易犯的错误。浏览器默认行为是拒绝放置,必须在 dragover 事件中调用 e.preventDefault(),放置操作才能成功。

    // 常见错误写法
    target.addEventListener('dragover', (e) => {
        // 这里没有 preventDefault,drop 永远不会触发!
    });
    

    // 正确写法
    target.addEventListener('dragover', (e) => {
    e.preventDefault(); // 必须
    });

    Q2:dragleave 误触发的处理

    当鼠标从父元素进入子元素时,也会触发父元素的 dragleave。解决办法是判断 relatedTarget 是否在容器内部:

    zone.addEventListener('dragleave', (e) => {
        // relatedTarget 是鼠标即将进入的元素
        // 如果这个元素仍在当前容器内,不算真正的离开
        if (zone.contains(e.relatedTarget)) return;
        zone.classList.remove('drag-over');
    });
    

    Q3:移动端(手机/平板)不支持 HTML5 拖拽 API

    HTML5 Drag and Drop API 是桌面浏览器的产物,iOS Safari 和 Android Chrome 均不支持。移动端需要使用 Touch 事件(touchstart / touchmove / touchend)自己实现拖拽逻辑,或使用专门的移动端库(如 SortableJS)。

    Q4:拖拽时禁止浏览器默认打开文件

    当从桌面拖拽文件进入浏览器时,浏览器默认会打开文件。需要在文档层面阻止这些事件:

    ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
        document.addEventListener(evt, (e) => {
            e.preventDefault();
            e.stopPropagation();
        }, false);
    });
    

    Q5:如何在拖拽时传递多个数据格式

    DataTransfer 支持同时存储多种格式,接收方按需读取:

    // 发送方:同时写入纯文本和富文本
    e.dataTransfer.setData('text/plain', '纯文本内容');
    e.dataTransfer.setData('text/html', '<b>加粗文本</b>');
    

    // 接收方:优先读取富文本,降级到纯文本
    const rich = e.dataTransfer.getData('text/html') || e.dataTransfer.getData('text/plain');

    Q6:使用 DataTransfer 传递文件

    只能通过 e.dataTransfer.files 读取文件列表,setData 不支持文件类型,只能传字符串或 HTML 片段。

    六、延伸阅读

    • Google DevelopersDrag and Drop API 进阶用法 — Chrome 团队讲解现代浏览器中的行为差异
    • SortableJSSortable.js — 成熟的开源拖拽排序库,兼容桌面和移动端,推荐在生产项目中使用而非手写
  • Can I Usedraggable attribute 兼容性 — 查看各浏览器支持情况
  • 本系列相关章节HTML5 语义化标签详解媒体查询完全指南

HTML5 拖拽 API 用法清晰,代码量不大,是原生浏览器能力的很好展示。桌面端项目优先考虑这套原生 API,体验好、性能优、零依赖。只有在做复杂的跨平台拖拽(尤其涉及移动端)时,才建议引入 SortableJS 这样的成熟库。