拖放实战示例
本节综合运用前面学习的 draggable 属性、拖放事件和 DataTransfer 对象,通过三个完整的实战案例展示拖放 API 的实际应用:拖拽排序列表、文件拖入上传区域、以及跨容器拖拽管理。每个示例都可以直接在浏览器中运行。
前置知识
阅读本节前,建议先了解:DataTransfer 对象
基础概念
拖放应用场景概览
| 场景 | 核心事件 | DataTransfer 用法 |
|---|---|---|
| 拖拽排序 | dragstart + dragover + drop | 传递元素 ID,effectAllowed = move |
| 文件上传 | dragover + drop | 读取 files 属性 |
| 跨容器拖拽 | 全部事件 | 传递 JSON 数据,effectAllowed = move |
实战示例一:拖拽排序列表
实现思路
- 列表中的每个项目设置
draggable="true" dragover事件中计算鼠标位置,判断插入点在当前项的上方还是下方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();
}
});
}最佳实践
- 统一数据传递格式:始终使用 JSON 传递结构化数据
- 清理状态:在
dragend中恢复所有样式和状态 - 使用
user-select: none防止拖拽时选中文字 - 提供视觉反馈:让用户清楚当前拖拽状态
- 考虑移动端:为触摸设备提供替代方案
- 使用事件委托:处理动态添加的拖拽元素
下一节
继续学习:localStorage