Skip to content

拖放实战示例

本节综合运用前面学习的 draggable 属性、拖放事件和 DataTransfer 对象,通过三个完整的实战案例展示拖放 API 的实际应用:拖拽排序列表、文件拖入上传区域、以及跨容器拖拽管理。每个示例都可以直接在浏览器中运行。

前置知识

阅读本节前,建议先了解:DataTransfer 对象

基础概念

拖放应用场景概览

场景核心事件DataTransfer 用法
拖拽排序dragstart + dragover + drop传递元素 ID,effectAllowed = move
文件上传dragover + drop读取 files 属性
跨容器拖拽全部事件传递 JSON 数据,effectAllowed = move

实战示例一:拖拽排序列表

实现思路

  1. 列表中的每个项目设置 draggable="true"
  2. dragover 事件中计算鼠标位置,判断插入点在当前项的上方还是下方
  3. drop 事件中将拖拽元素插入到正确位置

完整代码

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>拖拽排序列表</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 40px; background: #f0f2f5; }

    h1 { text-align: center; margin-bottom: 30px; color: #1a1a2e; }

    .sort-list {
      max-width: 500px;
      margin: 0 auto;
      background: white;
      border-radius: 12px;
      box-shadow: 0 4px 16px rgba(0,0,0,0.1);
      overflow: hidden;
    }

    .sort-item {
      display: flex;
      align-items: center;
      padding: 16px 20px;
      border-bottom: 1px solid #f0f0f0;
      cursor: grab;
      user-select: none;
      transition: background 0.15s, transform 0.15s;
    }

    .sort-item:last-child { border-bottom: none; }
    .sort-item:hover { background: #f8f9fa; }
    .sort-item:active { cursor: grabbing; }

    .sort-item.dragging {
      opacity: 0.4;
      background: #e8f0fe;
    }

    .sort-item.drag-above {
      border-top: 3px solid #1a73e8;
    }

    .sort-item.drag-below {
      border-bottom: 3px solid #1a73e8;
    }

    .sort-item .grip {
      color: #999;
      margin-right: 16px;
      font-size: 18px;
    }

    .sort-item .rank {
      width: 28px;
      height: 28px;
      background: #1a73e8;
      color: white;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 13px;
      font-weight: bold;
      margin-right: 16px;
      flex-shrink: 0;
    }

    .sort-item .content {
      flex: 1;
    }

    .sort-item .title { font-weight: 600; color: #333; }
    .sort-item .desc { font-size: 13px; color: #888; margin-top: 2px; }

    .toolbar {
      max-width: 500px;
      margin: 16px auto 0;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }

    .toolbar button {
      padding: 8px 20px;
      border: none;
      border-radius: 6px;
      cursor: pointer;
      font-size: 14px;
    }

    .btn-reset { background: #e0e0e0; color: #333; }
    .btn-save { background: #1a73e8; color: white; }

    .message {
      max-width: 500px;
      margin: 12px auto 0;
      padding: 12px 16px;
      border-radius: 6px;
      display: none;
    }

    .message.show { display: block; }
    .message.success { background: #e8f5e9; color: #2e7d32; }
  </style>
</head>
<body>
  <h1>拖拽排序任务列表</h1>

  <div class="sort-list" id="sortList">
    <div class="sort-item" draggable="true" data-id="1">
      <span class="grip">≡</span>
      <span class="rank">1</span>
      <div class="content">
        <div class="title">完成需求分析文档</div>
        <div class="desc">优先级:高 | 预计 2 小时</div>
      </div>
    </div>
    <div class="sort-item" draggable="true" data-id="2">
      <span class="grip">≡</span>
      <span class="rank">2</span>
      <div class="content">
        <div class="title">设计数据库架构</div>
        <div class="desc">优先级:高 | 预计 3 小时</div>
      </div>
    </div>
    <div class="sort-item" draggable="true" data-id="3">
      <span class="grip">≡</span>
      <span class="rank">3</span>
      <div class="content">
        <div class="title">编写 API 接口</div>
        <div class="desc">优先级:中 | 预计 5 小时</div>
      </div>
    </div>
    <div class="sort-item" draggable="true" data-id="4">
      <span class="grip">≡</span>
      <span class="rank">4</span>
      <div class="content">
        <div class="title">前端页面开发</div>
        <div class="desc">优先级:中 | 预计 8 小时</div>
      </div>
    </div>
    <div class="sort-item" draggable="true" data-id="5">
      <span class="grip">≡</span>
      <span class="rank">5</span>
      <div class="content">
        <div class="title">编写单元测试</div>
        <div class="desc">优先级:低 | 预计 4 小时</div>
      </div>
    </div>
  </div>

  <div class="toolbar">
    <button class="btn-reset" onclick="resetOrder()">重置排序</button>
    <button class="btn-save" onclick="saveOrder()">保存排序</button>
  </div>

  <div class="message success" id="message"></div>

  <script>
    const sortList = document.getElementById('sortList');
    const items = sortList.querySelectorAll('.sort-item');
    let draggedItem = null;

    // 保存初始顺序
    const initialOrder = Array.from(items).map(item => item.outerHTML);

    function updateRanks() {
      const items = sortList.querySelectorAll('.sort-item');
      items.forEach((item, index) => {
        item.querySelector('.rank').textContent = index + 1;
      });
    }

    // 为每个项绑定拖拽事件
    items.forEach(item => {
      item.addEventListener('dragstart', (e) => {
        draggedItem = item;
        e.dataTransfer.setData('text/plain', item.dataset.id);
        e.dataTransfer.effectAllowed = 'move';
        // 延迟添加样式,避免影响拖拽图像
        setTimeout(() => item.classList.add('dragging'), 0);
      });

      item.addEventListener('dragend', (e) => {
        item.classList.remove('dragging');
        // 清理所有指示器
        sortList.querySelectorAll('.drag-above, .drag-below').forEach(el => {
          el.classList.remove('drag-above', 'drag-below');
        });
        draggedItem = null;
      });

      item.addEventListener('dragover', (e) => {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'move';
        if (item === draggedItem) return;

        // 计算鼠标位置
        const rect = item.getBoundingClientRect();
        const midY = rect.top + rect.height / 2;

        // 清理所有指示器
        sortList.querySelectorAll('.drag-above, .drag-below').forEach(el => {
          el.classList.remove('drag-above', 'drag-below');
        });

        // 根据鼠标位置显示指示器
        if (e.clientY < midY) {
          item.classList.add('drag-above');
        } else {
          item.classList.add('drag-below');
        }
      });

      item.addEventListener('dragleave', () => {
        item.classList.remove('drag-above', 'drag-below');
      });

      item.addEventListener('drop', (e) => {
        e.preventDefault();
        e.stopPropagation();
        if (item === draggedItem) return;

        const rect = item.getBoundingClientRect();
        const midY = rect.top + rect.height / 2;

        // 根据位置决定插入到目标元素前面或后面
        if (e.clientY < midY) {
          sortList.insertBefore(draggedItem, item);
        } else {
          sortList.insertBefore(draggedItem, item.nextSibling);
        }

        // 更新序号
        updateRanks();
      });
    });

    // 重置排序
    function resetOrder() {
      sortList.innerHTML = initialOrder.join('');
      initDragItems();
      updateRanks();
      showMessage('排序已重置');
    }

    // 重新初始化拖拽
    function initDragItems() {
      const newItems = sortList.querySelectorAll('.sort-item');
      newItems.forEach(item => {
        item.addEventListener('dragstart', (e) => {
          draggedItem = item;
          e.dataTransfer.setData('text/plain', item.dataset.id);
          e.dataTransfer.effectAllowed = 'move';
          setTimeout(() => item.classList.add('dragging'), 0);
        });

        item.addEventListener('dragend', () => {
          item.classList.remove('dragging');
          sortList.querySelectorAll('.drag-above, .drag-below').forEach(el => {
            el.classList.remove('drag-above', 'drag-below');
          });
          draggedItem = null;
        });

        item.addEventListener('dragover', (e) => {
          e.preventDefault();
          if (item === draggedItem) return;
          const rect = item.getBoundingClientRect();
          const midY = rect.top + rect.height / 2;
          sortList.querySelectorAll('.drag-above, .drag-below').forEach(el => {
            el.classList.remove('drag-above', 'drag-below');
          });
          if (e.clientY < midY) {
            item.classList.add('drag-above');
          } else {
            item.classList.add('drag-below');
          }
        });

        item.addEventListener('dragleave', () => {
          item.classList.remove('drag-above', 'drag-below');
        });

        item.addEventListener('drop', (e) => {
          e.preventDefault();
          e.stopPropagation();
          if (item === draggedItem) return;
          const rect = item.getBoundingClientRect();
          const midY = rect.top + rect.height / 2;
          if (e.clientY < midY) {
            sortList.insertBefore(draggedItem, item);
          } else {
            sortList.insertBefore(draggedItem, item.nextSibling);
          }
          updateRanks();
        });
      });
    }

    // 保存排序
    function saveOrder() {
      const order = Array.from(sortList.querySelectorAll('.sort-item')).map(
        item => item.dataset.id
      );
      console.log('保存排序:', order);
      showMessage(`排序已保存:${order.join(' → ')}`);
    }

    // 显示消息
    function showMessage(text) {
      const msg = document.getElementById('message');
      msg.textContent = text;
      msg.classList.add('show');
      setTimeout(() => msg.classList.remove('show'), 3000);
    }
  </script>
</body>
</html>

实战示例二:文件拖入上传

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>文件拖拽上传</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, sans-serif; padding: 40px; background: #f5f7fa; }

    .upload-container { max-width: 600px; margin: 0 auto; }

    .upload-zone {
      border: 3px dashed #cbd5e0;
      border-radius: 16px;
      padding: 60px 40px;
      text-align: center;
      background: white;
      transition: all 0.3s;
      cursor: pointer;
    }

    .upload-zone:hover { border-color: #4299e1; }

    .upload-zone.drag-over {
      border-color: #3182ce;
      background: #ebf8ff;
      transform: scale(1.02);
    }

    .upload-icon { font-size: 48px; margin-bottom: 16px; }

    .upload-zone h3 { color: #2d3748; margin-bottom: 8px; }
    .upload-zone p { color: #718096; font-size: 14px; }

    .file-list {
      margin-top: 24px;
    }

    .file-item {
      display: flex;
      align-items: center;
      padding: 16px;
      background: white;
      border-radius: 10px;
      margin-bottom: 8px;
      box-shadow: 0 1px 3px rgba(0,0,0,0.08);
    }

    .file-icon {
      width: 48px;
      height: 48px;
      background: #e2e8f0;
      border-radius: 10px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 20px;
      margin-right: 16px;
    }

    .file-info { flex: 1; }
    .file-name { font-weight: 600; color: #2d3748; }
    .file-meta { font-size: 13px; color: #a0aec0; margin-top: 4px; }

    .file-progress {
      margin-top: 8px;
      height: 4px;
      background: #e2e8f0;
      border-radius: 2px;
      overflow: hidden;
    }

    .file-progress-bar {
      height: 100%;
      background: #4299e1;
      border-radius: 2px;
      transition: width 0.3s;
      width: 0;
    }

    .file-status {
      margin-left: 16px;
      padding: 4px 12px;
      border-radius: 20px;
      font-size: 12px;
      font-weight: 600;
    }

    .status-uploading { background: #fef3c7; color: #92400e; }
    .status-done { background: #d1fae5; color: #065f46; }
    .status-error { background: #fee2e2; color: #991b1b; }

    .stats {
      margin-top: 20px;
      padding: 16px;
      background: white;
      border-radius: 10px;
      text-align: center;
      box-shadow: 0 1px 3px rgba(0,0,0,0.08);
    }
  </style>
</head>
<body>
  <div class="upload-container">
    <div class="upload-zone" id="uploadZone">
      <div class="upload-icon">📁</div>
      <h3>拖拽文件到此处上传</h3>
      <p>支持所有文件类型 | 单文件最大 50MB</p>
    </div>

    <div class="file-list" id="fileList"></div>

    <div class="stats" id="stats" style="display:none;">
      共 <span id="totalCount">0</span> 个文件,
      总大小 <span id="totalSize">0 KB</span>
    </div>
  </div>

  <script>
    const uploadZone = document.getElementById('uploadZone');
    const fileList = document.getElementById('fileList');
    const stats = document.getElementById('stats');

    // 拖拽事件
    uploadZone.addEventListener('dragover', (e) => {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'copy';
      uploadZone.classList.add('drag-over');
    });

    uploadZone.addEventListener('dragleave', (e) => {
      // 只在真正离开时移除样式
      if (!uploadZone.contains(e.relatedTarget)) {
        uploadZone.classList.remove('drag-over');
      }
    });

    uploadZone.addEventListener('drop', (e) => {
      e.preventDefault();
      uploadZone.classList.remove('drag-over');
      handleFiles(e.dataTransfer.files);
    });

    // 处理文件
    function handleFiles(files) {
      Array.from(files).forEach(file => {
        const fileItem = createFileElement(file);
        fileList.appendChild(fileItem);
        simulateUpload(fileItem, file);
      });
      updateStats();
    }

    // 创建文件元素
    function createFileElement(file) {
      const size = formatSize(file.size);
      const ext = file.name.split('.').pop().toUpperCase();

      const item = document.createElement('div');
      item.className = 'file-item';
      item.innerHTML = `
        <div class="file-icon">${getFileIcon(file.type, ext)}</div>
        <div class="file-info">
          <div class="file-name">${file.name}</div>
          <div class="file-meta">${size} | ${file.type || '未知类型'} | ${ext}</div>
          <div class="file-progress">
            <div class="file-progress-bar"></div>
          </div>
        </div>
        <span class="file-status status-uploading">上传中</span>
      `;
      return item;
    }

    // 模拟上传进度
    function simulateUpload(fileItem, file) {
      const bar = fileItem.querySelector('.file-progress-bar');
      const status = fileItem.querySelector('.file-status');
      let progress = 0;

      const interval = setInterval(() => {
        progress += Math.random() * 15 + 5;
        if (progress >= 100) {
          progress = 100;
          clearInterval(interval);
          status.className = 'file-status status-done';
          status.textContent = '已完成';
        }
        bar.style.width = progress + '%';
      }, 200);
    }

    // 辅助函数
    function formatSize(bytes) {
      if (bytes < 1024) return bytes + ' B';
      if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
      return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
    }

    function getFileIcon(type, ext) {
      if (type.startsWith('image/')) return '🖼';
      if (type.startsWith('video/')) return '🎬';
      if (type.startsWith('audio/')) return '🎵';
      if (['PDF', 'DOC', 'DOCX', 'TXT'].includes(ext)) return '📄';
      if (['ZIP', 'RAR', '7Z', 'TAR'].includes(ext)) return '📦';
      return '📎';
    }

    function updateStats() {
      const items = fileList.querySelectorAll('.file-item');
      stats.style.display = items.length ? 'block' : 'none';
      document.getElementById('totalCount').textContent = items.length;
    }
  </script>
</body>
</html>

实战示例三:跨容器拖拽看板

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>看板拖拽管理</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, sans-serif; padding: 20px; background: #f1f5f9; }

    h1 { text-align: center; color: #1e293b; margin-bottom: 24px; }

    .board {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 16px;
      max-width: 1000px;
      margin: 0 auto;
    }

    .column {
      background: #e2e8f0;
      border-radius: 12px;
      padding: 12px;
      min-height: 400px;
    }

    .column-header {
      padding: 8px 12px;
      font-weight: 700;
      color: #475569;
      border-radius: 8px;
      margin-bottom: 12px;
      text-align: center;
    }

    .col-todo .column-header { background: #fca5a5; color: #7f1d1d; }
    .col-doing .column-header { background: #93c5fd; color: #1e3a5f; }
    .col-done .column-header { background: #86efac; color: #14532d; }

    .card {
      padding: 12px;
      background: white;
      border-radius: 8px;
      margin-bottom: 8px;
      cursor: grab;
      user-select: none;
      box-shadow: 0 1px 3px rgba(0,0,0,0.08);
      transition: box-shadow 0.2s, opacity 0.2s;
    }

    .card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
    .card.dragging { opacity: 0.3; }
    .card-title { font-weight: 600; font-size: 14px; color: #334155; }
    .card-desc { font-size: 12px; color: #94a3b8; margin-top: 4px; }
    .card-tag {
      display: inline-block;
      padding: 2px 8px;
      border-radius: 10px;
      font-size: 11px;
      margin-top: 6px;
    }
    .tag-urgent { background: #fee2e2; color: #dc2626; }
    .tag-normal { background: #dbeafe; color: #2563eb; }
    .tag-low { background: #dcfce7; color: #16a34a; }

    .column.drag-over { background: #cbd5e1; }
  </style>
</head>
<body>
  <h1>项目看板</h1>

  <div class="board">
    <!-- 待办列 -->
    <div class="column col-todo">
      <div class="column-header">待办 (3)</div>
      <div class="card" draggable="true" data-id="1">
        <div class="card-title">用户注册功能</div>
        <div class="card-desc">实现邮箱注册和手机注册</div>
        <span class="card-tag tag-urgent">紧急</span>
      </div>
      <div class="card" draggable="true" data-id="2">
        <div class="card-title">支付接口对接</div>
        <div class="card-desc">接入微信和支付宝支付</div>
        <span class="card-tag tag-urgent">紧急</span>
      </div>
      <div class="card" draggable="true" data-id="3">
        <div class="card-title">数据报表导出</div>
        <div class="card-desc">支持 Excel 和 PDF 格式导出</div>
        <span class="card-tag tag-normal">一般</span>
      </div>
    </div>

    <!-- 进行中列 -->
    <div class="column col-doing">
      <div class="column-header">进行中 (2)</div>
      <div class="card" draggable="true" data-id="4">
        <div class="card-title">首页重构</div>
        <div class="card-desc">使用新的设计稿重构首页布局</div>
        <span class="card-tag tag-normal">一般</span>
      </div>
      <div class="card" draggable="true" data-id="5">
        <div class="card-title">性能优化</div>
        <div class="card-desc">图片懒加载和代码分割</div>
        <span class="card-tag tag-low">低</span>
      </div>
    </div>

    <!-- 已完成列 -->
    <div class="column col-done">
      <div class="column-header">已完成 (1)</div>
      <div class="card" draggable="true" data-id="6">
        <div class="card-title">登录页面开发</div>
        <div class="card-desc">完成登录和注册页面</div>
        <span class="card-tag tag-low">低</span>
      </div>
    </div>
  </div>

  <script>
    // 看板拖拽逻辑
    const cards = document.querySelectorAll('.card');
    const columns = document.querySelectorAll('.column');
    let draggedCard = null;

    // 卡片拖拽事件
    cards.forEach(card => {
      card.addEventListener('dragstart', (e) => {
        draggedCard = card;
        e.dataTransfer.setData('text/plain', card.dataset.id);
        e.dataTransfer.effectAllowed = 'move';
        setTimeout(() => card.classList.add('dragging'), 0);
      });

      card.addEventListener('dragend', () => {
        card.classList.remove('dragging');
        columns.forEach(col => col.classList.remove('drag-over'));
        updateCounts();
        draggedCard = null;
      });
    });

    // 列事件
    columns.forEach(column => {
      const cardContainer = column; // 列本身就是容器

      column.addEventListener('dragover', (e) => {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'move';
        column.classList.add('drag-over');
      });

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

      column.addEventListener('drop', (e) => {
        e.preventDefault();
        column.classList.remove('drag-over');

        const id = e.dataTransfer.getData('text/plain');
        const card = document.querySelector(`.card[data-id="${id}"]`);

        if (card && card !== draggedCard) {
          // 将卡片追加到列的末尾(header 之后)
          const header = column.querySelector('.column-header');
          header.after(card);
        }
      });
    });

    // 更新计数
    function updateCounts() {
      columns.forEach(column => {
        const count = column.querySelectorAll('.card').length;
        const header = column.querySelector('.column-header');
        const name = header.textContent.split(' ')[0];
        header.textContent = `${name} (${count})`;
      });
    }
  </script>
</body>
</html>

注意事项

拖放 API 的局限性

局限性说明替代方案
移动端不支持iOS Safari 完全不支持Touch Events + CSS Transform
拖拽图像有限制不能跨域设置拖拽图像使用 Canvas 生成
无拖拽吸附原生无网格吸附功能CSS Grid + 手动计算位置
性能问题大量元素拖拽时卡顿虚拟滚动 + 限制可见区域

跨浏览器注意事项

javascript
// 兼容性处理
function setupDragDrop(element) {
  element.draggable = true;

  element.addEventListener('dragstart', (e) => {
    // Firefox 必须调用 setData
    if (e.dataTransfer.setData) {
      e.dataTransfer.setData('text/plain', element.id || '');
    }

    // IE 兼容
    if (e.dataTransfer.effectAllowed !== undefined) {
      e.dataTransfer.effectAllowed = 'move';
    }
  });

  element.addEventListener('dragover', (e) => {
    // 必须 preventDefault
    if (e.preventDefault) {
      e.preventDefault();
    }
    if (e.stopPropagation) {
      e.stopPropagation();
    }
  });
}

最佳实践

  1. 统一数据传递格式:始终使用 JSON 传递结构化数据
  2. 清理状态:在 dragend 中恢复所有样式和状态
  3. 使用 user-select: none 防止拖拽时选中文字
  4. 提供视觉反馈:让用户清楚当前拖拽状态
  5. 考虑移动端:为触摸设备提供替代方案
  6. 使用事件委托:处理动态添加的拖拽元素

下一节

继续学习:localStorage

参考链接