拖放操作
1. 文档目标与范围
本文档描述浏览器端拖放(Drag and Drop)能力的设计与实践,覆盖:
- 系统架构与事件流
- 核心功能模块划分
- 原生 API 与封装接口说明
- 配置参数与调优建议
- 常见问题、错误修正与兼容策略
- 移动端触摸事件替代方案
- 可访问性与性能优化
- 性能基准测试数据
- 高级拖拽模式实践
- 主流拖拽库选型对比
适用场景:看板卡片拖拽、列表排序、文件拖放上传、跨区域数据传递。
2. 术语与约定(统一口径)
| 术语 | 说明 | 备注 |
|---|---|---|
| 拖拽源(Drag Source) | 被拖动的元素 | 常通过 draggable="true" 启用 |
| 放置目标(Drop Target) | 接收拖放数据的区域 | 必须在 dragover 中 preventDefault() |
拖放事件(DragEvent) | 拖放生命周期事件对象 | 继承自 MouseEvent,包含 dataTransfer |
数据传输对象(DataTransfer) | 拖放时携带的数据容器 | 可读写字符串数据与文件列表 |
拖放项列表(DataTransferItemList) | DataTransfer.items 对应集合 | 支持读取类型与数据项 |
放置效果(dropEffect) | 当前放置行为效果 | copy / move / link / none |
允许效果(effectAllowed) | 拖拽源允许的操作类型 | 通常在 dragstart 设置 |
| 拖拽图像(Drag Image) | 拖拽时显示的视觉反馈 | 通过 setDragImage() 自定义 |
| 拖拽手柄(Drag Handle) | 限制拖拽触发的子元素 | 仅手柄区域可触发拖拽 |
统一约定
- "启用放置"默认指:在放置区域的
dragover调用event.preventDefault()。 - "文件拖放"默认指:优先读取
event.dataTransfer.files。 - "移动端拖放"默认指:不依赖原生 HTML5 DnD,采用触摸/指针事件方案。
3. 系统架构
<h4>007-datatransfer-api-lifecycle.html</h4><!DOCTYPE html>
<!--
来源:基础知识/11-拖放操作.md
演示:DataTransfer API、setDragImage、完整拖放事件生命周期
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>DataTransfer API 与拖放事件生命周期</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; padding: 30px; background: #f0f2f5; max-width: 960px; margin: 0 auto; }
h1 { color: #1a1a1a; margin-bottom: 25px; }
.section { background: white; padding: 25px; margin-bottom: 20px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.06); }
h2 { color: #333; font-size: 17px; margin-bottom: 15px; border-bottom: 2px solid #eee; padding-bottom: 8px; }
/* DataTransfer 演示 */
.data-item {
display: inline-flex; align-items: center; gap: 8px;
padding: 12px 20px; margin: 6px; background: linear-gradient(135deg, #667eea, #764ba2);
color: white; border-radius: 10px; cursor: grab;
user-select: none; font-size: 14px; font-weight: 500;
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s;
}
.data-item:active { cursor: grabbing; }
.data-item.dragging { opacity: 0.4; transform: scale(0.95); }
.drop-zone {
min-height: 120px; padding: 20px; border: 3px dashed #bdc3c7; border-radius: 12px;
transition: all 0.25s; display: flex; flex-wrap: wrap; gap: 8px; align-content: flex-start;
}
.drop-zone.active { border-color: #3498db; background: #ebf5fb; box-shadow: inset 0 0 20px rgba(52,152,219,0.08); }
.drop-zone.success { border-color: #27ae60; background: #eafaf1; }
/* 事件生命周期可视化 */
.event-timeline {
position: relative; padding-left: 30px; font-size: 13px;
}
.event-timeline::before {
content: ''; position: absolute; left: 8px; top: 0; bottom: 0;
width: 3px; background: linear-gradient(to bottom, #3498db, #9b59b6);
border-radius: 2px;
}
.event-node {
position: relative; padding: 10px 16px; margin: 8px 0;
background: #f8f9fa; border-radius: 8px; border-left: 3px solid #ddd;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-15px); }
to { opacity: 1; transform: translateX(0); }
}
.event-node::before {
content: ''; position: absolute; left: -33px; top: 14px;
width: 12px; height: 12px; border-radius: 50%; border: 2px solid white;
box-shadow: 0 0 0 2px #bbb;
}
.event-node.dragstart::before { background: #3498db; box-shadow: 0 0 0 2px #3498db; }
.event-node.dragenter::before { background: #f39c12; box-shadow: 0 0 0 2px #f39c12; }
.event-node.dragover::before { background: #e67e22; box-shadow: 0 0 0 2px #e67e22; }
.event-node.dragleave::before { background: #e74c3c; box-shadow: 0 0 0 2px #e74c3c; }
.event-node.drop::before { background: #27ae60; box-shadow: 0 0 0 2px #27ae60; }
.event-node.dragend::before { background: #9b59b6; box-shadow: 0 0 0 2px #9b59b6; }
.event-name { font-weight: bold; font-family: monospace; color: #2c3e50; }
.event-detail { color: #666; font-size: 12px; margin-top: 3px; }
.event-time { float: right; color: #aaa; font-size: 11px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 15px 0; }
th, td { padding: 10px 12px; text-align: left; border: 1px solid #dee2e6; }
th { background: #34495e; color: white; }
pre { background: #263238; color: #eceff1; padding: 15px; border-radius: 8px; overflow-x: auto; font-size: 12px; line-height: 1.6; max-height:300px;overflow-y:auto;}
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-family: monospace; }
.badge-source { background: #e3f2fd; color: #1976d2; }
.badge-target { background: #fce4ec; color: #c62828; }
</style>
</head>
<body>
<h1>🎯 DataTransfer API & 拖放事件生命周期</h1>
<!-- ========== DataTransfer 数据类型 ========== -->
<div class="section">
<h2>1. DataTransfer.setData / getData — 拖拽数据传递</h2>
<p>将下方数据项拖入目标区域,观察数据传递过程:</p>
<h3>📤 拖拽源(可拖拽的数据项)</h3>
<div style="margin:15px 0;">
<div class="data-item" draggable="true" data-type="text" data-value="Hello World" data-id="item-1">
📝 文本数据 "Hello World"
</div>
<div class="data-item" draggable="true" data-type="json" data-value='{"name":"张三","age":25}' data-id="item-2">
📦 JSON 数据对象
</div>
<div class="data-item" draggable="true" data-type="url" data-value="https://example.com/page" data-id="item-3">
🔗 URL 链接地址
</div>
<div class="data-item" draggable="true" data-type="custom" data-value="my-custom-data-12345" data-id="item-4">
🔧 自定义数据类型
</div>
</div>
<h3>📥 放置目标(接收数据)</h3>
<div class="drop-zone" id="dt-drop-zone">
<span style="color:#999;width:100%;text-align:center;padding:20px;">将上方数据项拖到这里</span>
</div>
<div id="dt-result" style="margin-top:12px;background:#f8f9fa;padding:12px;border-radius:6px;font-family:monospace;font-size:12px;display:none;"></div>
<pre id="dt-log" style="margin-top:12px;">// DataTransfer 操作日志...</pre>
</div>
<!-- ========== setDragImage ========== -->
<div class="section">
<h2>2. setDragImage 自定义拖拽图像</h2>
<p>默认拖拽图像是元素的半透明副本,可以通过 setDragImage 自定义:</p>
<div style="display:flex;gap:15px;flex-wrap:wrap;margin:15px 0;">
<div class="data-item" draggable="true" id="drag-default"
ondragstart="setDragDefault(event)">默认拖拽图像</div>
<div class="data-item" draggable="true" style="background:linear-gradient(135deg,#27ae60,#2ecc71);" id="drag-custom-img"
ondragstart="setCustomDragImage(event)">自定义图片拖拽</div>
<div class="data-item" draggable="true" style="background:linear-gradient(135deg,#e74c3c,#c0392b);" id="drag-offset"
ondragstart="setDragWithOffset(event)">偏移量拖拽</div>
<div class="data-item" draggable="true" style="background:linear-gradient(135deg,#f39c12,#f1c40f);" id="drag-hidden"
ondragstart="setHiddenDrag(event)">隐藏原始元素</div>
</div>
<pre>// setDragImage(imgElement, offsetX, offsetY)
// imgElement: 可以是 DOM 元素或 Image 对象
// offsetX/Y: 鼠标相对于图像左上角的偏移
// 常见用法:
e.dataTransfer.setDragImage(customImg, 0, 0); // 左上角对齐
e.dataTransfer.setDragImage(customImg, 20, 20); // 居中偏移
e.dataTransfer.setDragImage(new Image(), 0, 0); // 完全隐藏拖拽图像
// 注意: 必须在 dragstart 事件中同步调用!</pre>
</div>
<!-- ========== 事件生命周期 ========== -->
<div class="section">
<h2>3. 拖放事件完整生命周期</h2>
<p>拖动下方的圆形元素经过各个区域,实时查看事件触发顺序:</p>
<div style="display:flex;gap:30px;align-items:flex-start;">
<div style="flex:1;">
<div id="lifecycle-draggable" draggable="true"
ondragstart="logEvent('dragstart', event)"
ondragend="logEvent('dragend', event)"
style="width:70px;height:70px;border-radius:50%;background:linear-gradient(135deg,#667eea,#764ba2);
display:flex;align-items:center;justify-content:center;color:white;
font-size:24px;cursor:grab;user-select:none;box-shadow:0 4px 15px rgba(102,126,234,0.4);">
⚡
</div>
<p style="text-align:center;margin-top:8px;font-size:12px;color:#888;">拖动这个圆球 →</p>
</div>
<div style="flex:2;">
<div id="lifecycle-target"
ondragenter="logEvent('dragenter', event)"
ondragover="handleDragOver(event)"
ondragleave="logEvent('dragleave', event)"
ondrop="logEvent('drop', event)"
class="drop-zone" style="min-height:200px;position:relative;">
<span style="color:#999;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);">拖到这个区域<br>观察事件序列</span>
</div>
</div>
</div>
<h3 style="margin:18px 0 10px;">📋 事件日志(时间线)</h3>
<button onclick="clearEventLog()" style="margin-bottom:10px;background:#95a5a6;color:white;padding:6px 14px;border:none;border-radius:4px;cursor:pointer;font-size:12px;">清空日志</button>
<div class="event-timeline" id="event-timeline">
<div style="color:#999;text-align:center;padding:20px;">拖拽后事件将按时间顺序显示在这里...</div>
</div>
</div>
<!-- ========== DataTransfer 完整 API ========== -->
<div class="section">
<h2>4. DataTransfer 完整 API 参考</h2>
<table>
<thead><tr><th>方法/属性</th><th>说明</th></tr></thead>
<tbody>
<tr><td><code>setData(format, data)</code></td><td>设置拖拽数据,format 如 'text/plain', 'text/uri-list'</td></tr>
<tr><td><code>getData(format)</code></td><td>获取指定格式的数据(仅在 drop 中有效)</td></tr>
<tr><td><code>clearData(format)</code></td><td>清除指定格式或全部数据</td></tr>
<tr><td><code>setDragImage(el, x, y)</code></td><td>自定义拖拽时的跟随图像</td></tr>
<tr><td><code>types</code></td><td>只读,当前存储的数据格式列表(DOMStringList)</td></tr>
<tr><td><code>files</code></td><td>只读,被拖拽的 File 对象列表(FileList)</td></tr>
<tr><td><code>effectAllowed</code></td><td>允许的效果: none/copy/move/link/all/copyMove/copyLink/linkMove</td></tr>
<tr><td><code>dropEffect</code></td><td>实际效果: none/copy/move/link(需与 effectAllowed 兼容)</td></tr>
<tr><td><code>items</code></td><td>只读,DataTransferItemList(支持更多数据类型)</td></tr>
</tbody>
</table>
<h3 style="margin:18px 0 10px;">事件触发顺序总结</h3>
<pre style="font-size:11px;">┌─ 源元素 ─────────────────────────────┐
│ dragstart ← 开始拖拽(必须 setData)│
│ drag ← 拖拽过程中持续触发 │
│ dragend ← 拖拽结束(无论是否 drop) │
└──────────────────────────────────────────┘
┌─ 目标元素 ─────────────────────────────┐
│ dragenter ← 拖拽物进入元素范围 │
│ dragover ← 在元素范围内移动(必须 preventDefault 才能接收 drop)│
│ dragleave ← 离开元素范围 │
│ drop ← 释放鼠标(在此 getData) │
└──────────────────────────────────────────┘
⚠️ 关键点:
1. dragstart 中调用 e.dataTransfer.setData() 设置数据
2. dragover 中必须调用 e.preventDefault() + e.dataTransfer.dropEffect = 'move'
3. drop 中调用 e.preventDefault() + e.dataTransfer.getData() 获取数据
4. dragend 标志整个拖放流程结束</pre>
</div>
<!-- ========== touch-action 与触摸拖拽 ========== -->
<div class="section">
<h2>5. touch-action CSS 属性与触摸拖拽</h2>
<p><code>touch-action</code> 控制触摸操作的浏览器默认行为:</p>
<table>
<thead><tr><th>值</th><th>效果</th><th>适用场景</th></tr></thead>
<tbody>
<tr><td><code>auto</code></td><td>浏览器决定(默认)</td><td>一般网页</td></tr>
<tr><td><code>none</code></td><td>禁用所有触摸手势</td><td>自定义触摸交互</td></tr>
<tr><td><code>pan-x</code></td><td>仅允许水平滚动</td><td>水平轮播图</td></tr>
<tr><td><code>pan-y</code></td><td>仅允许垂直滚动</td><td>垂直列表</td></tr>
<tr><td><code>manipulation</code></td><td>允许双指缩放但禁用双击缩放</td><td>按钮、链接</td></tr>
<tr><td><code>pinch-zoom</code></td><td>仅允许双指缩放</td><td>地图、图片查看器</td></tr>
</tbody>
</table>
<pre style="margin-top:12px;font-size:11px;">/* 移动端拖拽元素推荐设置 */
.draggable-element {
touch-action: none; /* 禁止浏览器默认触摸行为 */
user-select: none; /* 禁止文本选择 */
-webkit-user-drag: element; /* Safari 支持 */
}
/* 可滚动容器内的拖拽区域 */
.scrollable-container .drag-item {
touch-action: pan-y; /* 保留垂直滚动,禁止水平手势干扰拖拽 */
}</pre>
</div>
<script>
// ===== DataTransfer 演示 =====
const dtLog = document.getElementById('dt-log');
const dtResult = document.getElementById('dt-result');
const dtZone = document.getElementById('dt-drop-zone');
document.querySelectorAll('.data-item[draggable]').forEach(item => {
item.addEventListener('dragstart', (e) => {
const type = item.dataset.type;
const value = item.dataset.value;
const id = item.dataset.id;
// 设置多种格式的数据
e.dataTransfer.setData('text/plain', value);
e.dataTransfer.setData('application/json', value);
e.dataTransfer.setData(`custom/${type}`, value);
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/id', id);
item.classList.add('dragging');
logDT(`[dragstart] 类型: ${type}, ID: ${id}`);
logDT(` setData('text/plain'): "${value}"`);
logDT(` setData('custom/${type}'): "${value}"`);
logDT(` effectAllowed: "copy"`);
logDT(` types: [${Array.from(e.dataTransfer.types).join(', ')}]`);
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
dtZone.classList.remove('active', 'success');
logDT('[dragend] 拖拽结束');
});
});
dtZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
dtZone.classList.add('active');
});
dtZone.addEventListener('dragleave', () => {
dtZone.classList.remove('active');
});
dtZone.addEventListener('drop', (e) => {
e.preventDefault();
dtZone.classList.remove('active');
dtZone.classList.add('success');
const plainText = e.dataTransfer.getData('text/plain');
const jsonData = e.dataTransfer.getData('application/json');
const customType = Array.from(e.dataTransfer.types).find(t => t.startsWith('custom/'));
dtResult.style.display = 'block';
dtResult.innerHTML =
`<strong>✅ 接收到数据:</strong><br>` +
`text/plain: <span style="color:#3498db;">${plainText}</span><br>` +
`application/json: <span style="color:#27ae60;">${jsonData}</span><br>` +
`自定义类型 (${customType}): ${e.dataTransfer.getData(customType)}<br>` +
`types 数组: [${Array.from(e.dataTransfer.types).join(', ')}]`;
setTimeout(() => dtZone.classList.remove('success'), 1500);
logDT(`[drop] 成功接收数据! text="${plainText}"`);
});
function logDT(msg) {
dtLog.textContent = `[${new Date().toLocaleTimeString()}] ${msg}\n` + dtLog.textContent;
}
// ===== setDragImage 演示 =====
function setDragDefault(e) {
// 不调用 setDragImage → 使用默认半透明克隆
console.log('使用默认拖拽图像');
}
function setCustomDragImage(e) {
const img = new Image();
img.src = 'data:image/svg+xml,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">' +
'<rect rx="8" fill="#27ae60" width="120" height="40"/>' +
'<text x="60" y="25" fill="white" text-anchor="middle" font-size="14">✨ 拖拽中!</text>' +
'</svg>'
);
e.dataTransfer.setDragImage(img, 60, 20);
}
function setDragWithOffset(e) {
const el = document.getElementById('drag-offset');
e.dataTransfer.setDragImage(el, el.offsetWidth / 2, el.offsetHeight / 2);
}
function setHiddenDrag(e) {
const emptyImg = new Image(1, 1);
emptyImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
e.dataTransfer.setDragImage(emptyImg, 0, 0);
}
// ===== 事件生命周期 =====
let eventCount = 0;
const timeline = document.getElementById('event-timeline');
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
document.getElementById('lifecycle-target').classList.add('active');
}
function logEvent(eventName, e) {
if (eventName === 'dragstart') {
timeline.innerHTML = '';
eventCount = 0;
}
eventCount++;
const node = document.createElement('div');
node.className = `event-node ${eventName}`;
let detail = '';
if (eventName === 'dragstart') detail = `effectAllowed 设为 "move",开始传输数据`;
else if (eventName === 'dragenter') detail = `拖拽物进入目标区域`;
else if (eventName === 'dragover') detail = `持续触发 (~250ms/次),需 preventDefault()`;
else if (eventName === 'dragleave') detail = `离开目标区域边界`;
else if (eventName === 'drop') {
detail = `释放!在此处 getData() 获取数据`;
document.getElementById('lifecycle-target').classList.remove('active');
document.getElementById('lifecycle-target').classList.add('success');
setTimeout(() => document.getElementById('lifecycle-target').classList.remove('success'), 1000);
} else if (eventName === 'dragend') {
detail = `整个拖放流程结束。dropSuccess=${!!e.dataTransfer.dropEffect}`;
document.getElementById('lifecycle-target').classList.remove('active');
}
node.innerHTML =
`<span class="event-time">#${eventCount}</span>` +
`<span class="event-name">${eventName}</span>` +
`<div class="event-detail">${detail}</div>`;
timeline.appendChild(node);
timeline.scrollTop = timeline.scrollHeight;
}
function clearEventLog() {
timeline.innerHTML = '<div style="color:#999;text-align:center;padding:20px;">日志已清空,重新拖拽以记录事件</div>';
}
</script>
</body>
</html>3.1 架构分层
3.2 事件时序(文件拖放)
3.3 事件生命周期详解
事件触发顺序(完整流程)
拖拽源 放置目标
| |
|--- dragstart -----------| 开始拖拽
|--- drag ---------------| 拖拽中(高频)
| dragenter 进入目标
| dragover 悬停目标(高频)
| dragleave 离开目标(可选)
|--- drag ---------------| 继续拖拽
| drop 放置
|--- dragend ----------| 结束拖拽(无论成功失败)3.4 事件传播与冒泡
拖放事件遵循标准 DOM 事件传播机制:捕获阶段 → 目标阶段 → 冒泡阶段。
关键注意事项
| 事件 | 冒泡特性 | 注意事项 |
|---|---|---|
dragstart | 冒泡 | 在捕获阶段拦截可阻止拖拽启动 |
drag | 冒泡 | 高频触发,避免在父元素监听 |
dragenter | 冒泡 | 子元素会触发父元素的 dragleave |
dragover | 冒泡 | 可在父容器统一处理 |
drop | 冒泡 | 需阻止冒泡避免触发父级处理 |
dragend | 冒泡 | 无论放置成功与否都会触发 |
事件委托示例
// 在父容器上统一处理拖放(适用于动态列表)
container.addEventListener("dragstart", (e) => {
const item = e.target.closest("[draggable]")
if (!item) return
// 处理拖拽开始
}, true) // 使用捕获阶段,优先于子元素处理
container.addEventListener("drop", (e) => {
e.stopPropagation() // 阻止冒泡,避免触发外层处理器
// 处理放置
})3.5 DataTransfer 数据类型选择决策树
在拖放操作中,根据不同的数据传输需求,需要选择合适的 setData/getData MIME 类型。以下决策树帮助开发者快速确定最佳数据类型策略:
各数据类型的典型应用场景
| MIME 类型 | 典型场景 | 安全等级 | 推荐度 |
|---|---|---|---|
text/plain | 传递 ID、简单文本 | ⭐⭐⭐ 高 | ★★★★★ 最常用 |
application/json | 传递复杂业务对象 | ⭐⭐⭐ 高 | ★★★★★ 推荐 |
text/uri-list | 拖拽链接、URL | ⭐⭐⭐ 高 | ★★★★☆ 常用 |
text/html | 富文本片段拖放 | ⭐ 低(XSS 风险) | ★★☆☆☆ 谨慎使用 |
Files(隐式) | 文件拖放上传 | ⭐⭐ 中(需服务端校验) | ★★★★★ 核心场景 |
3.6 模块边界建议
- 交互模块:只处理 UI 与用户反馈,不直接写业务规则。
- 控制模块:统一管理拖放事件、状态与数据提取。
- 校验模块:封装可放置规则、文件规则、权限规则。
- 业务模块:负责排序持久化、上传与失败重试。
4. 核心功能模块
4.1 拖拽源模块
- 设置
draggable="true"使元素可拖动。 - 在
dragstart写入业务数据(setData)并声明effectAllowed。 - 在
dragend清理拖拽态样式与临时状态。
状态管理建议
const dragState = {
source: null, // 当前拖拽元素
data: null, // 携带数据
startTime: 0, // 拖拽开始时间
canceled: false // 是否取消
}4.2 放置目标模块
dragenter/dragleave:处理区域高亮与离开反馈。dragover:调用preventDefault()以允许放置。drop:读取dataTransfer,执行业务逻辑并清理状态。
嵌套放置区域处理
// 避免子元素触发 dragleave 导致闪烁
let dragEnterCount = 0
dropzone.addEventListener("dragenter", (e) => {
dragEnterCount++
dropzone.classList.add("active")
})
dropzone.addEventListener("dragleave", (e) => {
dragEnterCount--
if (dragEnterCount === 0) {
dropzone.classList.remove("active")
}
})
dropzone.addEventListener("drop", (e) => {
dragEnterCount = 0
dropzone.classList.remove("active")
})4.3 数据传输模块
- 文本/结构化数据:
setData("text/plain", value)、setData("application/json", json)。 - 文件数据:
event.dataTransfer.files或event.dataTransfer.items。 - 受浏览器安全限制,跨源与跨应用拖放行为存在差异。
DataTransfer 读写注意事项
// 正确:在 dragstart 中写入数据
element.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("application/json", JSON.stringify(data))
e.dataTransfer.effectAllowed = "move"
})
// 正确:在 drop 中读取数据
dropzone.addEventListener("drop", (e) => {
const json = e.dataTransfer.getData("application/json")
if (json) {
const data = JSON.parse(json)
// 处理数据
}
})
// 错误:在 dragover 中读取数据(安全限制,数据不可用)
dropzone.addEventListener("dragover", (e) => {
e.preventDefault()
// e.dataTransfer.getData() 返回空字符串
})4.4 文件拖放模块
- 支持"拖放 + 点击选择"双入口,提升可用性。
- 校验数量、类型、大小后再触发上传。
- 显示上传进度、失败原因与重试按钮。
文件拖放增强功能
- 拖放文件夹(
webkitGetAsEntryAPI) - 分片上传大文件
- 拖放预览(图片、视频)
4.5 列表排序模块
- 计算当前拖拽项目标索引并更新 DOM 顺序。
- 更新本地状态后调用后端持久化排序结果。
- 避免频繁重排,必要时使用节流或
requestAnimationFrame。
排序算法优化
// 使用二分查找优化大列表排序位置计算
function findInsertIndex(list, dragY) {
const items = Array.from(list.children)
let low = 0, high = items.length - 1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
const rect = items[mid].getBoundingClientRect()
const midY = rect.top + rect.height / 2
if (dragY < midY) {
high = mid - 1
} else {
low = mid + 1
}
}
return low
}拖拽排序算法时序图
以下时序图展示了从用户拖拽到 DOM 更新完成的完整排序流程,重点展示位置计算、DOM 操作和动画过渡的协作关系:
FLIP 动画原理说明
上述时序图中提到的 FLIP 是一种高性能动画技术,特别适合拖拽排序场景:
| 步骤 | 操作 | 说明 |
|---|---|---|
| First | 记录元素的初始位置 | 在 DOM 变更前调用 getBoundingClientRect() |
| Last | 执行 DOM 变更 | insertBefore 后元素已在新位置 |
| Invert | 用 transform 将元素移回原位 | 计算 newRect - oldRect 的差值 |
| Play | 用 CSS transition 动画回新位置 | 移除 transform,触发平滑过渡 |
4.6 可访问性模块
- 为可拖拽元素提供键盘替代操作(上移/下移按钮)。
- 使用
aria-grabbed、aria-dropeffect与实时状态提示文本。 - 保证无鼠标场景可完成核心业务流程。
5. API 接口说明
5.1 拖放事件总览
| 事件 | 触发主体 | 触发时机 | 关键说明 |
|---|---|---|---|
dragstart | 拖拽源 | 开始拖拽 | 可设置 dataTransfer 与 effectAllowed |
drag | 拖拽源 | 拖拽过程中 | 高频事件,避免重计算 |
dragend | 拖拽源 | 拖拽结束 | 无论成功失败都会触发 |
dragenter | 放置目标 | 拖拽进入目标 | 用于高亮放置区 |
dragover | 放置目标 | 悬停目标上方 | 必须 preventDefault() |
dragleave | 放置目标 | 离开目标 | 清理高亮状态 |
drop | 放置目标 | 释放鼠标 | 读取数据并执行业务逻辑 |
5.2 DragEvent 接口
interface DragEvent extends MouseEvent {
readonly dataTransfer: DataTransfer | null
}继承自 MouseEvent 的常用属性
| 属性 | 类型 | 说明 |
|---|---|---|
clientX / clientY | number | 视口坐标 |
pageX / pageY | number | 文档坐标(含滚动) |
screenX / screenY | number | 屏幕坐标 |
target | EventTarget | 事件目标元素 |
currentTarget | EventTarget | 事件绑定元素 |
relatedTarget | EventTarget | 相关元素(dragenter/dragleave) |
5.3 DataTransfer 核心属性与方法
| 名称 | 类型 | 说明 |
|---|---|---|
effectAllowed | string | 拖拽源允许的操作类型 |
dropEffect | string | 当前放置效果 |
types | DOMStringList | 当前可读取的数据类型列表 |
files | FileList | 拖放的文件集合 |
items | DataTransferItemList | 数据项集合(文本/文件) |
setData(type, data) | function | 写入字符串数据 |
getData(type) | function | 读取字符串数据 |
clearData(type?) | function | 清理数据 |
setDragImage(el, x, y) | function | 设置自定义拖拽图像 |
effectAllowed 与 dropEffect 配合
| effectAllowed | 含义 | 对应 dropEffect |
|---|---|---|
none | 不允许任何操作 | none |
copy | 允许复制 | copy |
move | 允许移动 | move |
link | 允许链接 | link |
copyMove | 允许复制或移动 | copy / move |
copyLink | 允许复制或链接 | copy / link |
linkMove | 允许链接或移动 | link / move |
all | 允许所有操作 | 任意 |
// 源元素声明只允许移动
source.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "move"
})
// 目标元素根据位置决定效果
target.addEventListener("dragover", (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = isCopyArea(e) ? "copy" : "move"
})5.4 DataTransferItemList 与 DataTransferItem
interface DataTransferItemList {
length: number
add(data: string, type: string): DataTransferItem
add(file: File): DataTransferItem
remove(index: number): void
clear(): void
[index: number]: DataTransferItem
}
interface DataTransferItem {
kind: "string" | "file"
type: string
getAsString(callback: (data: string) => void): void
getAsFile(): File | null
webkitGetAsEntry(): FileSystemEntry | null // 用于读取文件夹
}使用示例
// 区分文本和文件
dropzone.addEventListener("drop", (e) => {
const items = e.dataTransfer.items
for (const item of items) {
if (item.kind === "file") {
const file = item.getAsFile()
console.log("文件:", file.name)
} else if (item.kind === "string") {
item.getAsString((data) => {
console.log("文本:", data)
})
}
}
})5.5 推荐封装接口(示例)
export interface DragPayload {
type: "card" | "file" | "text"
id?: string
data?: Record<string, unknown>
}
export interface DropzoneOptions {
accept?: string[]
maxCount?: number
maxSize?: number
disabled?: boolean
highlightClass?: string
preventBodyDrop?: boolean
}
export interface DropResult<T = unknown> {
ok: boolean
reason?: string
payload?: T
files?: File[]
}
export function bindDropzone(el: HTMLElement, options: DropzoneOptions): () => void
export function setDragPayload(event: DragEvent, payload: DragPayload): void
export function getDragPayload(event: DragEvent): DragPayload | null
export function validateDroppedFiles(files: File[], options: DropzoneOptions): string[]5.6 服务端接口约定(拖放上传场景)
| 接口 | 方法 | 用途 | 关键请求参数 | 关键响应字段 |
|---|---|---|---|---|
/api/dnd/upload | POST | 拖放文件直传 | multipart/form-data,字段 file | fileId、url |
/api/dnd/upload/chunk | POST | 上传文件分片 | fileId、chunkIndex、chunk | received |
/api/dnd/upload/merge | POST | 合并分片 | fileId、fileName | url、size |
/api/dnd/sort | POST | 持久化排序结果 | listId、orderedIds | success |
状态码建议
200/201:处理成功。400:参数或数据格式错误。401/403:未授权或无权限。413:文件过大。415:媒体类型不支持。500:服务端异常。
6. 配置参数详解
6.1 Dropzone 配置
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
accept | string[] | [] | 允许拖入的 MIME/扩展名白名单 |
maxCount | number | 1 | 最大文件数量 |
maxSize | number | 10MB | 单文件大小上限 |
disabled | boolean | false | 是否禁用拖放 |
highlightClass | string | "drag-over" | 悬停高亮类名 |
preventBodyDrop | boolean | true | 是否拦截页面级默认放置行为 |
6.2 排序配置
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
axis | "x" / "y" / "both" | "y" | 允许拖拽方向 |
handleSelector | string | 无 | 拖拽手柄选择器 |
animationMs | number | 150 | 排序动画时长 |
ghostClass | string | "dragging" | 拖拽态样式类 |
onMove | function | 无 | 拖拽过程中回调 |
onDrop | function | 无 | 放置完成回调 |
6.3 调优建议
- 高频
dragover场景:将位置计算放到requestAnimationFrame。 - 大量列表项排序:优先更新数据模型,批量提交 DOM 变化。
- 页面级文件拖放:统一拦截
document的dragover/drop,避免浏览器直接打开文件。
7. 使用示例
<h4>005-image-drop-preview.html</h4><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【5】拖放图片预览</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 700px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
.preview-zone {
width: 100%;
height: 350px;
border: 3px dashed #ccc;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
overflow: hidden;
position: relative;
transition: all 0.3s;
}
.preview-zone.drag-over {
border-color: #007bff;
background: #e7f3ff;
}
.preview-zone.has-image {
border-style: solid;
border-color: #ddd;
}
.placeholder {
text-align: center;
color: #999;
}
.placeholder .icon { font-size: 64px; margin-bottom: 16px; }
.placeholder p { font-size: 15px; }
.placeholder .hint { font-size: 13px; color: #aaa; margin-top: 8px; }
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.image-info {
margin-top: 16px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.info-item {
text-align: center;
}
.info-label { font-size: 12px; color: #666; margin-bottom: 4px; }
.info-value { font-weight: 600; color: #333; font-size: 14px; }
.actions {
margin-top: 16px;
text-align: center;
}
.btn {
padding: 10px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
}
.btn-primary { background: #007bff; color: white; }
.btn-primary:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; color: white; }
.btn-secondary:hover { background: #5a6268; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:拖放图片预览 - Object URL 方式</div>
<div class="preview-zone" id="previewZone">
<div class="placeholder" id="placeholder">
<div class="icon">🖼️</div>
<p><strong>拖放图片到此处预览</strong></p>
<p class="hint">支持 JPG、PNG、GIF、WebP 格式</p>
</div>
</div>
<div class="image-info" id="imageInfo" style="display: none;">
<div class="info-item">
<div class="info-label">文件名</div>
<div class="info-value" id="fileName">-</div>
</div>
<div class="info-item">
<div class="info-label">文件大小</div>
<div class="info-value" id="fileSize">-</div>
</div>
<div class="info-item">
<div class="info-label">MIME 类型</div>
<div class="info-value" id="fileType">-</div>
</div>
</div>
<div class="actions" id="actions" style="display: none;">
<button class="btn btn-primary" onclick="document.getElementById('fileInput').click()">选择其他图片</button>
<button class="btn btn-secondary" onclick="clearPreview()">清除预览</button>
<input type="file" id="fileInput" accept="image/*" style="display:none;" />
</div>
</div>
<script>
const previewZone = document.getElementById("previewZone")
const placeholder = document.getElementById("placeholder")
const imageInfo = document.getElementById("imageInfo")
const actions = document.getElementById("actions")
const fileInput = document.getElementById("fileInput")
let currentUrl = null
// 拖拽事件
previewZone.addEventListener("dragover", (event) => {
event.preventDefault()
previewZone.classList.add("drag-over")
})
previewZone.addEventListener("dragleave", () => {
previewZone.classList.remove("drag-over")
})
previewZone.addEventListener("drop", (event) => {
event.preventDefault()
previewZone.classList.remove("drag-over")
const file = event.dataTransfer.files[0]
if (file) showPreview(file)
})
// 文件选择
fileInput.addEventListener("change", (event) => {
const file = event.target.files?.[0]
if (file) showPreview(file)
})
function showPreview(file) {
if (!file.type.startsWith("image/")) {
alert("请拖放图片文件!")
return
}
// 清理之前的 URL
if (currentUrl) URL.revokeObjectURL(currentUrl)
currentUrl = URL.createObjectURL(file)
placeholder.style.display = "none"
const img = document.createElement("img")
img.className = "preview-image"
img.src = currentUrl
img.alt = file.name
img.onload = () => URL.revokeObjectURL(currentUrl)
previewZone.appendChild(img)
previewZone.classList.add("has-image")
// 显示信息
imageInfo.style.display = "grid"
actions.style.display = "block"
document.getElementById("fileName").textContent = file.name
document.getElementById("fileSize").textContent = formatBytes(file.size)
document.getElementById("fileType").textContent = file.type || "未知"
}
function clearPreview() {
if (currentUrl) URL.revokeObjectURL(currentUrl)
currentUrl = null
const img = previewZone.querySelector(".preview-image")
if (img) img.remove()
placeholder.style.display = "block"
previewZone.classList.remove("has-image")
imageInfo.style.display = "none"
actions.style.display = "none"
fileInput.value = ""
}
function formatBytes(bytes) {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
}
</script>
</body>
</html>```
### 7.1 示例一:基础数据拖放
```html
<div id="dragCard" draggable="true">拖拽卡片 A</div>
<div id="dropZone">放到这里</div>
<script>
const dragCard = document.getElementById("dragCard")
const dropZone = document.getElementById("dropZone")
dragCard.addEventListener("dragstart", (event) => {
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.setData("application/json", JSON.stringify({ id: "card-a", type: "card" }))
})
dropZone.addEventListener("dragover", (event) => {
event.preventDefault()
event.dataTransfer.dropEffect = "move"
})
dropZone.addEventListener("drop", (event) => {
event.preventDefault()
const raw = event.dataTransfer.getData("application/json")
if (!raw) return
const payload = JSON.parse(raw)
dropZone.textContent = `已放置:${payload.id}`
})
</script>7.2 示例二:文件拖放 + 校验 + 回退选择
<div id="fileDrop" class="drop-zone">拖放文件到此,或点击选择</div>
<input id="fileInput" type="file" multiple hidden />
<ul id="result"></ul>
<script>
const fileDrop = document.getElementById("fileDrop")
const fileInput = document.getElementById("fileInput")
const result = document.getElementById("result")
const ALLOWED = ["image/png", "image/jpeg", "application/pdf"]
const MAX_SIZE = 5 * 1024 * 1024
fileDrop.addEventListener("click", () => fileInput.click())
fileDrop.addEventListener("dragover", (event) => event.preventDefault())
fileDrop.addEventListener("drop", (event) => {
event.preventDefault()
handleFiles(Array.from(event.dataTransfer.files))
})
fileInput.addEventListener("change", (event) => handleFiles(Array.from(event.target.files || [])))
function handleFiles(files) {
result.innerHTML = ""
for (const file of files) {
const errors = []
if (!ALLOWED.includes(file.type)) errors.push("类型不支持")
if (file.size > MAX_SIZE) errors.push("超出 5MB")
const li = document.createElement("li")
li.textContent = `${file.name} - ${errors.length ? errors.join("、") : "校验通过"}`
result.appendChild(li)
}
}
</script>7.3 示例三:可排序列表(最小实现)
<ul id="sortable">
<li draggable="true" data-id="1">任务 1</li>
<li draggable="true" data-id="2">任务 2</li>
<li draggable="true" data-id="3">任务 3</li>
</ul>
<script>
const list = document.getElementById("sortable")
let dragging = null
list.addEventListener("dragstart", (event) => {
const target = event.target.closest("li")
if (!target) return
dragging = target
event.dataTransfer.effectAllowed = "move"
})
list.addEventListener("dragover", (event) => {
event.preventDefault()
const target = event.target.closest("li")
if (!target || target === dragging) return
const rect = target.getBoundingClientRect()
const isAfter = event.clientY > rect.top + rect.height / 2
list.insertBefore(dragging, isAfter ? target.nextSibling : target)
})
list.addEventListener("drop", (event) => {
event.preventDefault()
const orderedIds = Array.from(list.querySelectorAll("li")).map((item) => item.dataset.id)
console.log("最新顺序:", orderedIds)
dragging = null
})
</script>7.4 示例四:自定义拖拽图像
<style>
.drag-preview {
position: absolute;
top: -9999px;
background: #4a90d9;
color: white;
padding: 8px 16px;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
font-weight: bold;
pointer-events: none;
}
</style>
<div id="draggable" draggable="true">拖拽我</div>
<div id="preview" class="drag-preview">正在拖拽中...</div>
<script>
const draggable = document.getElementById("draggable")
const preview = document.getElementById("preview")
draggable.addEventListener("dragstart", (event) => {
event.dataTransfer.effectAllowed = "move"
// 设置自定义拖拽图像
event.dataTransfer.setDragImage(preview, 50, 20)
})
</script>7.5 示例五:拖拽手柄
<style>
.item { display: flex; align-items: center; padding: 8px; background: #f5f5f5; margin: 4px 0; }
.handle { cursor: grab; padding: 0 12px; color: #999; user-select: none; }
.item.dragging { opacity: 0.5; }
</style>
<div id="list">
<div class="item" draggable="true" data-id="1">
<span class="handle">☰</span>
<span class="content">项目 1</span>
</div>
<div class="item" draggable="true" data-id="2">
<span class="handle">☰</span>
<span class="content">项目 2</span>
</div>
</div>
<script>
const list = document.getElementById("list")
let dragging = null
let validDrag = false
list.addEventListener("dragstart", (event) => {
const handle = event.target.closest(".handle")
// 只允许通过手柄拖拽
if (!handle) {
event.preventDefault()
return
}
validDrag = true
dragging = event.target.closest(".item")
dragging.classList.add("dragging")
event.dataTransfer.effectAllowed = "move"
})
list.addEventListener("dragend", () => {
if (dragging) dragging.classList.remove("dragging")
dragging = null
validDrag = false
})
list.addEventListener("dragover", (event) => {
event.preventDefault()
if (!validDrag || !dragging) return
const target = event.target.closest(".item")
if (!target || target === dragging) return
const rect = target.getBoundingClientRect()
const isAfter = event.clientY > rect.top + rect.height / 2
list.insertBefore(dragging, isAfter ? target.nextSibling : target)
})
</script>7.6 示例六:跨容器拖放
<style>
.container { display: inline-block; width: 200px; min-height: 150px; border: 2px dashed #ccc; padding: 10px; margin: 10px; vertical-align: top; }
.container.drag-over { border-color: #4a90d9; background: #f0f7ff; }
.item { padding: 8px; background: #fff; margin: 4px 0; border: 1px solid #ddd; cursor: move; }
.item.dragging { opacity: 0.5; }
</style>
<div id="todo" class="container" data-status="todo">
<h3>待办</h3>
<div class="item" draggable="true" data-id="1">任务 A</div>
<div class="item" draggable="true" data-id="2">任务 B</div>
</div>
<div id="done" class="container" data-status="done">
<h3>已完成</h3>
<div class="item" draggable="true" data-id="3">任务 C</div>
</div>
<script>
const containers = document.querySelectorAll(".container")
let dragging = null
let sourceContainer = null
// 全局拖拽开始
document.addEventListener("dragstart", (event) => {
const item = event.target.closest(".item")
if (!item) return
dragging = item
sourceContainer = item.closest(".container")
item.classList.add("dragging")
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.setData("text/plain", item.dataset.id)
})
// 全局拖拽结束
document.addEventListener("dragend", () => {
if (dragging) dragging.classList.remove("dragging")
containers.forEach(c => c.classList.remove("drag-over"))
dragging = null
sourceContainer = null
})
containers.forEach(container => {
container.addEventListener("dragenter", (event) => {
event.preventDefault()
container.classList.add("drag-over")
})
container.addEventListener("dragover", (event) => {
event.preventDefault()
event.dataTransfer.dropEffect = "move"
})
container.addEventListener("dragleave", (event) => {
// 检查是否真正离开容器
if (!container.contains(event.relatedTarget)) {
container.classList.remove("drag-over")
}
})
container.addEventListener("drop", (event) => {
event.preventDefault()
container.classList.remove("drag-over")
if (!dragging) return
// 执行移动
const targetItem = event.target.closest(".item")
if (targetItem && targetItem !== dragging) {
const rect = targetItem.getBoundingClientRect()
const isAfter = event.clientY > rect.top + rect.height / 2
container.insertBefore(dragging, isAfter ? targetItem.nextSibling : targetItem)
} else {
container.appendChild(dragging)
}
// 输出状态变化
const newStatus = container.dataset.status
const oldStatus = sourceContainer.dataset.status
if (newStatus !== oldStatus) {
console.log(`任务 ${dragging.dataset.id} 从 ${oldStatus} 移动到 ${newStatus}`)
}
})
})
</script>7.7 示例七:拖放图片预览
<style>
.dropzone { width: 300px; height: 200px; border: 2px dashed #ccc; display: flex; align-items: center; justify-content: center; background: #fafafa; }
.dropzone.drag-over { border-color: #4a90d9; background: #f0f7ff; }
.preview { max-width: 100%; max-height: 100%; object-fit: contain; }
</style>
<div id="imageDrop" class="dropzone">拖放图片到这里</div>
<script>
const dropzone = document.getElementById("imageDrop")
dropzone.addEventListener("dragover", (event) => {
event.preventDefault()
dropzone.classList.add("drag-over")
})
dropzone.addEventListener("dragleave", () => {
dropzone.classList.remove("drag-over")
})
dropzone.addEventListener("drop", (event) => {
event.preventDefault()
dropzone.classList.remove("drag-over")
const file = event.dataTransfer.files[0]
if (!file || !file.type.startsWith("image/")) {
alert("请拖放图片文件")
return
}
// 创建预览
const img = document.createElement("img")
img.className = "preview"
img.src = URL.createObjectURL(file)
img.onload = () => URL.revokeObjectURL(img.src) // 清理内存
dropzone.innerHTML = ""
dropzone.appendChild(img)
})
</script>7.8 示例八:拖放文件夹(webkitGetAsEntry)
<style>
.dropzone { padding: 20px; border: 2px dashed #ccc; min-height: 100px; }
.dropzone.drag-over { border-color: #4a90d9; }
#tree { font-family: monospace; white-space: pre; margin-top: 10px; }
</style>
<div id="folderDrop" class="dropzone">拖放文件夹到这里</div>
<div id="tree"></div>
<script>
const dropzone = document.getElementById("folderDrop")
const treeOutput = document.getElementById("tree")
dropzone.addEventListener("dragover", (event) => {
event.preventDefault()
dropzone.classList.add("drag-over")
})
dropzone.addEventListener("dragleave", () => {
dropzone.classList.remove("drag-over")
})
dropzone.addEventListener("drop", async (event) => {
event.preventDefault()
dropzone.classList.remove("drag-over")
const items = event.dataTransfer.items
const results = []
for (const item of items) {
const entry = item.webkitGetAsEntry?.()
if (entry) {
await traverseEntry(entry, "", results)
}
}
treeOutput.textContent = results.join("\n")
})
async function traverseEntry(entry, path, results) {
const fullPath = path + entry.name
if (entry.isFile) {
results.push(`📄 ${fullPath}`)
} else if (entry.isDirectory) {
results.push(`📁 ${fullPath}/`)
const reader = entry.createReader()
const entries = await new Promise((resolve) => {
reader.readEntries(resolve)
})
for (const child of entries) {
await traverseEntry(child, fullPath + "/", results)
}
}
}
</script>8. 移动端触摸事件替代方案
由于移动浏览器对原生 HTML5 拖放 API 的支持不一致,推荐使用触摸/指针事件实现移动端拖拽。
8.1 三种拖拽实现方案对比
以下图表系统性地对比了桌面端原生 DnD、移动端 Touch API 和 Pointer Events 三种方案的特性差异,帮助开发者在不同场景下做出合理的技术选型:
三种方案详细对比矩阵
| 特性维度 | HTML5 DnD API | Touch Events | Pointer Events |
|---|---|---|---|
| 设备覆盖 | 仅桌面端良好 | 仅移动端 | 全平台统一 |
| 事件模型 | drag* 系列 7 个事件 | touch* 系列 4 个事件 | pointer* 系列 6+ 个事件 |
| 数据传输 | 内置 DataTransfer 对象 | 需自行维护状态 | 需自行维护状态 |
| 拖拽图像 | 浏览器自动生成 / setDragImage | 需手动创建克隆元素 | 需手动创建克隆元素 |
| 多点触控 | ❌ 不支持 | ✅ touches 数组 | ✅ isPrimary 过滤 |
| 自定义光标 | 通过 dropEffect 控制 | ❌ 不适用 | ❌ 不适用 |
| iOS Safari | ❌ 几乎不可用 | ✅ 可用但有限制 | ⚠️ 部分可用(见 FAQ) |
| Android Chrome | ⚠️ 有限支持 | ✅ 良好 | ✅ 良好 |
| 代码复用性 | 桌面/移动需分开实现 | 桌面/移动需分开实现 | 一套代码全平台 |
| 学习成本 | 低(API 完善) | 中(需理解触摸机制) | 中(概念较新) |
| 社区生态 | 成熟库少(因移动端缺陷) | 丰富 | 快速增长 |
| 推荐指数 | ★★★☆☆(仅桌面) | ★★★☆☆(仅移动端) | ★★★★★(首选) |
8.2 触摸事件基础
| 事件 | 说明 |
|---|---|
touchstart | 手指触摸屏幕 |
touchmove | 手指移动 |
touchend | 手指离开屏幕 |
touchcancel | 触摸被中断(如来电) |
8.3 指针事件(推荐)
指针事件统一处理鼠标、触摸、触控笔输入,兼容性更好。
| 事件 | 说明 |
|---|---|
pointerdown | 指针按下 |
pointermove | 指针移动 |
pointerup | 指针抬起 |
pointercancel | 指针取消 |
8.4 移动端拖拽实现示例
<style>
.draggable { width: 100px; height: 100px; background: #4a90d9; color: white; display: flex; align-items: center; justify-content: center; touch-action: none; }
.dropzone { width: 200px; height: 200px; border: 2px dashed #ccc; margin: 20px; display: inline-block; }
.dropzone.active { border-color: #4a90d9; background: #f0f7ff; }
</style>
<div id="draggable" class="draggable">拖拽我</div>
<div id="dropzone" class="dropzone">放置区域</div>
<script>
const draggable = document.getElementById("draggable")
const dropzone = document.getElementById("dropzone")
let isDragging = false
let startX = 0, startY = 0
let offsetX = 0, offsetY = 0
let clone = null
let originalParent = null
// 使用指针事件
draggable.addEventListener("pointerdown", (event) => {
isDragging = true
draggable.setPointerCapture(event.pointerId)
startX = event.clientX
startY = event.clientY
const rect = draggable.getBoundingClientRect()
offsetX = startX - rect.left
offsetY = startY - rect.top
originalParent = draggable.parentElement
// 创建拖拽副本(视觉反馈)
clone = draggable.cloneNode(true)
clone.style.position = "fixed"
clone.style.left = rect.left + "px"
clone.style.top = rect.top + "px"
clone.style.width = rect.width + "px"
clone.style.opacity = "0.8"
clone.style.pointerEvents = "none"
clone.style.zIndex = "1000"
document.body.appendChild(clone)
draggable.style.opacity = "0.3"
})
document.addEventListener("pointermove", (event) => {
if (!isDragging || !clone) return
clone.style.left = (event.clientX - offsetX) + "px"
clone.style.top = (event.clientY - offsetY) + "px"
// 检测是否在放置区域上方
const dropRect = dropzone.getBoundingClientRect()
const isOver = (
event.clientX >= dropRect.left &&
event.clientX <= dropRect.right &&
event.clientY >= dropRect.top &&
event.clientY <= dropRect.bottom
)
dropzone.classList.toggle("active", isOver)
})
document.addEventListener("pointerup", (event) => {
if (!isDragging) return
isDragging = false
// 检测放置位置
const dropRect = dropzone.getBoundingClientRect()
const isDropped = (
event.clientX >= dropRect.left &&
event.clientX <= dropRect.right &&
event.clientY >= dropRect.top &&
event.clientY <= dropRect.bottom
)
if (isDropped) {
dropzone.appendChild(draggable)
dropzone.classList.remove("active")
}
// 清理
draggable.style.opacity = "1"
if (clone) {
clone.remove()
clone = null
}
})
</script>8.5 移动端注意事项
| 问题 | 解决方案 |
| --- | --- | --- |
| 页面滚动冲突 | 在拖拽元素上设置 touch-action: none |
| 长按触发菜单 | event.preventDefault() 或 CSS -webkit-touch-callout: none |
| 多指触摸 | 检查 event.isPrimary 或跟踪触摸标识 |
| 性能问题 | 使用 transform 而非 left/top 移动元素 |
| 点击穿透 | 在 pointerup 后延迟 300ms 移除副本 |
8.6 桌面 + 移动端统一封装
class UnifiedDragDrop {
constructor(options) {
this.options = options
this.isDragging = false
this.supportsPointerEvents = "PointerEvent" in window
}
bind(element, dropzone) {
if (this.supportsPointerEvents) {
this.bindPointerEvents(element, dropzone)
} else {
this.bindMouseEvents(element, dropzone)
this.bindTouchEvents(element, dropzone)
}
}
bindPointerEvents(element, dropzone) {
element.style.touchAction = "none"
element.addEventListener("pointerdown", (e) => {
if (!e.isPrimary) return
this.startDrag(e, element)
})
document.addEventListener("pointermove", (e) => {
if (!this.isDragging) return
this.moveDrag(e, element, dropzone)
})
document.addEventListener("pointerup", (e) => {
if (!this.isDragging) return
this.endDrag(e, element, dropzone)
})
}
// ... 实现细节
}9. 可访问性实现
9.1 ARIA 属性
| 属性 | 用途 | 示例 |
|---|---|---|
aria-grabbed | 标识元素是否被拖拽 | "true" / "false" |
aria-dropeffect | 预期的放置效果 | "copy" / "move" / "link" / "none" |
aria-label | 描述拖拽目的 | "拖拽以重新排序" |
role="listitem" | 列表项角色 | 配合 role="list" |
9.2 键盘支持
<style>
.item:focus { outline: 2px solid #4a90d9; }
.item:focus-visible { outline-offset: 2px; }
</style>
<ul role="list">
<li class="item" draggable="true" tabindex="0"
role="listitem" aria-grabbed="false"
aria-label="任务 1,使用上下箭头键移动">
任务 1
</li>
<li class="item" draggable="true" tabindex="0"
role="listitem" aria-grabbed="false">
任务 2
</li>
</ul>
<script>
const list = document.querySelector("ul")
let focusedIndex = 0
list.addEventListener("keydown", (event) => {
const items = Array.from(list.querySelectorAll(".item"))
const current = document.activeElement
const currentIndex = items.indexOf(current)
switch (event.key) {
case "ArrowUp":
event.preventDefault()
if (currentIndex > 0) {
items[currentIndex - 1].focus()
}
break
case "ArrowDown":
event.preventDefault()
if (currentIndex < items.length - 1) {
items[currentIndex + 1].focus()
}
break
case " ":
// 空格键开始拖拽
event.preventDefault()
current.setAttribute("aria-grabbed", "true")
current.classList.add("grabbed")
break
case "Enter":
// 回车键放置
event.preventDefault()
const grabbed = list.querySelector(".item[aria-grabbed='true']")
if (grabbed && grabbed !== current) {
// 执行移动
const isAfter = items.indexOf(grabbed) < currentIndex
list.insertBefore(grabbed, isAfter ? current.nextSibling : current)
grabbed.setAttribute("aria-grabbed", "false")
grabbed.classList.remove("grabbed")
// 通知屏幕阅读器
announce(`已将 ${grabbed.textContent} 移动到位置 ${isAfter ? currentIndex + 1 : currentIndex}`)
}
break
case "Escape":
// 取消拖拽
const grabbedItem = list.querySelector(".item[aria-grabbed='true']")
if (grabbedItem) {
grabbedItem.setAttribute("aria-grabbed", "false")
grabbedItem.classList.remove("grabbed")
announce("已取消拖拽")
}
break
}
})
// 屏幕阅读器通知
function announce(message) {
const announcer = document.getElementById("announcer") || createAnnouncer()
announcer.textContent = message
}
function createAnnouncer() {
const el = document.createElement("div")
el.id = "announcer"
el.setAttribute("role", "status")
el.setAttribute("aria-live", "polite")
el.style.cssText = "position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;"
document.body.appendChild(el)
return el
}
</script>9.3 高对比度模式
@media (forced-colors: active) {
.item[aria-grabbed="true"] {
outline: 3px dashed Highlight;
}
.dropzone.drag-over {
outline: 3px solid Highlight;
}
}10. 性能优化
10.1 高频事件优化
// dragover 节流 + requestAnimationFrame
let rafId = null
dropzone.addEventListener("dragover", (event) => {
event.preventDefault()
if (rafId) return
rafId = requestAnimationFrame(() => {
// 执行位置计算
updateDragPosition(event)
rafId = null
})
})
dropzone.addEventListener("dragleave", () => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
})10.2 大列表虚拟化
// 只对可见项启用拖拽
function updateDraggableState(container) {
const items = container.querySelectorAll(".item")
const viewportTop = window.scrollY
const viewportBottom = viewportTop + window.innerHeight
items.forEach(item => {
const rect = item.getBoundingClientRect()
const isVisible = rect.bottom > 0 && rect.top < window.innerHeight
item.draggable = isVisible
})
}
// 滚动时更新
window.addEventListener("scroll", () => {
requestAnimationFrame(() => updateDraggableState(container))
}, { passive: true })10.3 DOM 操作优化
// 使用 DocumentFragment 批量操作
function reorderItems(list, fromIndex, toIndex) {
const items = Array.from(list.children)
const fragment = document.createDocumentFragment()
// 重新排序
const [moved] = items.splice(fromIndex, 1)
items.splice(toIndex, 0, moved)
// 一次性更新 DOM
items.forEach(item => fragment.appendChild(item))
list.appendChild(fragment)
}
// 使用 CSS transform 代替 DOM 重排(动画)
function animateReorder(element, fromRect, toRect) {
element.style.transition = "none"
element.style.transform = `translate(${fromRect.left - toRect.left}px, ${fromRect.top - toRect.top}px)`
requestAnimationFrame(() => {
element.style.transition = "transform 0.15s ease"
element.style.transform = ""
})
}10.4 内存管理
// 清理对象 URL
function previewFile(file) {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
// 使用完毕后立即释放
URL.revokeObjectURL(url)
}
img.src = url
}
// 移除事件监听器
class DragDropManager {
constructor() {
this.handlers = new Map()
}
on(element, event, handler) {
element.addEventListener(event, handler)
this.handlers.set(`${element.id}-${event}`, { element, event, handler })
}
destroy() {
this.handlers.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler)
})
this.handlers.clear()
}
}11. 拖放操作性能基准测试数据
本节提供基于实际测试的性能基准数据,帮助开发者在不同规模和场景下做出合理的性能取舍。以下数据基于 Chrome 120+ / macOS / M2 芯片的测试环境采集,仅供参考,具体数值受设备性能影响。
11.1 不同列表规模下的 dragover 事件频率
在拖拽过程中,dragover 事件的触发频率直接影响排序流畅度。以下是不同列表规模下的实测数据:
| 列表项数量 | dragover 频率(次/秒) | 单次处理耗时(均值) | CPU 占用峰值 | 内存增量 | 用户体验评级 |
|---|---|---|---|---|---|
| 10 项 | ~60 fps | 0.12 ms | 3% | < 1 MB | ★★★★★ 流畅 |
| 50 项 | ~55 fps | 0.35 ms | 8% | ~2 MB | ★★★★★ 流畅 |
| 100 项 | ~48 fps | 0.82 ms | 15% | ~5 MB | ★★★★☆ 轻微卡顿 |
| 500 项 | ~30 fps | 3.5 ms | 35% | ~18 MB | ★★★☆☆ 明显卡顿 |
| 1000 项 | ~18 fps | 8.2 ms | 58% | ~42 MB | ★★☆☆☆ 严重卡顿 |
| 5000 项 | ~6 fps | 28 ms | 85% | ~156 MB | ★☆☆☆☆ 不可接受 |
关键发现
- 50 项以内:无需特殊优化,原生 API 即可胜任。
- 50–200 项:建议启用
requestAnimationFrame节流 + 二分查找定位。 - 200–1000 项:必须配合虚拟滚动,仅渲染可见区域。
- 1000 项以上:强烈建议使用虚拟滚动 + Web Worker 进行离线计算。
11.2 节流策略性能对比
针对高频 dragover 事件,不同节流策略的效果差异显著:
| 节流策略 | 实现方式 | 有效帧率提升 | 响应延迟 | 推荐场景 |
|---|---|---|---|---|
| 无节流 | 每次 dragover 都执行 | 基准(0%) | 0ms | ≤ 20 项小列表 |
| requestAnimationFrame | RAF 合并到下一帧 | ↑ 15–25% | ~16ms | 首选,大多数场景 |
| 时间节流 16ms | Date.now() 判断间隔 | ↑ 20–30% | 0–16ms | 需精确控制的场景 |
| 时间节流 33ms | 限制为 ~30fps | ↑ 40–50% | 0–33ms | 大列表粗粒度更新 |
| 节流 + RAF 组合 | 双重保障 | ↑ 45–55% | ~16ms | 超大规模列表 |
// 推荐:RAF + 时间节流组合方案
class ThrottledDragHandler {
constructor(callback, intervalMs = 16) {
this.callback = callback
this.intervalMs = intervalMs
this.lastCallTime = 0
this.rafId = null
this.pendingArgs = null
}
execute(...args) {
this.pendingArgs = args
const now = performance.now()
if (now - this.lastCallTime >= this.intervalMs) {
this.flush(now)
} else if (!this.rafId) {
this.rafId = requestAnimationFrame((time) => {
this.flush(time)
})
}
}
flush(time) {
this.lastCallTime = time
this.rafId = null
if (this.pendingArgs) {
this.callback(...this.pendingArgs)
this.pendingArgs = null
}
}
cancel() {
if (this.rafId) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
this.pendingArgs = null
}
}
// 使用
const handler = new ThrottledDragHandler(updatePosition, 16)
dropzone.addEventListener("dragover", (e) => {
e.preventDefault()
handler.execute(e)
})11.3 虚拟滚动 + 拖拽排序的实现要点
当列表超过 200 项时,虚拟滚动(Virtual Scrolling)成为必需方案。以下是核心实现要点:
架构设计原则
| 要点 | 说明 | 实现建议 |
|---|---|---|
| 视口内渲染 | 只渲染可见区域的列表项 | 使用 IntersectionObserver 或手动计算可见范围 |
| 占位空间保持 | 容器高度 = 总项数 × 平均高度 | 防止滚动条跳动 |
| 拖拽项脱离虚拟池 | 被拖拽的项必须始终存在于 DOM 中 | 从虚拟池中提取为独立元素 |
| 滚动跟随 | 拖拽至边缘时自动滚动列表 | 监听边界距离,触发 scrollBy |
| 位置映射转换 | 虚拟索引 ↔ 实际数据索引的双向映射 | 维护独立的 indexMap |
虚拟滚动排序的核心逻辑
class VirtualSortable {
constructor(container, options = {}) {
this.container = container
this.itemHeight = options.itemHeight || 48
this.visibleCount = options.visibleCount || 15
this.buffer = options.buffer || 5
this.data = [] // 完整数据集
this.visibleRange = { start: 0, end: this.visibleCount }
this.draggingItem = null // 脱离虚拟池的拖拽项
}
// 计算当前可见范围
updateVisibleRange(scrollTop) {
const start = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.buffer)
const end = Math.min(
this.data.length,
start + this.visibleCount + this.buffer * 2
)
this.visibleRange = { start, end }
this.render()
}
// 渲染可见项
render() {
const fragment = document.createDocumentFragment()
for (let i = this.visibleRange.start; i < this.visibleRange.end; i++) {
// 跳过正在拖拽的项(它已在 DOM 外部独立显示)
if (this.draggingItem?.dataset.virtualIndex == i) continue
const el = this.createItemElement(this.data[i], i)
el.style.position = "absolute"
el.style.top = `${i * this.itemHeight}px`
el.style.width = "100%"
fragment.appendChild(el)
}
// 保持总高度
this.container.innerHTML = ""
this.container.style.height = `${this.data.length * this.itemHeight}px`
this.container.style.position = "relative"
this.container.appendChild(fragment)
// 将拖拽项追加到最后(z-index 最高)
if (this.draggingItem) {
this.container.appendChild(this.draggingItem)
}
}
// 拖拽开始时将该项从虚拟池中取出
onDragStart(index, element) {
this.draggingItem = element.cloneNode(true)
this.draggingItem.dataset.virtualIndex = index
this.draggingItem.style.position = "absolute"
this.draggingItem.style.zIndex = "1000"
this.draggingItem.style.pointerEvents = "none"
}
// 拖拽结束时重新放入虚拟池
onDragEnd(newIndex) {
if (this.draggingItem) {
const fromIndex = parseInt(this.draggingItem.dataset.virtualIndex)
// 更新数据模型
const [moved] = this.data.splice(fromIndex, 1)
this.data.splice(newIndex, 0, moved)
this.draggingItem = null
this.render()
}
}
}11.4 requestAnimationFrame vs setTimeout 在拖拽预览中的性能差异
| 指标 | requestAnimationFrame | setTimeout(fn, 0) | setTimeout(fn, 16) |
|---|---|---|---|
| 调用频率 | 与显示器刷新率同步(60/120/144Hz) | 取决于事件循环(可能远超 60fps) | 固定约 60fps |
| 帧对齐 | ✅ 自动对齐到垂直同步 | ❌ 可能出现撕裂 | ⚠️ 近似对齐 |
| CPU 利用效率 | ★★★★★ 高效利用空闲周期 | ★★☆☆☆ 可能过度消耗 | ★★★★☆ 较好 |
| 电池影响 | 低 | 高(频繁唤醒) | 中等 |
| 拖拽跟随精度 | ★★★★★ 平滑 | ★★★☆☆ 可能抖动 | ★★★★☆ 较平滑 |
| 内存压力 | 低 | 中(回调堆积风险) | 低 |
| 取消便利性 | cancelAnimationFrame | clearTimeout | clearTimeout |
结论:在拖拽预览和位置更新的场景中,requestAnimationFrame 始终是首选方案。它不仅能保证视觉平滑性,还能有效降低 CPU 占用和电池消耗。
11.5 性能监控指标建议
在实际项目中,建议建立以下性能基线监控:
| 指标 | 采集方法 | 告警阈值 | 优化方向 |
|---|---|---|---|
| dragover 处理耗时 | performance.now() 包裹 | > 10ms(P95) | 算法优化 / 节流 |
| 拖拽期间 FPS | requestAnimationFrame 计数 | < 45 fps 持续 > 1s | 减少 DOM 操作 |
| 堆内存增长 | performance.memory.usedJSHeapSize | 每秒增长 > 5MB | 检查泄漏 / 及时清理 |
| 主线程阻塞时长 | PerformanceObserver(Long Task) | > 50ms | 拆分计算到 Worker |
| 布局抖动次数 | DevTools Layout Shift | CLS > 0.1 | 避免 synchronous layout |
// 性能监控工具函数
class DragPerformanceMonitor {
constructor() {
this.metrics = {
dragoverCount: 0,
totalProcessTime: 0,
maxProcessTime: 0,
frameDrops: 0,
startTime: 0
}
this.lastFrameTime = 0
}
startDrag() {
this.metrics = {
dragoverCount: 0,
totalProcessTime: 0,
maxProcessTime: 0,
frameDrops: 0,
startTime: performance.now()
}
this.lastFrameTime = this.metrics.startTime
}
recordDragover(processFn) {
const start = performance.now()
processFn()
const duration = performance.now() - start
this.metrics.dragoverCount++
this.metrics.totalProcessTime += duration
this.metrics.maxProcessTime = Math.max(this.metrics.maxProcessTime, duration)
// 检测掉帧(假设目标 60fps = 16.67ms/帧)
const now = performance.now()
if (now - this.lastFrameTime > 20) {
this.metrics.frameDrops++
}
this.lastFrameTime = now
}
getReport() {
const totalDuration = performance.now() - this.metrics.startTime
return {
totalDragoverEvents: this.metrics.dragoverCount,
avgProcessTime: (this.metrics.totalProcessTime / this.metrics.dragoverCount).toFixed(2) + "ms",
maxProcessTime: this.metrics.maxProcessTime.toFixed(2) + "ms",
estimatedFPS: (this.metrics.dragoverCount / (totalDuration / 1000)).toFixed(1),
frameDrops: this.metrics.frameDrops,
totalDuration: totalDuration.toFixed(0) + "ms"
}
}
}12. 高级拖拽模式
12.1 多选拖拽(Multi-Select Drag)
多选拖拽允许用户通过 Shift/Ctrl(macOS 为 Cmd)+ 点击选中多个项目后一起拖拽,常见于文件管理器和邮件客户端场景。
交互设计要点
| 操作 | 行为 | 适用平台 |
|---|---|---|
| 单击 | 选中/取消单个项目 | 所有平台 |
| Ctrl/Cmd + 单击 | 切换单个项目的选中状态 | 桌面端 |
| Shift + 单击 | 选中从上一个选中项到当前项的范围 | 桌面端 |
| Ctrl/Cmd + A | 全选 | 桌面端 |
| 拖拽已选中项 | 拖动所有选中项 | 所有平台 |
完整实现示例
<style>
.multi-list { list-style: none; padding: 0; }
.multi-item {
padding: 10px 14px;
margin: 4px 0;
background: #fff;
border: 2px solid transparent;
border-radius: 6px;
cursor: default;
user-select: none;
transition: all 0.15s ease;
}
.multi-item.selected {
background: #e3f2fd;
border-color: #2196f3;
}
.multi-item.selected::before {
content: "☑";
color: #2196f3;
margin-right: 8px;
font-weight: bold;
}
.multi-item.dragging {
opacity: 0.4;
}
.ghost-container {
position: fixed;
pointer-events: none;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 2px;
}
.ghost-item {
background: rgba(33, 150, 243, 0.9);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.selection-count {
position: fixed;
bottom: 20px;
right: 20px;
background: #2196f3;
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
z-index: 1000;
display: none;
}
.selection-count.visible { display: block; }
</style>
<ul id="multiList" class="multi-list">
<li class="multi-item" draggable="true" data-id="1">文档报告.docx</li>
<li class="multi-item" draggable="true" data-id="2">数据分析.xlsx</li>
<li class="multi-item" draggable="true" data-id="3">项目计划.pptx</li>
<li class="multi-item" draggable="true" data-id="4">会议纪要.pdf</li>
<li class="multi-item" draggable="true" data-id="5">预算表格.xlsx</li>
<li class="multi-item" draggable="true" data-id="6">合同模板.docx</li>
</ul>
<div id="selectionCount" class="selection-count">已选中 0 项</div>
<script>
const list = document.getElementById("multiList")
const countBadge = document.getElementById("selectionCount")
const items = Array.from(list.querySelectorAll(".multi-item"))
const state = {
selected: new Set(), // 已选中项的 ID 集合
lastIndex: null, // 上一次点击的索引(用于 Shift 范围选择)
draggingItems: [], // 正在拖拽的项目集合
ghostEl: null // 自定义拖拽图像容器
}
// 更新选中 UI
function updateSelectionUI() {
items.forEach(item => {
item.classList.toggle("selected", state.selected.has(item.dataset.id))
})
const count = state.selected.size
countBadge.textContent = `已选中 ${count} 项`
countBadge.classList.toggle("visible", count > 0)
}
// 点击选择逻辑
list.addEventListener("mousedown", (e) => {
const item = e.target.closest(".multi-item")
if (!item) return
const id = item.dataset.id
const index = items.indexOf(item)
const isMetaKey = e.ctrlKey || e.metaKey
const isShiftKey = e.shiftKey
if (isShiftKey && state.lastIndex !== null) {
// Shift + 点击:范围选择
const start = Math.min(state.lastIndex, index)
const end = Math.max(state.lastIndex, index)
for (let i = start; i <= end; i++) {
state.selected.add(items[i].dataset.id)
}
} else if (isMetaKey) {
// Ctrl/Cmd + 点击:切换选中
if (state.selected.has(id)) {
state.selected.delete(id)
} else {
state.selected.add(id)
}
} else {
// 普通单击:单选
state.selected.clear()
state.selected.add(id)
}
state.lastIndex = index
updateSelectionUI()
})
// 拖拽开始
list.addEventListener("dragstart", (e) => {
const item = e.target.closest(".multi-item")
if (!item) return
const id = item.dataset.id
// 如果点击的项未被选中,则只拖拽这一项
if (!state.selected.has(id)) {
state.selected.clear()
state.selected.add(id)
updateSelectionUI()
}
// 收集所有选中项
state.draggingItems = items.filter(i => state.selected.has(i.dataset.id))
// 设置拖拽数据(传递所有选中 ID)
e.dataTransfer.setData(
"application/json",
JSON.stringify({
type: "multi-select",
ids: [...state.selected]
})
)
e.dataTransfer.effectAllowed = "move"
// 创建自定义多选拖拽图像
state.ghostEl = document.createElement("div")
state.ghostEl.className = "ghost-container"
const displayItems = state.draggingItems.slice(0, 5) // 最多显示 5 个
displayItems.forEach((dragItem, idx) => {
const ghost = document.createElement("div")
ghost.className = "ghost-item"
ghost.textContent = dragItem.textContent.trim()
if (idx === 0) ghost.style.fontWeight = "bold"
state.ghostEl.appendChild(ghost)
})
if (state.draggingItems.length > 5) {
const more = document.createElement("div")
more.className = "ghost-item"
more.textContent = `... 还有 ${state.draggingItems.length - 5} 项`
more.style.fontStyle = "italic"
state.ghostEl.appendChild(more)
}
document.body.appendChild(state.ghostEl)
e.dataTransfer.setDragImage(state.ghostEl, 60, 20)
// 标记拖拽态
state.draggingItems.forEach(i => i.classList.add("dragging"))
})
// 拖拽结束清理
list.addEventListener("dragend", () => {
state.draggingItems.forEach(i => i.classList.remove("dragging"))
state.draggingItems = []
if (state.ghostEl) {
state.ghostEl.remove()
state.ghostEl = null
}
})
// 放置处理(以另一个列表为例)
list.addEventListener("drop", (e) => {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData("application/json"))
console.log(`放置了 ${data.ids.length} 个项目:`, data.ids)
// 这里可以执行实际的批量移动操作
} catch (err) {
console.error("解析拖拽数据失败", err)
}
})
list.addEventListener("dragover", (e) => e.preventDefault())
</script>12.2 跨窗口/跨 iframe 拖拽的限制与解决方案
跨窗口和跨 iframe 的拖拽是一个高级且常被忽视的场景,涉及同源策略、安全沙箱和数据隔离等多个层面。
限制分析
| 限制维度 | 同源 iframe | 跨源 iframe | 新窗口(window.open) | 外部应用 |
|---|---|---|---|---|
| DataTransfer 数据互通 | ✅ 支持 | ❌ 受限(仅 Files) | ✅ 支持 | ❌ 不支持 |
| dragover/drop 事件穿透 | ✅ 正常冒泡 | ⚠️ 受 sandbox 属性影响 | ❌ 独立事件循环 | ❌ 不适用 |
| setDragImage 跨域 | ✅ 支持 | ❌ 图像不显示 | ✅ 支持 | ❌ 不适用 |
| effectAllowed 传递 | ✅ 支持 | ⚠️ 部分浏览器丢失 | ✅ 支持 | ❌ 不适用 |
| 安全策略 | 同源策略 | CORS + CSP | 同源策略 | 操作系统级别 |
同源 iframe 拖拽方案
/**
* 同源 iframe 之间的拖拽通信
* 原理:DataTransfer 在同源 iframe 间可以正常传递字符串数据
*/
// 父页面:发起拖拽
const parentSource = document.getElementById("parent-draggable")
parentSource.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("application/json", JSON.stringify({
source: "parent-frame",
id: "item-123",
timestamp: Date.now(),
// 可以包含任何可序列化的数据
}))
e.dataTransfer.effectAllowed = "copy"
})
// 子 iframe 页面:接收拖拽
// (子 iframe 内部的代码)
const childDropzone = document.getElementById("child-dropzone")
childDropzone.addEventListener("dragover", (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = "copy"
})
childDropzone.addEventListener("drop", (e) => {
e.preventDefault()
const rawData = e.dataTransfer.getData("application/json")
if (rawData) {
const payload = JSON.parse(rawData)
console.log("收到来自父页面的数据:", payload)
// payload.source === "parent-frame" → 确认来源
}
})跨源 iframe 拖拽方案(postMessage 桥接)
由于跨源 iframe 的 DataTransfer 字符串数据无法直接读取,需要借助 postMessage 作为数据桥接通道:
/**
* 跨源 iframe 拖拽桥接方案
*
* 架构:
* 父页面 ──dragstart──→ [DataTransfer 仅携带标识] ──→ 子 iframe dragenter
* 父页面 ──postMessage──→ [完整数据] ───────────────→ 子 iframe message 事件
* 子 iframe ──postMessage──→ [确认接收] ─────────────→ 父页面
*/
// ========== 父页面代码 ==========
const crossOriginIframe = document.getElementById("cross-origin-iframe")
const pendingDragData = new Map() // 存储待传输的拖拽数据
// 1. 拖拽开始:写入唯一标识到 DataTransfer
document.addEventListener("dragstart", (e) => {
const item = e.target.closest("[draggable]")
if (!item) return
const transferId = `transfer-${Date.now()}-${Math.random().toString(36).slice(2)}`
const fullData = {
id: item.dataset.id,
type: item.dataset.type,
content: item.textContent.trim(),
metadata: { /* 复杂对象 */ }
}
// 存储完整数据到本地 Map
pendingDragData.set(transferId, fullData)
// DataTransfer 只写入 ID(跨域也可传递短字符串)
e.dataTransfer.setData("text/plain", transferId)
e.dataTransfer.effectAllowed = "copy"
// 通过 postMessage 发送完整数据到 iframe
try {
crossOriginIframe.contentWindow.postMessage(
{ type: "DRAG_DATA_PREPARE", transferId, data: fullData },
"*" // 生产环境应指定目标 origin
)
} catch (err) {
console.warn("postMessage 发送失败(iframe 可能未加载):", err)
}
})
// 2. 监听 iframe 的确认消息
window.addEventListener("message", (e) => {
if (e.data.type === "DROP_CONFIRMED") {
const { transferId } = e.data
console.log(`子 iframe 确认接收了数据: ${transferId}`)
pendingDragData.delete(transferId) // 清理已消费的数据
}
})
// 3. 定期清理过期数据(防止内存泄漏)
setInterval(() => {
const now = Date.now()
for (const [id, data] of pendingDragData) {
if (now - data.timestamp > 60000) { // 60 秒过期
pendingDragData.delete(id)
}
}
}, 30000)
// ========== 子 iframe 页面代码 ==========
const receivedDataCache = new Map()
// 1. 监听来自父页面的拖拽数据
window.addEventListener("message", (e) => {
// 生产环境:验证 e.origin
if (e.data.type === "DRAG_DATA_PREPARE") {
const { transferId, data } = e.data
receivedDataCache.set(transferId, {
...data,
receivedAt: Date.now()
})
}
})
// 2. 放置区域处理
const dropzone = document.getElementById("cross-origin-dropzone")
dropzone.addEventListener("dragover", (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = "copy"
})
dropzone.addEventListener("drop", (e) => {
e.preventDefault()
// 从 DataTransfer 读取 transferId
const transferId = e.dataTransfer.getData("text/plain")
if (!transferId) {
console.warn("未获取到传输标识")
return
}
// 从缓存中获取完整数据
const fullData = receivedDataCache.get(transferId)
if (!fullData) {
console.error("未找到对应的拖拽数据,可能 postMessage 未到达")
return
}
console.log("接收到完整的跨域拖拽数据:", fullData)
// 通知父页面数据已接收
try {
window.parent.postMessage(
{ type: "DROP_CONFIRMED", transferId },
"*"
)
} catch (err) {
// 忽略
}
receivedDataCache.delete(transferId)
})跨源 iframe 的安全注意事项
| 安全风险 | 防护措施 |
|---|---|
| 恶意 iframe 窃取数据 | 使用 sandbox="allow-same-origin" 限制权限;postMessage 指定 targetOrigin |
| 伪造拖拽数据 | 在消息接收端验证 event.origin 白名单 |
| 数据缓存溢出攻击 | 设置 TTL 自动清理缓存;限制缓存条目上限 |
| Clickjacking 结合拖拽 | 在敏感操作处要求二次确认 |
12.3 拖拽撤销/重做(Undo/Redo)集成
对于看板、列表排序等涉及数据变更的拖拽场景,集成 Undo/Redo 机制可以大幅提升用户体验,防止误操作的损失。
命令模式(Command Pattern)实现
/**
* 基于 Command Pattern 的拖拽 Undo/Redo 管理
*
* 设计思路:
* - 每次拖拽操作产生一个 Command 对象
* - Command 包含 execute() 和 undo() 方法
* - CommandStack 维护操作历史栈和重做栈
*/
// ========== 基础 Command 类 ==========
class DragCommand {
/**
* @param {Object} params
* @param {string} params.id - 唯一标识
* @param {string} params.description - 操作描述(用于 UI 显示)
* @param {Function} params.execute - 执行操作
* @param {Function} params.undo - 撤销操作
*/
constructor({ id, description, execute, undo }) {
this.id = id
this.description = description
this.timestamp = Date.now()
this._execute = execute
this._undo = undo
}
execute() { return this._execute() }
undo() { return this._undo() }
}
// ========== Command Stack(操作历史管理)==========
class DragCommandStack {
constructor(maxHistory = 50) {
this.undoStack = []
this.redoStack = []
this.maxHistory = maxHistory
this.listeners = new Set()
}
// 执行一个新命令
execute(command) {
const result = command.execute()
this.undoStack.push(command)
this.redoStack = [] // 新操作清空重做栈
// 限制历史栈大小
if (this.undoStack.length > this.maxHistory) {
this.undoStack.shift()
}
this.notify("executed", command)
return result
}
// 撤销
undo() {
const command = this.undoStack.pop()
if (!command) return null
command.undo()
this.redoStack.push(command)
this.notify("undone", command)
return command
}
// 重做
redo() {
const command = this.redoStack.pop()
if (!command) return null
command.execute()
this.undoStack.push(command)
this.notify("redone", command)
return command
}
// 状态查询
canUndo() { return this.undoStack.length > 0 }
canRedo() { return this.redoStack.length > 0 }
getUndoDescription() { return this.undoStack[this.undoStack.length - 1]?.description }
getRedoDescription() { return this.redoStack[this.redoStack.length - 1]?.description }
// 事件订阅
onChange(callback) {
this.listeners.add(callback)
return () => this.listeners.delete(callback)
}
notify(action, command) {
this.listeners.forEach(cb => cb({
action,
command,
canUndo: this.canUndo(),
canRedo: this.canRedo(),
undoDesc: this.getUndoDescription(),
redoDesc: this.getRedoDescription()
}))
}
// 清空历史
clear() {
this.undoStack = []
this.redoStack = []
this.notify("cleared", null)
}
}
// ========== 实际集成示例 ==========
// 初始化命令栈
const commandStack = new DragCommandStack(30)
// 列表排序的拖拽集成
const sortableList = document.getElementById("undo-sortable")
let listState = ["A", "B", "C", "D", "E"] // 数据模型
function renderList() {
sortableList.innerHTML = ""
listState.forEach((item, index) => {
const li = document.createElement("li")
li.draggable = true
li.dataset.index = index
li.textContent = `项目 ${item}`
li.style.cssText = "padding: 8px; background: #f5f5f5; margin: 2px 0;"
sortableList.appendChild(li)
})
}
renderList()
let dragSourceIndex = null
sortableList.addEventListener("dragstart", (e) => {
const item = e.target.closest("li[draggable]")
if (item) dragSourceIndex = parseInt(item.dataset.index)
})
sortableList.addEventListener("dragover", (e) => {
e.preventDefault()
})
sortableList.addEventListener("drop", (e) => {
e.preventDefault()
const targetItem = e.target.closest("li[draggable]")
if (!targetItem || dragSourceIndex === null) return
const targetIndex = parseInt(targetItem.dataset.index)
if (dragSourceIndex === targetIndex) return
// 创建撤销命令
const command = new DragCommand({
id: `sort-${Date.now()}`,
description: `移动「${listState[dragSourceIndex]}」从位置 ${dragSourceIndex + 1} 到 ${targetIndex + 1}`,
execute: () => {
const [moved] = listState.splice(dragSourceIndex, 1)
// 如果是从前往后拖,targetIndex 需要减 1(因为已经删除了一个元素)
const insertAt = dragSourceIndex < targetIndex ? targetIndex - 1 : targetIndex
listState.splice(insertAt, 0, moved)
renderList()
return listState.slice()
},
undo: () => {
// 反向操作:恢复原始顺序
// 这里简化处理:重新加载数据快照
// 实际项目中应在 execute 时保存快照
renderList()
return listState.slice()
}
})
commandStack.execute(command)
dragSourceIndex = null
})
// ========== 键盘快捷键绑定 ==========
document.addEventListener("keydown", (e) => {
// Ctrl+Z / Cmd+Z:撤销
if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
e.preventDefault()
commandStack.undo()
}
// Ctrl+Shift+Z / Cmd+Shift+Z / Ctrl+Y:重做
if ((e.ctrlKey || e.metaKey) && (e.key === "y" || (e.key === "z" && e.shiftKey))) {
e.preventDefault()
commandStack.redo()
}
})
// ========== UI 状态栏(可选)==========
commandStack.onChange((state) => {
console.log(
`[拖拽历史] 操作: ${state.action}`,
`| 可撤销: ${state.canUndo ? state.undoDesc : '无'}`,
`| 可重做: ${state.canRedo ? state.redoDesc : '无'}`
)
// 可在此更新工具栏按钮的 disabled 状态
const undoBtn = document.getElementById("undo-btn")
const redoBtn = document.getElementById("redo-btn")
if (undoBtn) undoBtn.disabled = !state.canUndo
if (redoBtn) redoBtn.disabled = !state.canRedo
})Undo/Redo 最佳实践
| 实践 | 说明 |
|---|---|
| 保存完整快照 | 在 execute 中深拷贝当前状态,确保 undo 能准确还原 |
| 合并连续同类操作 | 如连续多次微调位置,可合并为一个命令(如 Photoshop 的历史记录) |
| 设置合理的历史上限 | 建议 30–50 条,过多会占用大量内存 |
| 服务端协同 | 协作编辑场景需考虑操作转换(OT)或 CRDT 算法 |
| 持久化关键操作 | 重要拖拽操作(如删除)即使刷新页面也应可撤销,需存入 localStorage 或服务端 |
12.4 键盘辅助拖拽(方向键微调位置)
对于精细排版、图表节点调整等场景,纯鼠标拖拽难以达到像素级精准。键盘辅助拖拽允许用户在选中元素后,通过方向键进行微调。
交互设计规范
| 按键 | 功能 | 默认步长 | 按住 Shift | 按住 Ctrl/Cmd |
|---|---|---|---|---|
↑ / ↓ / ← / → | 向对应方向微调位置 | 1 px | 10 px(粗调) | 0.1 px(精调) |
Enter | 确认当前位置 | — | — | — |
Escape | 取消并还原到初始位置 | — | — | — |
Tab | 切换到下一个可拖拽元素 | — | — | — |
完整实现示例
<style>
.grid-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 20px;
min-height: 400px;
background: #f0f0f0;
position: relative;
}
.adjustable-card {
background: white;
border: 2px solid #ddd;
border-radius: 8px;
padding: 16px;
text-align: center;
cursor: grab;
position: relative;
transition: box-shadow 0.15s;
z-index: 1;
}
.adjustable-card:focus {
outline: none;
border-color: #2196f3;
box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.3);
z-index: 10;
}
.adjustable-card.adjusting {
border-color: #ff9800;
box-shadow: 0 4px 16px rgba(255, 152, 0, 0.3);
}
.position-badge {
position: absolute;
top: -8px;
right: -8px;
background: #ff9800;
color: white;
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
display: none;
font-family: monospace;
}
.adjustable-card.adjusting .position-badge { display: block; }
.hint-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #333;
color: white;
padding: 10px 20px;
font-size: 13px;
display: none;
z-index: 1000;
}
.hint-bar.visible { display: flex; gap: 20px; align-items: center; }
.hint-bar kbd {
background: #555;
padding: 2px 6px;
border-radius: 3px;
font-family: inherit;
font-size: 12px;
}
</style>
<div class="grid-container" id="gridContainer">
<!-- 卡片由 JS 动态生成 -->
</div>
<div class="hint-bar" id="hintBar">
<span><kbd>↑↓←→</kbd> 微调位置</span>
<span><kbd>Shift</kbd> + 方向键 = 10px</span>
<span><kbd>Ctrl</kbd> + 方向键 = 0.1px</span>
<span><kbd>Enter</kbd> 确认 · <kbd>Esc</kbd> 取消</span>
</div>
<script>
const container = document.getElementById("gridContainer")
const hintBar = document.getElementById("hintBar")
// 卡片数据
const cards = [
{ id: "card-1", title: "标题卡片", color: "#e3f2fd" },
{ id: "card-2", title: "图片组件", color: "#fff3e0" },
{ id: "card-3", title: "数据表格", color: "#e8f5e9" },
{ id: "card-4", title: "统计图表", color: "#fce4ec" },
{ id: "card-5", title: "导航菜单", color: "#f3e5f5" },
{ id: "card-6", title: "表单区域", color: "#e0f7fa" },
]
// 渲染卡片
cards.forEach(card => {
const el = document.createElement("div")
el.className = "adjustable-card"
el.tabIndex = 0
el.dataset.id = card.id
el.style.background = card.color
el.innerHTML = `
${card.title}
<div class="position-badge">0, 0</div>
`
container.appendChild(el)
})
const cardElements = Array.from(container.querySelectorAll(".adjustable-card"))
// 键盘辅助拖拽状态
const adjustState = {
activeCard: null,
initialTransform: { x: 0, y: 0 }, // 进入调整模式时的偏移量
currentOffset: { x: 0, y: 0 }, // 当前额外偏移量
baseRect: null // 初始位置的 bounding rect
}
// 获取步长
function getStepSize(e) {
if (e.ctrlKey || e.metaKey) return 0.1 // 精调
if (e.shiftKey) return 10 // 粗调
return 1 // 默认
}
// 更新位置显示
function updatePositionDisplay(card) {
const badge = card.querySelector(".position-badge")
if (badge) {
badge.textContent = `${adjustState.currentOffset.x.toFixed(1)}, ${adjustState.currentOffset.y.toFixed(1)}`
}
}
// 应用变换
function applyTransform(card) {
const totalX = adjustState.initialTransform.x + adjustState.currentOffset.x
const totalY = adjustState.initialTransform.y + adjustState.currentOffset.y
card.style.transform = `translate(${totalX}px, ${totalY}px)`
updatePositionDisplay(card)
}
// 进入调整模式
function enterAdjustMode(card) {
if (adjustState.activeCard === card) return
// 如果已有其他卡片在调整模式,先退出
if (adjustState.activeCard) {
exitAdjustMode(false) // 不确认
}
adjustState.activeCard = card
adjustState.baseRect = card.getBoundingClientRect()
// 解析已有的 transform
const style = window.getComputedStyle(card)
const matrix = new DOMMatrix(style.transform)
adjustState.initialTransform = { x: matrix.m41, y: matrix.m42 }
adjustState.currentOffset = { x: 0, y: 0 }
card.classList.add("adjusting")
hintBar.classList.add("visible")
applyTransform(card)
announceToScreenReader(`已进入位置调整模式,使用方向键微调「${card.textContent.trim()}」的位置`)
}
// 退出调整模式
function exitAdjustMode(confirm = true) {
const card = adjustState.activeCard
if (!card) return
if (!confirm) {
// 取消:还原到初始位置
adjustState.currentOffset = { x: 0, y: 0 }
applyTransform(card)
announceToScreenReader(`已取消调整,「${card.textContent.trim()}」已还原`)
} else {
announceToScreenReader(
`已确认位置,最终偏移: (${adjustState.currentOffset.x.toFixed(1)}, ${adjustState.currentOffset.y.toFixed(1)})`
)
}
card.classList.remove("adjusting")
adjustState.activeCard = null
hintBar.classList.remove("visible")
}
// 全局键盘事件监听
document.addEventListener("keydown", (e) => {
const card = adjustState.activeCard
if (!card) return
const step = getStepSize(e)
switch (e.key) {
case "ArrowUp":
e.preventDefault()
adjustState.currentOffset.y -= step
applyTransform(card)
break
case "ArrowDown":
e.preventDefault()
adjustState.currentOffset.y += step
applyTransform(card)
break
case "ArrowLeft":
e.preventDefault()
adjustState.currentOffset.x -= step
applyTransform(card)
break
case "ArrowRight":
e.preventDefault()
adjustState.currentOffset.x += step
applyTransform(card)
break
case "Enter":
e.preventDefault()
exitAdjustMode(true)
break
case "Escape":
e.preventDefault()
exitAdjustMode(false)
break
}
})
// Tab 键切换卡片时退出当前调整模式
container.addEventListener("keydown", (e) => {
if (e.key === "Tab" && adjustState.activeCard) {
// Tab 会自然切换焦点,这里只需在 blur 时处理
}
})
// Focus 进入调整模式
cardElements.forEach(card => {
card.addEventListener("focus", () => enterAdjustMode(card))
card.addEventListener("blur", (e) => {
// 只有当焦点完全离开卡片时才退出
if (adjustState.activeCard === card && !card.contains(e.relatedTarget)) {
// 延迟退出,给 Enter/Esc 键留出处理时间
setTimeout(() => {
if (document.activeElement !== card) {
exitAdjustMode(true) // 失焦时自动确认
}
}, 100)
}
})
})
// 屏幕阅读器通知
function announceToScreenReader(message) {
let announcer = document.getElementById("sr-announcer")
if (!announcer) {
announcer = document.createElement("div")
announcer.id = "sr-announcer"
announcer.setAttribute("role", "status")
announcer.setAttribute("aria-live", "polite")
announcer.setAttribute("aria-atomic", "true")
announcer.style.cssText =
"position:fixed;left:-9999px;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);"
document.body.appendChild(announcer)
}
announcer.textContent = message
}
</script>13. 调试技巧
13.1 拖放事件日志
// 开发环境启用拖放调试
function enableDragDebug() {
const events = ["dragstart", "drag", "dragend", "dragenter", "dragover", "dragleave", "drop"]
events.forEach(eventType => {
document.addEventListener(eventType, (event) => {
console.log(`[${eventType}]`, {
target: event.target,
relatedTarget: event.relatedTarget,
dataTransfer: {
types: [...(event.dataTransfer?.types || [])],
dropEffect: event.dataTransfer?.dropEffect,
effectAllowed: event.dataTransfer?.effectAllowed,
files: event.dataTransfer?.files.length
},
coords: { x: event.clientX, y: event.clientY }
})
}, true) // 捕获阶段
})
}
// 生产环境移除
if (process.env.NODE_ENV === "development") {
enableDragDebug()
}13.2 可视化拖放区域
// 高亮所有拖放目标
function visualizeDropzones() {
document.querySelectorAll("[data-dropzone]").forEach(el => {
el.style.outline = "2px dashed red"
el.style.outlineOffset = "2px"
})
document.querySelectorAll("[draggable]").forEach(el => {
el.style.outline = "2px solid blue"
el.style.outlineOffset = "2px"
})
}13.3 模拟拖放事件
// 用于自动化测试
function simulateDragDrop(source, target) {
const dataTransfer = new DataTransfer()
// 模拟 dragstart
const dragStartEvent = new DragEvent("dragstart", {
bubbles: true,
cancelable: true,
dataTransfer
})
source.dispatchEvent(dragStartEvent)
// 模拟 dragover
const dragOverEvent = new DragEvent("dragover", {
bubbles: true,
cancelable: true,
dataTransfer
})
target.dispatchEvent(dragOverEvent)
// 模拟 drop
const dropEvent = new DragEvent("drop", {
bubbles: true,
cancelable: true,
dataTransfer
})
target.dispatchEvent(dropEvent)
// 模拟 dragend
const dragEndEvent = new DragEvent("dragend", {
bubbles: true,
cancelable: true,
dataTransfer
})
source.dispatchEvent(dragEndEvent)
}13.4 常见调试检查项
| 问题 | 检查方法 |
| --- | --- | --- |
| drop 事件不触发 | 检查 dragover 是否调用了 preventDefault() |
| 数据丢失 | 确认 setData 和 getData 使用相同的 MIME 类型 |
| 拖拽不启动 | 检查 draggable 属性是否为 "true"(字符串) |
| 光标不变化 | 检查 effectAllowed 和 dropEffect 是否匹配 |
| 子元素干扰 | 使用 event.target.closest() 获取正确目标 |
| 内存泄漏 | 确保移除事件监听器和清理对象 URL |
14. 常见错误与修正
| 常见问题 | 错误/模糊点 | 正确做法 |
|---|---|---|
| 放置不生效 | 未在 dragover 中 preventDefault() | 在目标区域 dragover/drop 都调用 preventDefault() |
| 拖放数据为空 | setData/getData 类型不一致 | 统一 MIME 类型,如 application/json |
| 排序抖动 | 在 dragover 执行高成本计算 | 节流计算并最小化 DOM 变更 |
| 页面直接打开文件 | 未拦截页面级 drop 默认行为 | 对 document 统一拦截 dragover/drop |
| 移动端无法使用 | 误以为原生 DnD 在手机端一致可用 | 使用触摸/指针事件或第三方库 |
| dragleave 闪烁 | 子元素触发父元素 dragleave | 使用计数器或 relatedTarget 过滤 |
| 拖拽图像不显示 | 图像元素不在 DOM 中或被隐藏 | 确保图像元素可见且已渲染 |
| effectAllowed 无效 | 在错误的事件中设置 | 在 dragstart 事件中设置 |
15. 安全与性能建议
15.1 安全
- 不信任拖放内容:对
text/html数据先做净化(XSS 防护)。 - 文件拖放上传必须在服务端二次校验:类型、大小与内容签名。
- 跨区域拖放数据建立白名单策略:避免越权操作。
- 上传接口启用鉴权、限流、防重放机制。
- 敏感操作需二次确认:如拖拽删除。
15.2 性能
- 避免在
drag/dragover中执行复杂逻辑或频繁重绘。 - 列表排序优先更新内存状态,再批量提交视图变更。
- 文件预览优先使用对象 URL,上传完毕后及时清理缓存和监听器。
- 使用 CSS
will-change提示浏览器优化拖拽动画。
16. 浏览器兼容性与移动端策略
| 能力 | 桌面浏览器 | 移动浏览器 | 建议 |
|---|---|---|---|
| HTML5 原生拖放(元素) | 支持较好 | 支持有限且差异大 | 复杂场景优先用指针事件方案 |
文件拖放(DataTransfer.files) | 支持较好 | 部分支持 | 提供 <input type="file"> 回退入口 |
setDragImage | 支持 | 支持不稳定 | 作为增强能力,不作为关键依赖 |
| 列表排序交互 | 可用 | 原生体验一般 | 移动端建议使用专用排序库 |
DataTransferItemList | 现代浏览器支持良好 | 支持较好 | 优先使用,提供 files 回退 |
说明:发布前需基于目标设备与浏览器版本进行实测回归。
17. 主流拖拽库对比
在实际项目开发中,根据场景复杂度和团队技术栈选择合适的拖拽库至关重要。以下是目前主流拖拽库的系统对比:
17.1 库选型速查表
| 库名 | GitHub Stars | 包大小(gzip) | 核心定位 | 桌面 DnD | 移动端 | 虚拟滚动 | TypeScript | 许可证 |
|---|---|---|---|---|---|---|---|---|
| SortableJS | ~32k | ~9KB | 列表排序 | ✅ | ✅ Touch | ✅ 插件 | ✅ | MIT |
| @dnd-kit/core | ~12k | ~8KB | 现代化 DnD | ✅ Pointer | ✅ Pointer | ✅ 内建 | ✅ | BSD-3 |
| draggable (Shopify) | ~22k | ~15KB | 全面拖拽框架 | ✅ | ✅ Pointer | ✅ 插件 | ✅ | MIT |
| react-beautiful-dnd | ~30k | ~18KB | React 列表排序 | ✅ | ⚠️ 有限 | ❌ | ✅ | Apache-2.0 |
| vue-draggable-plus | ~4k | ~12KB | Vue3 拖拽 | ✅ SortableJS | ✅ | ✅ | ✅ | MIT |
| interact.js | ~12k | ~25KB | 手势/拖拽/缩放 | ✅ | ✅ | ❌ | ✅ | MIT |
| html5dnd.js | ~0.5k | ~3KB | 原生 Polyfill | ✅ | ❌ | ❌ | ❌ | MIT |
17.2 详细对比分析
SortableJS —— 列表排序的首选
优势
- 成熟稳定,生产环境经过大量验证
- 丰富的插件生态(MultiDrag、Swap、Plugin 支持)
- 支持嵌套排序、多列网格排序
- 与 Vue/React/Angular/Svelte 均有官方封装
劣势
- 基于 HTML5 DnD API,移动端依赖 Touch 事件 polyfill
- 自定义拖拽手柄配置相对繁琐
- 不适用于自由画布拖拽场景
适用场景:看板(Kanban)、任务列表排序、文件队列重排
import Sortable from "sortablejs"
new Sortable(document.getElementById("myList"), {
animation: 150,
handle: ".drag-handle",
group: "shared",
multiDrag: true, // 多选拖拽插件
selectedClass: "selected",
onEnd: function(evt) {
console.log("从", evt.oldIndex, "移动到", evt.newIndex)
}
})@dnd-kit/core —— 现代化方案的新星
优势
- 基于 Pointer Events,天然支持桌面 + 移动端
- 内建无障碍支持(ARIA 属性 + 键盘操作)
- 模块化设计,按需引入
- React/Vue 封装完善(
@dnd-kit/modular)
劣势
- 社区生态较新,第三方教程较少
- 部分高级功能(如碰撞检测)需自行实现
- 版本迭代较快,API 偶有 breaking change
适用场景:需要同时支持桌面和移动端的现代 Web 应用、注重可访问性的产品
import { DndContext, useSensor, useSensors, PointerSensor } from "@dnd-kit/core"
function App() {
const sensors = useSensors(useSensor(PointerSensor))
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
{/* 可拖拽内容 */}
</DndContext>
)
}Shopify Draggable —— 企业级全面方案
优势
- Shopify 出品,企业级质量保证
- 支持Sortable、Swappable、Droppable、Collidable 四种模式
- 内建碰撞检测、吸附对齐、网格约束
- 完善的插件系统和扩展能力
劣势
- 包体积较大(完整版 ~15KB gzipped)
- 学习曲线较陡峭
- 社区活跃度一般
适用场景:复杂的仪表盘编辑器、低代码平台的组件拖放、需要多种拖拽模式的综合应用
react-beautiful-dnd (Atlassian) —— React 生态经典
优势
- Atlassian(Jira/Trello 团队)出品,经过海量用户验证
- API 设计优雅,符合 React 思维模式
- 动画效果精美,开箱即用
劣势
- 已停止维护(Archived 状态),不再接受 PR
- 不支持虚拟滚动(长列表性能瓶颈)
- 移动端支持有限
- 仅支持 React
适用场景:现有项目维护(不建议新项目采用);对于新 React 项目推荐迁移至 @dnd-kit
html5dnd.js —— 轻量级 Polyfill
优势
- 极小的包体积(~3KB gzipped)
- 零依赖,专注于补齐浏览器差异
- 适用于只需要基础拖放能力的场景
劣势
- 功能单一,无排序/碰撞等高级能力
- 维护不活跃
- 不提供移动端支持
适用场景:老旧浏览器兼容、轻量级拖放增强
17.3 选型决策流程
| 项目特征 | 推荐方案 | 理由 |
|---|---|---|
| 纯列表/看板排序 | SortableJS | 成熟、插件丰富、社区庞大 |
| 现代 SPA(React/Vue)+ 移动端 | @dnd-kit | Pointer Events 统一双端、内置 a11y |
| 复杂拖拽(画布/碰撞/吸附) | Shopify Draggable | 功能最全面 |
| 现有 r-b-d 项目维护 | 继续使用 r-b-d | 迁移成本高,暂无必要替换 |
| 轻量需求 / Polyfill | html5dnd.js | 最小化依赖 |
| Vue 3 项目 | vue-draggable-plus | SortableJS 的 Vue 3 封装 |
| 自研需求 | 参考 interact.js | 底层手势抽象,灵活度高 |
17.4 自研 vs 引入库的权衡
| 因素 | 自研 | 引入库 |
|---|---|---|
| 开发速度 | 慢(需处理各种边界情况) | 快(API 即插即用) |
| 包体积 | 可精确控制 | 取决于库的大小 |
| 定制灵活性 | 完全可控 | 受限于库的 API 设计 |
| 长期维护成本 | 高(Bug 修复/兼容性跟进) | 低(社区共同维护) |
| 团队学习成本 | 低(自己写的最懂) | 中(需阅读文档和源码) |
| 无障碍支持 | 需自行实现 | 主流库通常已内置 |
一般建议:除非有非常特殊的定制需求(如特定领域的专业工具),否则优先选用成熟的开源库。自研拖拽系统的隐性成本往往被低估——尤其是移动端兼容、无障碍支持和边界 Bug 修复方面。
18. FAQ
Q1:为什么 drop 事件不触发?
最常见原因是没有在放置区域 dragover 中调用 preventDefault()。其他可能原因:
- 拖拽源设置了
draggable="false" - 在
dragstart中调用了preventDefault() - 事件被父元素阻止
Q2:为什么 event.dataTransfer.files 是空的?
如果拖的是页面元素而不是文件,files 会为空。应根据 types 区分数据来源:
if (event.dataTransfer.types.includes("Files")) {
// 处理文件
} else {
// 处理文本数据
}Q3:能否只用拖放,不提供文件选择按钮?
不建议。应保留 <input type="file"> 作为键盘与移动端回退方案,满足可访问性要求。
Q4:如何限制只能"移动"不能"复制"?
在 dragstart 设 effectAllowed="move",并在目标 dragover 设 dropEffect="move"。注意这仅是视觉提示,实际行为需自行实现。
Q5:为什么 dragleave 会频繁触发导致闪烁?
鼠标在目标子节点间移动也可能触发。可通过计数器或 relatedTarget 过滤:
dropzone.addEventListener("dragleave", (event) => {
if (!dropzone.contains(event.relatedTarget)) {
dropzone.classList.remove("drag-over")
}
})Q6:拖放上传是否可以替代服务端校验?
不可以。前端校验只提升体验,安全校验必须在服务端执行。前端校验可被绕过。
Q7:如何实现拖放复制而非移动?
// 在 drop 中克隆元素而非移动
dropzone.addEventListener("drop", (event) => {
event.preventDefault()
const clone = dragging.cloneNode(true)
dropzone.appendChild(clone)
// 不移除原元素
})Q8:如何检测拖放是否来自外部(如桌面文件)?
element.addEventListener("dragenter", (event) => {
// 外部拖入时,dataTransfer.types 通常只有 "Files"
const isExternal = event.dataTransfer.types.length === 1 &&
event.dataTransfer.types[0] === "Files"
console.log("来自外部:", isExternal)
})Q9:为什么 iOS Safari 上拖拽几乎不可用?
这是 iOS Safari 的已知限制。iOS Safari 对 HTML5 原生 DnD API 的支持极其有限:
draggable属性:在 iOS Safari 上基本无效,元素无法通过原生 DnD 拖动dragstart/dragover/drop事件:不会正常触发或行为异常- 文件拖放:部分版本支持从照片 app 拖入图片,但不稳定
解决方案:
- 首选方案:使用 Pointer Events API 实现统一的桌面/移动端拖拽(参见第 8.3 节)
- 备选方案:引入
@dnd-kit/core或Shopify Draggable等基于 Pointer Events 的库 - 最低限度:为 iOS 用户提供完全替代的交互方式(如长按弹出操作菜单、滑动操作等)
// iOS Safari 检测与降级
const isIOSSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) &&
/iPad|iPhone|iPod/.test(navigator.userAgent)
if (isIOSSafari) {
// 启用 Pointer Events 方案替代原生 DnD
initPointerBasedDragDrop()
} else {
// 使用原生 HTML5 DnD
initNativeDragDrop()
}Q10:如何解决长按拖拽与文字选中的冲突?
在可拖拽元素上进行长按操作时,浏览器默认会触发文字选中行为,导致拖拽体验不佳。
解决方案组合:
/* 方案 1:CSS 禁止文字选中 */
.draggable-item {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none; /* iOS:禁止长按菜单 */
}// 方案 2:动态控制 user-select(仅在拖拽时禁用)
const draggables = document.querySelectorAll(".draggable-item")
draggables.forEach(item => {
// mousedown/touchstart 时禁用选中
item.addEventListener("pointerdown", () => {
item.style.userSelect = "none"
item.style.webkitUserSelect = "none"
})
// mouseup/touchend 时恢复选中
document.addEventListener("pointerup", () => {
draggables.forEach(el => {
el.style.userSelect = ""
el.style.webkitUserSelect = ""
})
}, { once: false })
})
// 方案 3:区分拖拽和选中的阈值(延迟判断)
let pressTimer = null
const DRAG_THRESHOLD = 150 // 150ms 后才认为是拖拽
item.addEventListener("pointerdown", () => {
pressTimer = setTimeout(() => {
// 超过阈值 → 进入拖拽模式
item.style.userSelect = "none"
startDrag(item)
}, DRAG_THRESHOLD)
})
item.addEventListener("pointerup", () => {
if (pressTimer) {
clearTimeout(pressTimer)
pressTimer = null
// 短按 → 不触发拖拽,允许正常选中行为
}
})
item.addEventListener("pointermove", () => {
// 如果手指/鼠标移动了,提前判定为拖拽意图
if (pressTimer) {
clearTimeout(pressTimer)
pressTimer = null
item.style.userSelect = "none"
startDrag(item)
}
})推荐策略:对于明确的拖拽区域(如拖拽手柄),直接使用 CSS user-select: none;对于内容区域需要在拖拽和选中之间平衡时,使用方案 3 的阈值判定方式。
Q11:为什么自定义拖拽光标(setDragImage)在某些情况下不显示?
setDragImage 设置的自定义拖拽图像不显示是常见的兼容性问题,主要原因如下:
| 原因 | 现象 | 解决方案 |
|---|---|---|
| 图像元素不在 DOM 中 | 显示默认拖拽图像 | 确保 setDragImage 的参数元素已挂载到 DOM |
图像元素被 display:none 隐藏 | 显示默认拖拽图像 | 使用 position: absolute; left: -9999px 替代 display:none |
| 图像尚未渲染完成 | 显示空白或默认图像 | 在 dragstart 前确保图像已加载(img.complete === true) |
| 跨域 iframe 中的元素 | 图像不显示 | 跨域 iframe 中的元素无法作为拖拽图像 |
| Firefox 的特殊限制 | 有时不显示外部图像 | Firefox 要求图像与页面同源 |
| 移动端浏览器 | 大多数不支持 | setDragImage 在移动端支持不稳定,应作为增强而非依赖 |
可靠的 setDragImage 使用模式:
// 推荐做法:预创建隐藏的拖拽图像元素
const dragImageTemplate = document.createElement("div")
dragImageTemplate.className = "drag-image-template"
dragImageTemplate.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
width: auto;
height: auto;
pointer-events: none;
z-index: -1;
`
dragImageTemplate.innerHTML = `
<div style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 10px 20px;
border-radius: 8px;
font-weight: bold;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
white-space: nowrap;
">正在拖拽...</div>
`
document.body.appendChild(dragImageTemplate)
// 在 dragstart 中使用
sourceElement.addEventListener("dragstart", (e) => {
// 克隆模板以确保每次拖拽都是独立的元素
const image = dragImageTemplate.cloneNode(true)
image.style.top = "-9999px"
document.body.appendChild(image)
e.dataTransfer.setDragImage(image, 50, 20)
// 清理临时克隆(setDragImage 已捕获快照)
requestAnimationFrame(() => image.remove())
})19. 最佳实践检查清单
- 已覆盖拖拽源、放置目标、状态管理与错误处理
- 已实现文件拖放与点击选择双入口
- 已实现大小/类型/数量校验与失败提示
- 已提供排序结果持久化与回滚机制
- 已完成无障碍与移动端回退方案
- 已优化高频事件性能(节流/RAF)
- 已处理嵌套元素导致的事件冒泡问题
- 已测试跨浏览器兼容性
- 已实现服务端安全校验
- 已提供调试工具与错误日志
- 已评估并选择合适的拖拽库(非自研时)
- 已建立性能基线监控(大列表场景)
- 已实现 Undo/Redo 机制(数据变更类拖拽)
- 已处理 iOS Safari 等特殊浏览器的降级方案
- 已解决长按拖拽与文字选中的冲突
补充示例
<h4>001-basic-drag-drop.html</h4><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【1】基础数据拖放</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 800px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
.drag-area {
display: flex;
gap: 40px;
align-items: flex-start;
flex-wrap: wrap;
justify-content: center;
padding: 30px 20px;
}
.drag-source {
width: 180px;
height: 120px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
font-size: 16px;
font-weight: bold;
user-select: none;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.drag-source:hover { transform: translateY(-4px); box-shadow: 0 8px 25px rgba(102, 126, 234, 0.5); }
.drag-source:active { cursor: grabbing; transform: scale(0.95); }
.arrow {
font-size: 32px;
color: #ccc;
padding-top: 40px;
}
.drop-target {
width: 240px;
min-height: 160px;
border: 3px dashed #ccc;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
transition: all 0.3s;
position: relative;
padding: 20px;
}
.drop-target.drag-over {
border-color: #28a745;
background: #e8f5e9;
transform: scale(1.02);
}
.drop-placeholder {
color: #999;
font-size: 14px;
}
.drop-content {
width: 100%;
}
.dropped-card {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
padding: 16px;
border-radius: 8px;
text-align: center;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.reset-btn {
margin-top: 20px;
padding: 10px 24px;
background: #6c757d;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
display: block;
margin-left: auto;
margin-right: auto;
}
.reset-btn:hover { background: #5a6268; }
.log-panel {
margin-top: 20px;
padding: 16px;
background: #1e1e1e;
color: #d4d4d4;
border-radius: 6px;
font-family: monospace;
font-size: 13px;
max-height: 200px;
overflow-y: auto;
}
.log-entry { margin-bottom: 4px; }
.log-entry.info { color: #569cd6; }
.log-entry.success { color: #4ec9b0; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:基础数据拖放 - DataTransfer 数据传递</div>
<div class="drag-area">
<div id="dragCard" draggable="true" class="drag-source">
📦 拖拽卡片 A<br><small style="font-weight:normal;font-size:12px;">携带 JSON 数据</small>
</div>
<div class="arrow">→</div>
<div id="dropZone" class="drop-target">
<span class="drop-placeholder">🎯 放置区域<br>将卡片拖到这里</span>
</div>
</div>
<button class="reset-btn" onclick="resetDemo()">重置演示</button>
<div class="log-panel" id="logPanel">
<div class="log-entry info">[系统] 等待拖放操作...</div>
</div>
</div>
<script>
const dragCard = document.getElementById("dragCard")
const dropZone = document.getElementById("dropZone")
const logPanel = document.getElementById("logPanel")
// 拖拽开始
dragCard.addEventListener("dragstart", (event) => {
const data = {
id: "card-a",
type: "card",
title: "拖拽卡片 A",
timestamp: Date.now()
}
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.setData("application/json", JSON.stringify(data))
log(`[dragstart] 开始拖拽,数据: ${JSON.stringify(data)}`, "info")
})
// 拖拽结束
dragCard.addEventListener("dragend", () => {
log("[dragend] 拖拽结束", "info")
})
// 允许放置
dropZone.addEventListener("dragover", (event) => {
event.preventDefault()
event.dataTransfer.dropEffect = "move"
dropZone.classList.add("drag-over")
})
// 离开放置区
dropZone.addEventListener("dragleave", (event) => {
if (!dropZone.contains(event.relatedTarget)) {
dropZone.classList.remove("drag-over")
}
})
// 放置处理
dropZone.addEventListener("drop", (event) => {
event.preventDefault()
dropZone.classList.remove("drag-over")
try {
const raw = event.dataTransfer.getData("application/json")
if (!raw) {
log("[drop] 未获取到数据", "error")
return
}
const payload = JSON.parse(raw)
dropZone.innerHTML = `
<div class="drop-content">
<div class="dropped-card">
<strong style="font-size:18px;">✓ 放置成功</strong><br><br>
<strong>ID:</strong> ${payload.id}<br>
<strong>类型:</strong> ${payload.type}<br>
<strong>标题:</strong> ${payload.title}<br>
<strong>时间戳:</strong> ${new Date(payload.timestamp).toLocaleTimeString()}
</div>
</div>
`
log(`[drop] 成功接收数据: ID=${payload.id}, Type=${payload.type}`, "success")
} catch (error) {
log(`[drop] 解析数据失败: ${error.message}`, "error")
}
})
function resetDemo() {
dropZone.innerHTML = '<span class="drop-placeholder">🎯 放置区域<br>将卡片拖到这里</span>'
dropZone.classList.remove("drag-over")
log("[系统] 已重置", "info")
}
function log(message, type = "info") {
const entry = document.createElement("div")
entry.className = `log-entry ${type}`
entry.textContent = message
logPanel.appendChild(entry)
logPanel.scrollTop = logPanel.scrollHeight
}
</script>
</body>
</html>```
<h4>002-sortable-list.html</h4>
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【2】可排序列表</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 600px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
.sortable-list {
list-style: none;
padding: 0;
}
.sortable-item {
padding: 14px 18px;
margin: 8px 0;
background: white;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: grab;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.2s;
user-select: none;
}
.sortable-item:hover {
border-color: #007bff;
box-shadow: 0 2px 8px rgba(0,123,255,0.15);
}
.sortable-item.dragging {
opacity: 0.5;
border-color: #007bff;
background: #e7f3ff;
}
.sortable-item .handle {
color: #999;
font-size: 18px;
cursor: grab;
}
.sortable-item .content {
flex: 1;
font-weight: 500;
}
.sortable-item .badge {
background: #e9ecef;
color: #666;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
}
.order-display {
margin-top: 20px;
padding: 16px;
background: #f8f9fa;
border-radius: 6px;
font-size: 13px;
}
.order-label {
font-weight: 600;
color: #555;
margin-bottom: 8px;
}
.order-sequence {
color: #007bff;
font-family: monospace;
word-break: break-all;
}
.hint {
margin-top: 12px;
font-size: 13px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:可排序列表 - 拖拽排序</div>
<ul id="sortable" class="sortable-list">
<li draggable="true" data-id="1" class="sortable-item">
<span class="handle">☰</span>
<span class="content">学习 HTML5 基础知识</span>
<span class="badge">优先级: 高</span>
</li>
<li draggable="true" data-id="2" class="sortable-item">
<span class="handle">☰</span>
<span class="content">掌握 Canvas 绑图 API</span>
<span class="badge">优先级: 中</span>
</li>
<li draggable="true" data-id="3" class="sortable-item">
<span class="handle">☰</span>
<span class="content">实践 SVG 矢量图形</span>
<span class="badge">优先级: 中</span>
</li>
<li draggable="true" data-id="4" class="sortable-item">
<span class="handle">☰</span>
<span class="content">了解 Web Workers 多线程</span>
<span class="badge">优先级: 低</span>
</li>
<li draggable="true" data-id="5" class="sortable-item">
<span class="handle">☰</span>
<span class="content">学习地理位置定位 API</span>
<span class="badge">优先级: 低</span>
</li>
</ul>
<p class="hint">💡 提示:拖拽列表项可以重新排序</p>
<div class="order-display">
<div class="order-label">当前顺序:</div>
<div class="order-sequence" id="orderSequence"></div>
</div>
</div>
<script>
const list = document.getElementById("sortable")
let dragging = null
function updateOrderDisplay() {
const items = list.querySelectorAll(".sortable-item")
const ids = Array.from(items).map(item => item.dataset.id)
document.getElementById("orderSequence").textContent = ids.join(" → ")
}
updateOrderDisplay()
list.addEventListener("dragstart", (event) => {
const target = event.target.closest(".sortable-item")
if (!target) return
dragging = target
dragging.classList.add("dragging")
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.setData("text/plain", target.dataset.id)
})
list.addEventListener("dragend", () => {
if (dragging) {
dragging.classList.remove("dragging")
dragging = null
}
updateOrderDisplay()
})
list.addEventListener("dragover", (event) => {
event.preventDefault()
const target = event.target.closest(".sortable-item")
if (!target || target === dragging) return
const rect = target.getBoundingClientRect()
const isAfter = event.clientY > rect.top + rect.height / 2
if (isAfter) {
list.insertBefore(dragging, target.nextSibling)
} else {
list.insertBefore(dragging, target)
}
})
list.addEventListener("drop", (event) => {
event.preventDefault()
updateOrderDisplay()
})
</script>
</body>
</html>```
<h4>003-kanban-board.html</h4>
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【3】跨容器拖放(看板)</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f0f2f5; color: #333; }
.demo-container { max-width: 1000px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 20px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
.board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.column {
background: #f8f9fa;
border-radius: 10px;
padding: 16px;
min-height: 350px;
transition: all 0.3s;
}
.column.drag-over {
background: #e7f3ff;
box-shadow: inset 0 0 0 3px #007bff;
}
.column-header {
font-weight: 700;
font-size: 15px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 2px solid #e0e0e0;
display: flex;
align-items: center;
justify-content: space-between;
}
.column-header.todo { color: #6c757d; border-color: #6c757d; }
.column-header.doing { color: #fd7e14; border-color: #fd7e14; }
.column-header.done { color: #28a745; border-color: #28a745; }
.count-badge {
background: #e9ecef;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
color: #666;
}
.task-card {
background: white;
padding: 14px;
margin-bottom: 10px;
border-radius: 8px;
border-left: 4px solid #007bff;
cursor: move;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
transition: all 0.2s;
user-select: none;
}
.task-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
transform: translateY(-2px);
}
.task-card.dragging {
opacity: 0.4;
transform: rotate(3deg);
}
.task-title {
font-weight: 500;
font-size: 14px;
margin-bottom: 6px;
line-height: 1.4;
}
.task-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #888;
}
.task-tag {
background: #e3f2fd;
color: #1976d2;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
}
.log-bar {
margin-top: 20px;
padding: 12px 16px;
background: #1e1e1e;
color: #4ec9b0;
border-radius: 6px;
font-family: monospace;
font-size: 13px;
min-height: 44px;
}
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:跨容器拖放 - 看板(Kanban)</div>
<div class="board">
<div class="container column" data-status="todo">
<div class="column-header todo">
📋 待办
<span class="count-badge" id="todoCount">0</span>
</div>
<div class="task-card" draggable="true" data-id="1">
<div class="task-title">学习 HTML5 语义化标签</div>
<div class="task-meta"><span class="task-tag">基础</span><span>#1</span></div>
</div>
<div class="task-card" draggable="true" data-id="2">
<div class="task-title">掌握 Canvas 基础绑图</div>
<div class="task-meta"><span class="task-tag">进阶</span><span>#2</span></div>
</div>
<div class="task-card" draggable="true" data-id="3">
<div class="task-title">了解 Web Storage API</div>
<div class="task-meta"><span class="task-tag">基础</span><span>#3</span></div>
</div>
</div>
<div class="container column" data-status="doing">
<div class="column-header doing">
🔨 进行中
<span class="count-badge" id="doingCount">0</span>
</div>
<div class="task-card" draggable="true" data-id="4">
<div class="task-title">实践 SVG 动画效果</div>
<div class="task-meta"><span class="task-tag">进阶</span><span>#4</span></div>
</div>
</div>
<div class="container column" data-status="done">
<div class="column-header done">
✅ 已完成
<span class="count-badge" id="doneCount">0</span>
</div>
<div class="task-card" draggable="true" data-id="5">
<div class="task-title">搭建开发环境</div>
<div class="task-meta"><span class="task-tag">环境</span><span>#5</span></div>
</div>
</div>
</div>
<div class="log-bar" id="logBar">💡 拖拽任务卡片到不同列来改变状态...</div>
</div>
<script>
const containers = document.querySelectorAll(".container")
const logBar = document.getElementById("logBar")
let dragging = null
let sourceContainer = null
function updateCounts() {
containers.forEach(container => {
const status = container.dataset.status
const count = container.querySelectorAll(".task-card").length
const badge = document.getElementById(`${status}Count`)
if (badge) badge.textContent = count
})
}
updateCounts()
// 全局拖拽开始
document.addEventListener("dragstart", (event) => {
const item = event.target.closest(".task-card")
if (!item) return
dragging = item
sourceContainer = item.closest(".container")
item.classList.add("dragging")
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.setData("text/plain", item.dataset.id)
})
// 全局拖拽结束
document.addEventListener("dragend", () => {
if (dragging) {
dragging.classList.remove("dragging")
dragging = null
}
sourceContainer = null
containers.forEach(c => c.classList.remove("drag-over"))
updateCounts()
})
// 容器事件绑定
containers.forEach(container => {
container.addEventListener("dragenter", (event) => {
event.preventDefault()
container.classList.add("drag-over")
})
container.addEventListener("dragover", (event) => {
event.preventDefault()
event.dataTransfer.dropEffect = "move"
})
container.addEventListener("dragleave", (event) => {
if (!container.contains(event.relatedTarget)) {
container.classList.remove("drag-over")
}
})
container.addEventListener("drop", (event) => {
event.preventDefault()
container.classList.remove("drag-over")
if (!dragging) return
const targetItem = event.target.closest(".task-card")
if (targetItem && targetItem !== dragging) {
const rect = targetItem.getBoundingClientRect()
const isAfter = event.clientY > rect.top + rect.height / 2
container.insertBefore(dragging, isAfter ? targetItem.nextSibling : targetItem)
} else {
container.appendChild(dragging)
}
// 记录状态变化
const newStatus = container.dataset.status
const oldStatus = sourceContainer?.dataset.status
if (newStatus !== oldStatus && oldStatus) {
const statusNames = { todo: "待办", doing: "进行中", done: "已完成" }
const taskTitle = dragging.querySelector(".task-title").textContent.trim()
logBar.textContent = `✅ 任务「${taskTitle}」从「${statusNames[oldStatus]}」移动到「${statusNames[newStatus]}」`
setTimeout(() => {
logBar.textContent = "💡 拖拽任务卡片到不同列来改变状态..."
}, 3000)
}
})
})
</script>
</body>
</html>```
<h4>004-file-drop-validate.html</h4>
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【4】文件拖放 + 校验</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 700px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
.dropzone {
border: 3px dashed #007bff;
border-radius: 12px;
padding: 50px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #f8f9ff;
margin-bottom: 20px;
}
.dropzone:hover { background: #e7f3ff; border-color: #0056b3; }
.dropzone.drag-over {
background: #d4edda;
border-color: #28a745;
transform: scale(1.02);
}
.dropzone .icon { font-size: 56px; margin-bottom: 16px; }
.dropzone p { margin: 6px 0; font-size: 15px; color: #555; }
.dropzone .hint { font-size: 13px; color: #888; }
input[type="file"] { display: none; }
.file-list { list-style: none; }
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
margin-bottom: 10px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
transition: all 0.2s;
}
.file-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.file-info { flex: 1; }
.file-name { font-weight: 500; font-size: 14px; }
.file-meta { font-size: 12px; color: #666; margin-top: 4px; }
.file-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.status-valid { background: #d4edda; color: #155724; }
.status-invalid { background: #f8d7da; color: #721c24; }
.config-bar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
font-size: 14px;
}
.config-bar label { font-weight: 500; color: #555; }
.config-bar select { padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:文件拖放上传 + 类型校验</div>
<div class="config-bar">
<label>允许的文件类型:</label>
<select id="allowedTypes" onchange="updateAllowedTypes()">
<option value="image">仅图片 (jpg/png/gif)</option>
<option value="document">文档 (pdf/txt/doc)</option>
<option value="all">所有类型</option>
</select>
</div>
<div class="dropzone" id="dropZone" onclick="document.getElementById('fileInput').click()">
<div class="icon">📁</div>
<p><strong>拖放文件到此处</strong></p>
<p class="hint">或点击选择文件 · 支持多选</p>
<input type="file" id="fileInput" multiple />
</div>
<ul class="file-list" id="fileList"></ul>
</div>
<script>
const dropZone = document.getElementById("dropZone")
const fileInput = document.getElementById("fileInput")
const fileList = document.getElementById("fileList")
let ALLOWED_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
function updateAllowedTypes() {
const value = document.getElementById("allowedTypes").value
switch(value) {
case "image":
ALLOWED_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
break
case "document":
ALLOWED_TYPES = ["application/pdf", "text/plain", "application/msword"]
break
default:
ALLOWED_TYPES = []
}
}
// 拖拽事件
;["dragenter", "dragover"].forEach(event => {
dropZone.addEventListener(event, (e) => {
e.preventDefault()
dropZone.classList.add("drag-over")
})
})
;["dragleave", "drop"].forEach(event => {
dropZone.addEventListener(event, (e) => {
e.preventDefault()
dropZone.classList.remove("drag-over")
})
})
dropZone.addEventListener("drop", (event) => {
handleFiles(Array.from(event.dataTransfer.files))
})
fileInput.addEventListener("change", (event) => {
handleFiles(Array.from(event.target.files || []))
})
function handleFiles(files) {
fileList.innerHTML = ""
if (files.length === 0) return
files.forEach(file => {
const errors = validateFile(file)
const item = document.createElement("li")
item.className = "file-item"
const statusClass = errors.length === 0 ? "status-valid" : "status-invalid"
const statusText = errors.length === 0 ? "✓ 校验通过" : `✗ ${errors[0]}`
item.innerHTML = `
<div class="file-info">
<div class="file-name">${file.name}</div>
<div class="file-meta">${formatBytes(file.size)} · ${file.type || "未知类型"}</div>
</div>
<span class="file-status ${statusClass}">${statusText}</span>
`
fileList.appendChild(item)
})
}
function validateFile(file) {
const errors = []
if (!ALLOWED_TYPES.length && file.type) {
// 允许所有类型时不校验 MIME
} else if (ALLOWED_TYPES.length > 0 && !ALLOWED_TYPES.includes(file.type)) {
errors.push("类型不支持")
}
if (file.size > MAX_SIZE) {
errors.push("超过 5MB 限制")
}
return errors
}
function formatBytes(bytes) {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
}
</script>
</body>
</html>```
<h4>006-drag-handle.html</h4>
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>【6】拖拽手柄(Handle)</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f8f9fa; color: #333; }
.demo-container { max-width: 550px; margin: 0 auto; background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.demo-title { margin-bottom: 16px; font-size: 18px; color: #555; border-bottom: 2px solid #007bff; padding-bottom: 8px; }
.hint-text {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 12px 16px;
margin-bottom: 20px;
font-size: 14px;
border-radius: 4px;
}
.list { list-style: none; }
.item {
display: flex;
align-items: center;
padding: 14px 16px;
margin: 8px 0;
background: white;
border: 2px solid #e0e0e0;
border-radius: 8px;
transition: all 0.2s;
}
.item.dragging {
opacity: 0.4;
border-color: #007bff;
background: #e7f3ff;
}
.handle {
cursor: grab;
padding: 8px 14px;
color: #999;
font-size: 20px;
user-select: none;
margin-right: 12px;
transition: color 0.2s;
}
.handle:hover { color: #007bff; }
.handle:active { cursor: grabbing; }
.content {
flex: 1;
font-weight: 500;
}
.badge {
background: #e9ecef;
color: #666;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
}
.order-display {
margin-top: 20px;
padding: 14px;
background: #f8f9fa;
border-radius: 6px;
text-align: center;
}
.order-display span { color: #007bff; font-weight: 600; }
</style>
</head>
<body>
<div class="demo-container">
<div class="demo-title">示例:拖拽手柄 - 仅通过手柄区域触发拖拽</div>
<div class="hint-text">
💡 提示:只有点击左侧的 ☰ 手柄图标才能拖拽排序,点击其他位置无效
</div>
<ul class="list" id="sortable">
<li class="item" draggable="true" data-id="1">
<span class="handle">☰</span>
<span class="content">项目 Alpha</span>
<span class="badge">重要</span>
</li>
<li class="item" draggable="true" data-id="2">
<span class="handle">☰</span>
<span class="content">项目 Beta</span>
<span class="badge">普通</span>
</li>
<li class="item" draggable="true" data-id="3">
<span class="handle">☰</span>
<span class="content">项目 Gamma</span>
<span class="badge">紧急</span>
</li>
<li class="item" draggable="true" data-id="4">
<span class="handle">☰</span>
<span class="content">项目 Delta</span>
<span class="badge">普通</span>
</li>
<li class="item" draggable="true" data-id="5">
<span class="handle">☰</span>
<span class="content">项目 Epsilon</span>
<span class="badge">低优先</span>
</li>
</ul>
<div class="order-display">
当前顺序:<span id="orderDisplay"></span>
</div>
</div>
<script>
const list = document.getElementById("sortable")
let dragging = null
let validDrag = false
function updateOrder() {
const items = list.querySelectorAll(".item")
const names = Array.from(items).map(item =>
item.querySelector(".content").textContent.trim()
)
document.getElementById("orderDisplay").textContent = names.join(" → ")
}
updateOrder()
list.addEventListener("dragstart", (event) => {
const handle = event.target.closest(".handle")
// 只允许通过手柄拖拽
if (!handle) {
event.preventDefault()
return
}
validDrag = true
dragging = event.target.closest(".item")
dragging.classList.add("dragging")
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.setData("text/plain", dragging.dataset.id)
})
list.addEventListener("dragend", () => {
if (dragging) dragging.classList.remove("dragging")
dragging = null
validDrag = false
updateOrder()
})
list.addEventListener("dragover", (event) => {
event.preventDefault()
if (!validDrag || !dragging) return
const target = event.target.closest(".item")
if (!target || target === dragging) return
const rect = target.getBoundingClientRect()
const isAfter = event.clientY > rect.top + rect.height / 2
if (isAfter) {
list.insertBefore(dragging, target.nextSibling)
} else {
list.insertBefore(dragging, target)
}
})
</script>
</body>
</html>```
<h4>008-drag-sort-upload-kanban.html</h4>
```html
<!DOCTYPE html>
<!--
来源:基础知识/11-拖放操作.md
演示:拖放排序列表、拖拽上传带预览、跨区域拖拽
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>高级拖放:排序、上传、跨区域</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; padding: 30px; background: #f0f2f5; max-width: 960px; margin: 0 auto; }
h1 { color: #1a1a1a; margin-bottom: 25px; }
.section { background: white; padding: 25px; margin-bottom: 20px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.06); }
h2 { color: #333; font-size: 17px; margin-bottom: 15px; border-bottom: 2px solid #eee; padding-bottom: 8px; }
/* 排序列表 */
.sortable-list { list-style: none; padding: 0; max-width: 450px; }
.sortable-item {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; margin: 6px 0; background: #f8f9fa;
border: 2px solid #e8e8e8; border-radius: 10px;
cursor: grab; transition: all 0.2s; user-select: none;
}
.sortable-item:active { cursor: grabbing; }
.sortable-item.dragging {
opacity: 0.4; transform: scale(0.98);
border-color: #3498db; background: #ebf5fb;
}
.sortable-item.drag-over {
border-color: #27ae60; background: #eafaf1;
transform: translateY(2px);
}
.sort-handle {
font-size: 18px; color: #bbb; cursor: grab; padding: 4px;
user-select: none;
}
.sort-handle:hover { color: #666; }
.sort-info { flex: 1; }
.sort-info .name { font-weight: 600; font-size: 14px; color: #333; }
.sort-info .desc { font-size: 12px; color: #888; margin-top: 2px; }
.sort-rank {
width: 28px; height: 28px; background: #e9ecef; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: bold; color: #666; flex-shrink: 0;
}
/* 文件上传 */
.upload-zone {
border: 3px dashed #bdc3c7; border-radius: 14px; padding: 30px;
text-align: center; transition: all 0.25s; min-height: 160px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
}
.upload-zone.drag-over {
border-color: #3498db; background: #ebf5fb;
box-shadow: inset 0 0 30px rgba(52,152,219,0.08);
transform: scale(1.01);
}
.upload-icon { font-size: 48px; margin-bottom: 10px; }
.upload-text { font-size: 15px; color: #666; }
.upload-hint { font-size: 12px; color: #aaa; margin-top: 6px; }
.file-preview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; margin-top: 15px; }
.file-item {
background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 10px;
overflow: hidden; position: relative;
}
.file-item img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
.file-item .file-name {
padding: 6px 8px; font-size: 11px; color: #555; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.file-item .remove-btn {
position: absolute; top: 4px; right: 4px; width: 22px; height: 22px;
background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%;
cursor: pointer; font-size: 12px; display: flex; align-items: center;
justify-content: center; opacity: 0; transition: opacity 0.2s;
}
.file-item:hover .remove-btn { opacity: 1; }
/* 跨区域拖拽(看板) */
.kanban-board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; }
@media (max-width: 700px) { .kanban-board { grid-template-columns: 1fr; } }
.kanban-column {
background: #f8f9fa; border-radius: 12px; padding: 14px;
border: 2px solid transparent; transition: all 0.2s;
min-height: 200px;
}
.kanban-column.drag-over { border-color: #3498db; background: #ebf5fb; }
.kanban-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #eee;
}
.kanban-header h4 { font-size: 14px; display: flex; align-items: center; gap: 6px; }
.kanban-count {
background: #e0e0e0; padding: 2px 8px; border-radius: 10px;
font-size: 11px; color: #666;
}
.kanban-card {
background: white; padding: 12px 14px; margin-bottom: 8px;
border-radius: 8px; border-left: 3px solid #ddd;
cursor: grab; font-size: 13px; transition: all 0.15s;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.kanban-card:active { cursor: grabbing; }
.kanban-card.dragging { opacity: 0.35; transform: rotate(3deg); box-shadow: 0 8px 20px rgba(0,0,0,0.15); }
.kanban-card .tag {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 10px; margin-top: 6px;
}
.tag-high { background: #fee2e2; color: #dc2626; }
.tag-mid { background: #fef3c7; color: #d97706; }
.tag-low { background: #d1fae5; color: #059669; }
pre { background: #263238; color: #eceff1; padding: 14px; border-radius: 8px; overflow-x: auto; font-size: 12px; line-height: 1.6; }
</style>
</head>
<body>
<h1>🎯 高级拖放实战演示</h1>
<!-- ========== 拖拽排序列表 ========== -->
<div class="section">
<h2>1. 拖拽排序列表</h2>
<p style="font-size:13px;color:#666;margin-bottom:12px;">拖动列表项调整顺序:</p>
<ul class="sortable-list" id="sortable-list">
<li class="sortable-item" draggable="true">
<span class="sort-handle">⠿</span>
<span class="sort-rank">1</span>
<div class="sort-info">
<div class="name">学习 HTML 基础语法</div>
<div class="desc">了解标签、属性、文档结构</div>
</div>
</li>
<li class="sortable-item" draggable="true">
<span class="sort-handle">⠿</span>
<span class="sort-rank">2</span>
<div class="sort-info">
<div class="name">掌握 CSS 选择器和布局</div>
<div class="desc">Flexbox、Grid、响应式设计</div>
</div>
</li>
<li class="sortable-item" draggable="true">
<span class="sort-handle">⠿</span>
<span class="sort-rank">3</span>
<div class="sort-info">
<div class="name">JavaScript 核心概念</div>
<div class="desc">变量、函数、DOM 操作、事件</div>
</div>
</li>
<li class="sortable-item" draggable="true">
<span class="sort-handle">⠿</span>
<span class="sort-rank">4</span>
<div class="sort-info">
<div class="name">前端框架入门</div>
<div class="desc">React / Vue.js 基础使用</div>
</div>
</li>
<li class="sortable-item" draggable="true">
<span class="sort-handle">⠿</span>
<span class="sort-rank">5</span>
<div class="sort-info">
<div class="name">构建工具与工程化</div>
<div class="desc">Webpack、Vite、ESLint、Git</div>
</div>
</li>
</ul>
<pre id="sort-log" style="margin-top:15px;font-size:11px;">// 拖拽排序日志...</pre>
</div>
<!-- ========== 文件拖拽上传 ========== -->
<div class="section">
<h2>2. 文件拖拽上传(带图片预览)</h2>
<div class="upload-zone" id="upload-zone">
<div class="upload-icon">📁</div>
<div class="upload-text">将文件拖到此处,或点击选择</div>
<div class="upload-hint">支持 JPG、PNG、GIF 格式,可多选</div>
<input type="file" id="file-input" multiple accept="image/*" style="display:none;">
</div>
<div class="file-preview-grid" id="preview-grid"></div>
</div>
<!-- ========== 看板式跨区域拖拽 ========== -->
<div class="section">
<h2>3. 看板式跨区域拖拽</h2>
<p style="font-size:13px;color:#666;margin-bottom:12px;">在列之间拖拽卡片:</p>
<div class="kanban-board">
<div class="kanban-column" data-status="todo" ondragover="kanbanDragOver(event)" ondrop="kanbanDrop(event)" ondragleave="kanbanDragLeave(event)">
<div class="kanban-header">
<h4>📋 待办 <span class="kanban-count" id="count-todo">3</span></h4>
</div>
<div class="kanban-card" draggable="true" ondragstart="kanbanDragStart(event)">
设计登录页面 UI 原型
<span class="tag tag-high">高优先级</span>
</div>
<div class="kanban-card" draggable="true" ondragstart="kanbanDragStart(event)">
编写用户认证 API 接口
<span class="tag tag-high">高优先级</span>
</div>
<div class="kanban-card" draggable="true" ondragstart="kanbanDragStart(event)">
配置开发环境
<span class="tag tag-mid">中优先级</span>
</div>
</div>
<div class="kanban-column" data-status="doing" ondragover="kanbanDragOver(event)" ondrop="kanbanDrop(event)" ondragleave="kanbanDragLeave(event)">
<div class="kanban-header">
<h4>🔨 进行中 <span class="kanban-count" id="count-doing">2</span></h4>
</div>
<div class="kanban-card" draggable="true" ondragstart="kanbanDragStart(event)">
实现首页布局组件
<span class="tag tag-mid">中优先级</span>
</div>
<div class="kanban-card" draggable="true" ondragstart="kanbanDragStart(event)">
对接第三方支付 SDK
<span class="tag tag-high">高优先级</span>
</div>
</div>
<div class="kanban-column" data-status="done" ondragover="kanbanDragOver(event)" ondrop="kanbanDrop(event)" ondragleave="kanbanDragLeave(event)">
<div class="kanban-header">
<h4>✅ 已完成 <span class="kanban-count" id="count-done">1</span></h4>
</div>
<div class="kanban-card" draggable="true" ondragstart="kanbanDragStart(event)">
项目初始化与脚手架搭建
<span class="tag tag-low">低优先级</span>
</div>
</div>
</div>
</div>
<script>
// ===== 1. 排序列表 =====
const sortList = document.getElementById('sortable-list');
const sortLog = document.getElementById('sort-log');
let draggedSortItem = null;
sortList.querySelectorAll('.sortable-item').forEach(item => {
item.addEventListener('dragstart', (e) => {
draggedSortItem = item;
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'sort');
logSort(`[dragstart] 开始拖拽: "${item.querySelector('.name').textContent}"`);
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
draggedSortItem = null;
sortList.querySelectorAll('.sortable-item').forEach(i => i.classList.remove('drag-over'));
updateSortRanks();
logSort('[dragend] 拖拽结束,已更新排名');
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
if (!draggedSortItem || draggedSortItem === item) return;
const rect = item.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
item.parentNode.insertBefore(draggedSortItem, item);
} else {
item.parentNode.insertBefore(draggedSortId, item.nextSibling);
}
item.classList.add('drag-over');
});
item.addEventListener('dragleave', () => {
item.classList.remove('drag-over');
});
});
function updateSortRanks() {
sortList.querySelectorAll('.sortable-item').forEach((item, i) => {
item.querySelector('.sort-rank').textContent = i + 1;
});
}
function logSort(msg) {
sortLog.textContent = `[${new Date().toLocaleTimeString()}] ${msg}\n` + sortLog.textContent;
}
// ===== 2. 文件上传 =====
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const previewGrid = document.getElementById('preview-grid');
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('drag-over');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('drag-over');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('drag-over');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', () => {
handleFiles(fileInput.files);
});
function handleFiles(files) {
Array.from(files).forEach(file => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = (e) => {
const item = document.createElement('div');
item.className = 'file-item';
item.innerHTML = `
<img src="${e.target.result}" alt="${file.name}">
<button class="remove-btn" onclick="this.parentElement.remove()">✕</button>
<div class="file-name">${file.name}</div>`;
previewGrid.appendChild(item);
};
reader.readAsDataURL(file);
});
}
// ===== 3. 看板拖拽 =====
let draggedCard = null;
function kanbanDragStart(e) {
draggedCard = e.target;
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'kanban');
}
function kanbanDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
e.currentTarget.classList.add('drag-over');
}
function kanbanDragLeave(e) {
e.currentTarget.classList.remove('drag-over');
}
function kanbanDrop(e) {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
if (draggedCard) {
const body = e.currentTarget.querySelector('.kanban-header')?.nextElementSibling || e.currentTarget;
body.appendChild(draggedCard);
draggedCard.classList.remove('dragging');
updateKanbanCounts();
}
}
// 全局 dragend 处理
document.addEventListener('dragend', (e) => {
if (e.target.classList.contains('kanban-card')) {
e.target.classList.remove('dragging');
document.querySelectorAll('.kanban-column').forEach(col => col.classList.remove('drag-over'));
updateKanbanCounts();
}
});
function updateKanbanCounts() {
['todo', 'doing', 'done'].forEach(status => {
const col = document.querySelector(`[data-status="${status}"]`);
const count = col.querySelectorAll('.kanban-card').length;
document.getElementById(`count-${status}`).textContent = count;
});
}
</script>
</body>
</html><!DOCTYPE html>
<!--
来源:基础知识/11-拖放操作.md
演示:触摸设备拖拽(Touch Events + CSS touch-action)、拖拽状态机
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>触摸拖拽与状态机演示</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; padding: 30px; background: #f0f2f5; max-width: 900px; margin: 0 auto; min-height: 100vh; }
h1 { color: #1a1a1a; margin-bottom: 25px; }
.section { background: white; padding: 25px; margin-bottom: 20px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.06); }
h2 { color: #333; font-size: 17px; margin-bottom: 15px; border-bottom: 2px solid #eee; padding-bottom: 8px; }
/* 触摸拖拽画布 */
.touch-canvas {
position: relative; width: 100%; height: 350px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 12px; overflow: hidden; touch-action: none;
user-select: none;
}
.touch-canvas .grid-bg {
position: absolute; inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.05) 1px 1px, transparent 1px,
linear-gradient(90deg, rgba(255,255,255,0.05) 1px 1px, transparent 1px);
background-size: 40px 40px;
}
.draggable-chip {
position: absolute; padding: 12px 20px; border-radius: 25px;
color: white; font-size: 14px; font-weight: 600;
cursor: grab; user-select: none; touch-action: none;
display: flex; align-items: center; gap: 8px;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
transition: box-shadow 0.15s;
z-index: 1;
}
.draggable-chip:active, .draggable-chip.dragging {
cursor: grabbing; z-index: 100;
box-shadow: 0 12px 35px rgba(0,0,0,0.5);
transform: scale(1.05);
}
/* 状态机面板 */
.state-machine {
display: flex; justify-content: center; gap: 8px; flex-wrap: wrap;
margin: 20px 0; padding: 16px; background: #f8f9fa; border-radius: 10px;
}
.state-node {
padding: 10px 18px; border-radius: 8px; font-size: 13px; font-weight: 600;
border: 2px solid #e0e0e0; background: white; transition: all 0.3s;
position: relative;
}
.state-node.active {
border-color: #3498db; background: #ebf5fb; color: #2980b9;
box-shadow: 0 0 0 3px rgba(52,152,219,0.2), 0 4px 12px rgba(52,152,219,0.15);
transform: scale(1.05);
}
.state-arrow {
font-size: 18px; color: #bbb; align-self: center;
}
/* touch-action 演示区 */
.ta-demo {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 15px 0;
}
@media (max-width: 650px) { .ta-demo { grid-template-columns: 1fr 1fr; } }
@media (max-width: 400px) { .ta-demo { grid-template-columns: 1fr; } }
.ta-box {
padding: 30px 16px; border-radius: 10px; text-align: center;
font-size: 13px; color: #555; font-weight: 500; cursor: grab;
touch-action: pan-y; user-select: none;
border: 2px solid #e0e0e0; transition: all 0.2s;
display: flex; flex-direction: column; align-items: center; gap: 8px;
}
.ta-box:active { background: #ebf5fb; border-color: #3498db; }
.ta-box .swipe-icon { font-size: 28px; }
.ta-box .action-label {
font-size: 11px; padding: 3px 10px; border-radius: 12px;
background: #f0f0f0; font-family: monospace;
}
table { width: 100%; border-collapse: collapse; font-size: 13px; margin: 15px 0; }
th, td { padding: 10px 12px; text-align: left; border: 1px solid #dee2e6; }
th { background: #34495e; color: white; }
pre { background: #263238; color: #eceff1; padding: 15px; border-radius: 8px; overflow-x: auto; font-size: 12px; line-height: 1.6; }
.log-area { background: #1e1e1e; color: #d4d4d4; padding: 14px; border-radius: 8px; font-family: monospace; font-size: 12px; line-height: 1.6; max-height:200px;overflow-y:auto;margin-top:12px;}
</style>
</head>
<body>
<h1>📱 触摸拖拽 & 状态机演示</h1>
<!-- ========== Touch Drag Canvas ========== -->
<div class="section">
<h2>1. 触摸事件拖拽画布</h2>
<p style="font-size:13px;color:#666;margin-bottom:12px;">
支持鼠标和触摸拖拽。在移动设备上也可以正常工作:
</p>
<div class="touch-canvas" id="touch-canvas">
<div class="grid-bg"></div>
<!-- 可拖拽元素由 JS 动态定位 -->
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px;">
<button style="background:#3498db;color:white;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px;" onclick="addChip()">➕ 添加元素</button>
<button style="background:#e74c3c;color:white;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px;" onclick="resetChips()">🔄 重置位置</button>
<button style="background:#95a5a6;color:white;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px;" onclick="randomizePositions()">🎲 随机排列</button>
</div>
<div class="log-area" id="drag-log">// 拖拽日志...</div>
</div>
<!-- ========== Drag State Machine ========== -->
<div class="section">
<h2>2. 拖拽状态机(State Machine)</h2>
<p style="font-size:13px;color:#666;margin-bottom:12px;">
一个完整的拖拽操作经历多个状态转换:
</p>
<div class="state-machine" id="state-machine">
<div class="state-node" data-state="idle" id="st-idle">😴 空闲 idle</div>
<div class="state-arrow">→</div>
<div class="state-node" data-state="prepare" id="st-prepare">👆 准备 prepare</div>
<div class="state-arrow">→</div>
<div class="state-node" data-state="dragging" id="st-dragging">✋ 拖拽中 dragging</div>
<div class="state-arrow">→</div>
<div class="state-node" data-state="over-target" id="st-over">📍 悬停 over</div>
<div class="state-arrow" style="transform:rotate(90deg);margin:4px 0;">↘</div>
<div class="state-node" data-state="drop" id="st-drop">✅ 放置 drop</div>
<div class="state-arrow" style="transform:rotate(90deg);margin:4px 0;">↙</div>
<div class="state-node" data-state="end" id="st-end">🏁 结束 end</div>
</div>
<table>
<thead><tr><th>当前状态</th><th>触发事件</th><th>下一状态</th></tr></thead>
<tbody>
<tr><td><code>idle</code></td><td>mousedown/touchstart</td><td><code>prepare</code></td></tr>
<tr><td><code>prepare</code></td><td>mousemove ≥ 3px / touchmove</td><td><code>dragging</code></td></tr>
<tr><td><code>prepare</code></td><td>mouseup/cancel</td><td><code>idle</code></td></tr>
<tr><td><code>dragging</code></td><td>进入 drop zone</td><td><code>over-target</code></td></tr>
<tr><td><code>dragging</code></td><td>mouseup (不在 zone)</td><td><code>end</code></td></tr>
<tr><td><code>over-target</code></td><td>mouseup</td><td><code>drop</code></td></tr>
<tr><td><code>over-target</code></td><td>离开 zone</td><td><code>dragging</code></td></tr>
<tr><td><code>any</code></td><td>Escape 键</td><td><code>idle</code> (cancel)</td></tr>
</tbody>
</table>
</div>
<!-- ========== touch-action ========== -->
<div class="section">
<h2>3. touch-action CSS 属性对比</h2>
<p style="font-size:13px;color:#666;margin-bottom:12px;">
尝试在下方各个区域内滑动手指,观察不同的触摸行为:
</p>
<div class="ta-demo">
<div class="ta-box" style="touch-action:auto;">
<span class="swipe-icon">↕️</span>
<span>auto(默认)<br>允许所有手势</span>
<span class="action-label">touch-action: auto</span>
</div>
<div class="ta-box" style="touch-action:none;">
<span class="swipe-icon">🚫</span>
<span>none<br>禁止所有手势</span>
<span class="action-label">touch-action: none</span>
</div>
<div class="ta-box" style="touch-action:pan-x;">
<span class="swipe-icon">↔️</span>
<span>pan-x<br>仅允许水平滑动</span>
<span class="action-label">touch-action: pan-x</span>
</div>
<div class="ta-box" style="touch-action:pan-y;">
<span class="swipe-icon">↕️</span>
<span>pan-y<br>仅允许垂直滑动</span>
<span class="action-label">touch-action: pan-y</span>
</div>
<div class="ta-box" style="touch-action:manipulation;">
<span class="swipe-icon">👆</span>
<span>manipulation<br>允许缩放但不双击放大</span>
<span class="action-label">touch-action: manipulation</span>
</div>
<div class="ta-box" style="touch-action:pinch-zoom;">
<span class="swipe-icon">🤏</span>
<span>pinch-zoom<br>仅允许双指缩放</span>
<span class="action-label">touch-action: pinch-zoom</span>
</div>
</div>
<pre style="font-size:11px;">/* touch-action 最佳实践 */
/* 自定义拖拽元素 — 禁止浏览器默认行为 */
.draggable-element {
touch-action: none; /* 关键!阻止默认滚动/缩放 */
user-select: none;
}
/* 可滚动容器内的拖拽项 */
.scrollable-container .drag-item {
touch-action: pan-y; /* 保留垂直滚动 */
/* 不设 pan-x → 水平方向交给 JS 处理 */
}
/* 按钮和交互控件 */
button, a, input {
touch-action: manipulation; /* 允许缩放,禁用双击缩放 */
/* 提升点击响应速度 ~300ms */
}</pre>
</div>
<script>
// ===== Touch Drag Canvas =====
const canvas = document.getElementById('touch-canvas');
const log = document.getElementById('drag-log');
let chipIdCounter = 0;
const colors = [
'linear-gradient(135deg, #667eea, #764ba2)',
'linear-gradient(135deg, #f093fb, #f5576c)',
'linear-gradient(135deg, #4facfe, #00f2fe)',
'linear-gradient(135deg, #43e97b, #38f9d7)',
'linear-gradient(135deg, #fa709a, #fee140)',
'linear-gradient(135deg, #a18cd1, #fbc2eb)',
];
const icons = ['🎯', '⭐', '🔥', '💡', '🎨', '🚀', '💎', '🌟', '🎮', '🎵'];
function logDrag(msg) {
log.textContent = `[${new Date().toLocaleTimeString()}] ${msg}\n` + log.textContent;
}
function createChip(x, y) {
const chip = document.createElement('div');
chip.className = 'draggable-chip';
chip.id = `chip-${chipIdCounter++}`;
chip.style.left = x + 'px';
chip.style.top = y + 'px';
chip.style.background = colors[chipIdCounter % colors.length];
chip.innerHTML = `${icons[chipIdCounter % icons.length]} 芯片 ${chipIdCounter}`;
// 绑定指针事件(统一处理 mouse + touch)
bindPointerEvents(chip);
canvas.appendChild(chip);
return chip;
}
function bindPointerEvents(el) {
let startX, startY, initialX, initialY, isDragging = false;
const startDrag = (clientX, clientY) => {
isDragging = true;
startX = clientX;
startY = clientY;
initialX = el.offsetLeft;
initialY = el.offsetTop;
el.classList.add('dragging');
logDrag(`[start] 开始拖拽 ${el.id} at (${Math.round(initialX)}, ${Math.round(initialY)})`);
// 更新状态机
updateState('prepare');
setTimeout(() => updateState('dragging'), 300);
};
const moveDrag = (clientX, clientY) => {
if (!isDragging) return;
const dx = clientX - startX;
const dy = clientY - startY;
// 边界限制
const canvasRect = canvas.getBoundingClientRect();
const newLeft = Math.max(0, Math.min(canvasRect.width - el.offsetWidth, initialX + dx));
const newTop = Math.max(0, Math.min(canvasRect.height - el.offsetHeight, initialY + dy));
el.style.left = newLeft + 'px';
el.style.top = newTop + 'px';
// 检测是否在边界附近
if (newLeft <= 0 || newLeft >= canvasRect.width - el.offsetWidth ||
newTop <= 0 || newTop >= canvasRect.height - el.offsetHeight) {
updateState('over-target');
} else {
updateState('dragging');
}
};
const endDrag = () => {
if (!isDragging) return;
isDragging = false;
el.classList.remove('dragging');
const finalX = Math.round(el.offsetLeft);
const finalY = Math.round(el.offsetTop);
updateState('end');
setTimeout(() => updateState('idle'), 800);
logDrag(`[end] 放置 ${el.id} at (${finalX}, ${finalY})`);
};
// Mouse events
el.addEventListener('mousedown', (e) => {
e.preventDefault();
startDrag(e.clientX, e.clientY);
});
document.addEventListener('mousemove', (e) => moveDrag(e.clientX, e.clientY));
document.addEventListener('mouseup', endDrag);
// Touch events
el.addEventListener('touchstart', (e) => {
e.preventDefault();
const t = e.touches[0];
startDrag(t.clientX, t.clientY);
}, { passive: false });
document.addEventListener('touchmove', (e) => {
if (!isDragging) return;
e.preventDefault(); // 阻止页面滚动
const t = e.touches[0];
moveDrag(t.clientX, t.clientY);
}, { passive: false });
document.addEventListener('touchend', endDrag);
document.addEventListener('touchcancel', endDrag);
}
function addChip() {
const rect = canvas.getBoundingClientRect();
const x = Math.random() * (rect.width - 140) + 10;
const y = Math.random() * (rect.height - 50) + 10;
createChip(x, y);
}
function resetChips() {
const chips = canvas.querySelectorAll('.draggable-chip');
const positions = [
[20, 20], [160, 80], [320, 30], [480, 100],
[80, 180], [240, 220], [400, 170]
];
chips.forEach((chip, i) => {
const pos = positions[i % positions.length];
chip.style.left = pos[0] + 'px';
chip.style.top = pos[1] + 'px';
});
logDrag('[reset] 所有元素已重置到初始位置');
}
function randomizePositions() {
const chips = canvas.querySelectorAll('.draggable-chip');
const rect = canvas.getBoundingClientRect();
chips.forEach(chip => {
chip.style.left = Math.random() * (rect.width - 140) + 10 + 'px';
chip.style.top = Math.random() * (rect.height - 50) + 10 + 'px';
});
logDrag('[shuffle] 元素位置已随机打乱');
}
// 初始化一些芯片
for (let i = 0; i < 5; i++) addChip();
// ===== State Machine =====
function updateState(stateName) {
document.querySelectorAll('.state-node').forEach(node => {
node.classList.toggle('active', node.dataset.state === stateName);
});
}
// 初始状态
updateState('idle');
</script>
</body>
</html>