这是一份从 0 到 1 的「前端拖拽排序实现详解」——覆盖原理、数据结构、事件系统、碰撞算法、动画(FLIP)、自动滚动、可访问性(A11y)、移动端兼容(Pointer Events)、等

1. 为什么要自己实现拖拽排序
-
灵活性:可完全掌控判定规则(吸附/交换/插入)、动画、自动滚动阈值、边界、禁用项、拖拽手柄、网格布局等。
-
性能可控:跳过不必要的 reflow/回流,使用 FLIP/transform 动画,和虚拟列表结合。
-
A11y:可按产品需求定制键盘交互和 ARIA。
-
可维护:减少黑盒行为;当库升级/弃更/体积限制时仍能保证需求落地。
2. 基本概念与设计目标
拖拽排序:用户在一个列表/网格中按住某项,拖动到另一个位置,松手后数据顺序发生改变,UI 与数据一致。
设计目标
-
统一鼠标/触摸/触控笔:建议使用 Pointer Events(降级到 mouse/touch)。
-
流畅动画:FLIP +
transform: translate3d避免布局抖动。 -
准确判定:插入/交换/区间半分(half-split)/中心点碰撞(center-overlap)。
-
可访问性:屏幕阅读器、焦点管理、键盘操作(↑↓ 或空格+方向键移动)。
-
边界与滚动:容器内自动滚动;页面滚动;
position: sticky交互。 -
支持:列表与网格、拖拽手柄、禁用项、锁定轴向、可放回原位(撤销)。
3. 三条技术路线总览
-
HTML5 Drag & Drop API
-
优点:API 现成;原生事件;快速 POC。
-
缺点:移动端体验不佳;拖影样式受限;跨浏览器表现差异;难做细粒度动画与可控碰撞。
-
场景:后台管理 PC 端 简单场景、低成本卡片排序。
-
-
Pointer/Mouse/Touch 自行实现(推荐)
-
优点:最可控;移动端体验可打磨;动画/命中规则/滚动完全自定义。
-
缺点:需要更多代码与边界处理。
-
场景:移动端+PC 端统一、需要动画和高拟物交互。
-
-
第三方库(如 SortableJS / dnd-kit / VueDraggableNext)
-
优点:成熟、快。
-
缺点:体积、黑盒限制、与业务定制冲突。
-
场景:赶工/通用后台,或 MVP 阶段。
-
后文重点讲路线 2(可控实现),也会给出路线 1 的简版和路线 3 的心智对照。
4. 方案一:HTML5 Drag & Drop API(简版示例)
仅作为教学/备选。移动端需要额外 polyfill,拖影效果受限。
核心事件
-
dragstart:开始拖拽(设置 dataTransfer、样式) -
dragover:在目标上方移动(必须event.preventDefault()才能触发 drop) -
drop:释放,执行数据交换 -
dragend:结束清理
最小可用列表排序(HTML5 DnD)
<ul id="list" class="list">
<li draggable="true" data-id="1">A</li>
<li draggable="true" data-id="2">B</li>
<li draggable="true" data-id="3">C</li>
<li draggable="true" data-id="4">D</li>
</ul>
<style>
.list { list-style: none; padding: 0; width: 240px; }
.list li { padding: 8px 12px; margin: 6px 0; background: #f5f5f5; border-radius: 6px; cursor: grab; }
.list li.dragging { opacity: .5; }
</style>
<script>
const list = document.getElementById('list');
let draggingEl = null;
list.addEventListener('dragstart', e => {
draggingEl = e.target;
draggingEl.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
list.addEventListener('dragend', () => {
draggingEl?.classList.remove('dragging');
draggingEl = null;
});
list.addEventListener('dragover', e => {
e.preventDefault(); // 允许 drop
const afterEl = getDragAfterElement(list, e.clientY);
if (!afterEl) {
list.appendChild(draggingEl);
} else {
list.insertBefore(draggingEl, afterEl);
}
});
function getDragAfterElement(container, y) {
const els = [...container.querySelectorAll('li:not(.dragging)')];
let nearest = null;
let offset = Number.NEGATIVE_INFINITY;
for (const el of els) {
const rect = el.getBoundingClientRect();
const diff = y - rect.top - rect.height / 2; // 半分判定
if (diff < 0 && diff > offset) {
offset = diff;
nearest = el;
}
}
return nearest;
}
</script>
要点:
-
dragover内使用「半分判定」:光标超过目标项中线就插在它前面。 -
数据层可在
dragend或外部按钮触发时,读取 DOM 次序映射为数组顺序。
缺陷:移动端弱、动画难控、
dataTransfer.setDragImage仍不如自绘幻影灵活。
接下来我们用路线 2 做真正可控、移动端友好的实现。
5. 方案二:Pointer/Mouse/Touch 统一实现(推荐)
为什么选择 Pointer Events
-
pointerdown/pointermove/pointerup覆盖鼠标、触控笔、触摸。 -
可通过
event.pointerType区分"mouse" | "touch" | "pen"。 -
配合
setPointerCapture,事件在指针捕获期间稳定派发到指定元素。
兼容性:现代浏览器基本支持。极老设备降级到
touchstart/mousedown也可。
基础步骤
-
pointerdown:记录起点,克隆一个 拖影(drag ghost)或将项设置为绝对定位(脱离文档流)。 -
pointermove:计算偏移,移动拖影;找当前命中项;预演插入(占位符)。 -
pointerup:提交顺序(更新数组),移除占位符/拖影,释放捕获。
占位符策略
-
在列表中插一个与被拖项同尺寸的 placeholder,原位置被占。
-
拖影不影响布局;其他项根据插入位置平滑过渡(FLIP)。
6. 排序判定核心:碰撞/命中策略
常见命中策略:
-
半分判定(Half-Split)
-
纵向列表:指针 Y 超过目标项中线,插到它后面;否则前面。
-
简单直觉,易实现。
-
-
中心点判定(Center-Over)
-
以拖拽目标中心点与其它项矩形碰撞决定位置。
-
可扩展到网格布局。
-
-
最近距离/最小交叉面积
-
计算拖影中心与各项中心的距离/交叉面积,选择最佳目标。
-
更平滑但计算量更大,列表大时需优化。
-
本教程大部分示例采用 半分判定(列表)与 中心点判定(网格)。
7. 动画策略:FLIP
FLIP:First → Last → Invert → Play
-
First:记录每个元素初始位置(
getBoundingClientRect)。 -
Last:应用新布局(插入占位/改变顺序/更新 DOM)。
-
Invert:计算两次位置差异
dx/dy,把元素瞬间transform: translate(...)回原位置。 -
Play:移除 transform,以过渡动画回到新位置(
transition)。
优点:避免频繁 reflow;动画流畅;与 transform 硬件加速友好。
8. 自动滚动(Auto-Scroll)
当拖影接近可滚动容器边缘或窗口边缘,自动滚动以扩展可拖区域。
关键点:
-
设定 阈值(顶部/底部/左右各 20~40px)。
-
根据接近程度计算 速度(线性或指数)。
-
通过
requestAnimationFrame循环滚动,直到pointermove离开阈值或松手。 -
注意与页面滚动/内部滚动的优先级(命中最近的滚动容器)。
9. 拖拽手柄、轴向锁、网格/瀑布流
-
拖拽手柄:仅在指定子元素
handle上pointerdown才允许启动拖拽;防止误触。 -
轴向锁:
axis: 'y' | 'x' | 'both',列表常选'y',网格'both'。 -
网格/瀑布流:使用中心点判定 + 最近格子吸附,动画仍然用 FLIP。
10. 可访问性(A11y)与键盘可操作性
-
列表容器:
role="list" / role="listbox"(根据语义)。 -
列表项:
role="listitem" / role="option",可加aria-grabbed="true|false"表示抓取状态(注:旧属性在 ARIA 1.2 中弱化,但读屏仍有帮助)。 -
视觉焦点:
tabindex="0",使用键盘 ↑↓ 或Space开始抓取,Enter/Space确认放下,Esc取消。 -
提示:
aria-live="polite"的区域在排序变更时读出“已移动到第 n 位”。 -
注意
aria-dropeffect已废弃,使用动态描述或aria-describedby替代提示。
11. 数据结构与不可变更新(性能友好)
-
使用
id稳定标识项;DOM 节点与数据项一一映射。 -
排序提交时使用不可变更新,如:
function arrayMove(arr, from, to) { const next = arr.slice(); const [item] = next.splice(from, 1); next.splice(to, 0, item); return next; } -
React/Vue 等框架中保证 key 稳定,避免错误复用。
12. 完整 Vanilla 实现
12.1 列表排序(Pointer Events + 半分判定 + FLIP + 自动滚动 + 手柄)
可直接用一个 HTML 文件打开运行(现代浏览器)。
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8"/>
<title>Sortable List - Vanilla</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 0; padding: 24px; background: #fafafa; }
h1 { margin: 0 0 16px; font-size: 20px; }
.panel { max-width: 520px; margin: 0 auto; background: #fff; border-radius: 12px; box-shadow: 0 6px 24px rgba(0,0,0,.08); padding: 16px; }
.list { list-style: none; margin: 0; padding: 0; max-height: 420px; overflow: auto; border: 1px dashed #ddd; border-radius: 8px; }
.item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; margin: 8px; background: #f6f7f8; border-radius: 8px; transition: transform .25s ease, box-shadow .2s ease, opacity .2s ease; will-change: transform; }
.item[aria-grabbed="true"] { box-shadow: 0 8px 24px rgba(0,0,0,.18); }
.handle { width: 18px; height: 18px; border-radius: 4px; background: #ccc; cursor: grab; flex: none; position: relative; }
.handle::before, .handle::after {
content:""; position:absolute; left:50%; top:50%; width:12px; height:2px; background:#fff; transform:translate(-50%,-50%);
box-shadow: 0 -4px 0 0 #fff, 0 4px 0 0 #fff;
}
.label { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.placeholder { height: 0; margin: 8px; border-radius: 8px; background: rgba(0, 125, 250, .15); transition: height .2s ease; }
.ghost {
position: fixed; left: 0; top: 0; z-index: 9999; pointer-events: none;
transform: translate3d(var(--x,0), var(--y,0), 0);
}
</style>
</head>
<body>
<div class="panel" role="region" aria-label="可排序列表区域">
<h1>拖拽排序(Vanilla + Pointer Events + FLIP + AutoScroll)</h1>
<ul id="list" class="list" role="listbox" aria-live="polite"></ul>
</div>
<script>
(() => {
const data = Array.from({length: 20}, (_, i) => ({ id: String(i+1), text: `任务 ${i+1}` }));
const list = document.getElementById('list');
// 渲染
function render(items) {
list.innerHTML = '';
items.forEach((it, idx) => {
const li = document.createElement('li');
li.className = 'item';
li.setAttribute('role', 'option');
li.setAttribute('tabindex', '0');
li.dataset.id = it.id;
li.dataset.index = idx;
const handle = document.createElement('div');
handle.className = 'handle';
handle.setAttribute('aria-hidden', 'true');
handle.title = '拖拽手柄';
const label = document.createElement('div');
label.className = 'label';
label.textContent = it.text;
li.appendChild(handle);
li.appendChild(label);
list.appendChild(li);
});
}
render(data);
// 工具:不可变移动
function arrayMove(arr, from, to) {
const next = arr.slice();
const [m] = next.splice(from, 1);
next.splice(to, 0, m);
return next;
}
// FLIP:记录元素初始位(First)
function measurePositions(container) {
const map = new Map();
container.querySelectorAll('.item').forEach(el => {
map.set(el.dataset.id, el.getBoundingClientRect());
});
return map;
}
// FLIP:播放动画
function playFLIP(container, first) {
container.querySelectorAll('.item').forEach(el => {
const last = el.getBoundingClientRect();
const f = first.get(el.dataset.id);
if (!f) return;
const dx = f.left - last.left;
const dy = f.top - last.top;
if (dx || dy) {
el.style.transform = `translate(${dx}px, ${dy}px)`;
el.style.transition = 'none';
el.getBoundingClientRect(); // 强制 reflow
requestAnimationFrame(() => {
el.style.transition = 'transform .25s ease';
el.style.transform = '';
});
}
});
}
// 自动滚动
let rafScroll = null;
function autoScrollIfNeeded(container, pointerY, edge = 36, maxSpeed = 18) {
cancelAnimationFrame(rafScroll);
const rect = container.getBoundingClientRect();
let direction = 0;
if (pointerY - rect.top < edge) direction = -1;
else if (rect.bottom - pointerY < edge) direction = 1;
if (!direction) return;
const dist = direction < 0 ? (pointerY - rect.top) : (rect.bottom - pointerY);
const speed = Math.round((1 - Math.max(0, dist) / edge) * maxSpeed);
const step = () => {
container.scrollTop += direction * Math.max(1, speed);
rafScroll = requestAnimationFrame(step);
};
rafScroll = requestAnimationFrame(step);
}
// 拖拽状态
let dragging = null; // {id, startX, startY, offsetX, offsetY, node, ghost, placeholder}
let positions = null;
// 命中:半分判定,返回插入参考元素
function getInsertPosition(container, y, excludeEl) {
const items = [...container.querySelectorAll('.item')].filter(el => el !== excludeEl);
let nearest = null;
let offset = Number.NEGATIVE_INFINITY;
for (const el of items) {
const rect = el.getBoundingClientRect();
const diff = y - (rect.top + rect.height / 2);
if (diff < 0 && diff > offset) {
offset = diff;
nearest = el;
}
}
return nearest; // 返回要插在其前面的元素;如果为 null,则 append 到末尾
}
// 只允许在手柄上开始拖拽
list.addEventListener('pointerdown', (e) => {
const handle = e.target.closest('.handle');
if (!handle) return;
const item = e.target.closest('.item');
if (!item) return;
e.preventDefault();
item.setPointerCapture?.(e.pointerId);
const rect = item.getBoundingClientRect();
dragging = {
id: item.dataset.id,
node: item,
startX: e.clientX,
startY: e.clientY,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
width: rect.width,
height: rect.height,
originIndex: Number(item.dataset.index),
placeholder: null,
ghost: null
};
// A11y state
item.setAttribute('aria-grabbed', 'true');
// 创建占位
const ph = document.createElement('div');
ph.className = 'placeholder';
ph.style.height = rect.height + 'px';
dragging.placeholder = ph;
item.parentNode.insertBefore(ph, item.nextSibling);
// 记录 First
positions = measurePositions(list);
// 脱离文档流 & 创建 ghost
const ghost = item.cloneNode(true);
ghost.classList.add('ghost');
ghost.style.width = rect.width + 'px';
ghost.style.height = rect.height + 'px';
ghost.querySelector('.handle')?.remove(); // ghost 不需要手柄可交互
document.body.appendChild(ghost);
dragging.ghost = ghost;
// 原节点隐形但占位由 placeholder 负责
item.style.opacity = '0';
item.style.pointerEvents = 'none';
moveGhost(e.clientX, e.clientY);
});
function moveGhost(x, y) {
if (!dragging) return;
const gx = x - dragging.offsetX;
const gy = y - dragging.offsetY;
dragging.ghost.style.setProperty('--x', gx + 'px');
dragging.ghost.style.setProperty('--y', gy + 'px');
}
window.addEventListener('pointermove', (e) => {
if (!dragging) return;
moveGhost(e.clientX, e.clientY);
// 自动滚动
autoScrollIfNeeded(list, e.clientY);
// 预演插入
const ref = getInsertPosition(list, e.clientY, dragging.node);
if (ref) {
list.insertBefore(dragging.placeholder, ref);
} else {
list.appendChild(dragging.placeholder);
}
// FLIP:让其他项平滑过渡
playFLIP(list, positions);
});
window.addEventListener('pointerup', (e) => {
if (!dragging) return;
cancelAnimationFrame(rafScroll);
const items = [...list.querySelectorAll('.item')];
const phIndex = [...list.children].indexOf(dragging.placeholder);
const to = Math.max(0, phIndex - items.filter(n => n.compareDocumentPosition(dragging.placeholder) & Node.DOCUMENT_POSITION_FOLLOWING).length);
const from = dragging.originIndex;
// 更新数据(不可变)
const nextData = arrayMove(data, from, to);
data.splice(0, data.length, ...nextData); // 覆盖原数组内容
// 清理拖影/占位
dragging.node.style.opacity = '';
dragging.node.style.pointerEvents = '';
dragging.node.setAttribute('aria-grabbed', 'false');
dragging.placeholder.remove();
dragging.ghost.remove();
dragging = null;
// 重新渲染并播放 FLIP
const first = measurePositions(list);
render(data);
const lastPositions = first; // 旧 first 实际上是更新前,此处需重测
const newFirst = lastPositions; // 为讲解简化:先渲染再做 FLIP
playFLIP(list, newFirst);
// 更新 index 标记(便于下次 from)
[...list.querySelectorAll('.item')].forEach((el, i) => el.dataset.index = i);
// A11y 提示
list.setAttribute('aria-label', `排序已更新`);
});
// 键盘交互(↑/↓ 移动当前项)
list.addEventListener('keydown', (e) => {
const current = document.activeElement?.closest('.item');
if (!current) return;
const idx = Number(current.dataset.index);
if (e.key === 'ArrowDown') {
e.preventDefault();
if (idx < data.length - 1) {
const next = arrayMove(data, idx, idx + 1);
data.splice(0, data.length, ...next);
const first = measurePositions(list);
render(data);
playFLIP(list, first);
const focusEl = list.querySelector(`.item[data-index="${idx+1}"]`) || list.querySelectorAll('.item')[idx+1];
focusEl?.focus();
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (idx > 0) {
const next = arrayMove(data, idx, idx - 1);
data.splice(0, data.length, ...next);
const first = measurePositions(list);
render(data);
playFLIP(list, first);
const focusEl = list.querySelector(`.item[data-index="${idx-1}"]`) || list.querySelectorAll('.item')[idx-1];
focusEl?.focus();
}
}
});
})();
</script>
</body>
</html>
说明与要点:
-
仅允许从
.handle开始拖拽,降低误触。 -
使用 占位符 保持布局稳定。
-
FLIP 动画避免抖动;
transform不会触发布局重排。 -
自动滚动 只对
.list容器生效;如需页面滚动,可检测document.scrollingElement。 -
A11y:
role="listbox",键盘 ↑↓ 也能移动,aria-live给出状态提示(此处用aria-label简化示例)。
12.2 网格排序(中心点命中 + 吸附)
下面给出核心逻辑片段(在上面基础上替换命中判定):
function getNearestCell(container, point) {
// 返回最接近拖影中心点的 item
let best = null;
let bestDist = Infinity;
const items = [...container.querySelectorAll('.item')];
for (const el of items) {
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const dx = point.x - cx;
const dy = point.y - cy;
const dist = dx*dx + dy*dy;
if (dist < bestDist) {
bestDist = dist;
best = el;
}
}
return best;
}
// pointermove 中:
const center = { x: e.clientX, y: e.clientY };
const target = getNearestCell(gridContainer, center);
if (target && target !== dragging.node) {
// 根据 target 位置和半分判定决定插入 index
}
网格排序的插入规则:
计算 线性 index(行主序),例如
index = row * colCount + col。拖影中心指向的卡片为参考,若
center.x在其左半/上半则插前,否则插后。FLIP 动画同列表。
13. React 版:useSortable Hook 与组件
目标:在 React 中以“受控数据 + 可插拔事件”方式使用,保持 不可变更新 和 key 稳定。
13.1 Hook 最小实现
// useSortable.js
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
export function arrayMove(arr, from, to) {
const next = arr.slice();
const [m] = next.splice(from, 1);
next.splice(to, 0, m);
return next;
}
export function useSortable({ items, onChange, getId = it => it.id }) {
const ref = useRef(null);
const state = useRef({ dragging: null, positions: null });
const [_, force] = useState({});
const measure = useCallback(() => {
const el = ref.current;
const map = new Map();
el?.querySelectorAll('[data-id]').forEach(n => map.set(n.dataset.id, n.getBoundingClientRect()));
return map;
}, []);
const playFLIP = useCallback((first) => {
const el = ref.current;
el?.querySelectorAll('[data-id]').forEach(n => {
const last = n.getBoundingClientRect();
const f = first.get(n.dataset.id);
if (!f) return;
const dx = f.left - last.left;
const dy = f.top - last.top;
if (dx || dy) {
n.style.transform = `translate(${dx}px, ${dy}px)`;
n.style.transition = 'none';
n.getBoundingClientRect();
requestAnimationFrame(() => {
n.style.transition = 'transform .25s ease';
n.style.transform = '';
});
}
});
}, []);
useLayoutEffect(() => {
const root = ref.current;
if (!root) return;
const onPointerDown = (e) => {
const handle = e.target.closest('[data-handle]');
if (!handle) return;
const item = e.target.closest('[data-id]');
if (!item) return;
e.preventDefault();
const rect = item.getBoundingClientRect();
state.current.dragging = {
id: item.dataset.id, node: item,
startX: e.clientX, startY: e.clientY,
offsetX: e.clientX - rect.left, offsetY: e.clientY - rect.top,
width: rect.width, height: rect.height, originIndex: Number(item.dataset.index),
placeholder: null, ghost: null
};
item.setPointerCapture?.(e.pointerId);
item.setAttribute('aria-grabbed','true');
// placeholder
const ph = document.createElement('div');
ph.style.height = rect.height + 'px';
ph.className = 'placeholder';
state.current.dragging.placeholder = ph;
item.parentNode.insertBefore(ph, item.nextSibling);
state.current.positions = measure();
// ghost
const ghost = item.cloneNode(true);
ghost.style.cssText = `position:fixed;left:0;top:0;z-index:9999;pointer-events:none;transform:translate3d(${e.clientX - rect.left}px,${e.clientY - rect.top}px,0);width:${rect.width}px;height:${rect.height}px`;
ghost.querySelector('[data-handle]')?.remove();
document.body.appendChild(ghost);
state.current.dragging.ghost = ghost;
item.style.opacity = '0';
item.style.pointerEvents = 'none';
};
const onPointerMove = (e) => {
const d = state.current.dragging; if (!d) return;
d.ghost.style.transform = `translate3d(${e.clientX - d.offsetX}px,${e.clientY - d.offsetY}px,0)`;
// 半分判定
const refEl = getInsertPosition(root, e.clientY, d.node);
if (refEl) root.insertBefore(d.placeholder, refEl);
else root.appendChild(d.placeholder);
playFLIP(state.current.positions);
};
const onPointerUp = (e) => {
const d = state.current.dragging; if (!d) return;
const children = [...root.children];
const phIndex = children.indexOf(d.placeholder);
const to = phIndex - 1 < 0 ? 0 : phIndex; // 计算目标 index(可按需修正)
const from = d.originIndex;
d.node.style.opacity = '';
d.node.style.pointerEvents = '';
d.node.setAttribute('aria-grabbed','false');
d.placeholder.remove(); d.ghost.remove();
state.current.dragging = null;
onChange(arrayMove(items, from, to));
// 交由父组件重新渲染后,使用 FLIP
requestAnimationFrame(() => playFLIP(state.current.positions));
};
root.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
return () => {
root.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
};
}, [items, measure, playFLIP, onChange]);
return { ref };
}
// 辅助:半分判定
function getInsertPosition(container, y, excludeEl) {
const items = [...container.querySelectorAll('[data-id]')].filter(el => el !== excludeEl);
let nearest = null; let offset = Number.NEGATIVE_INFINITY;
for (const el of items) {
const rect = el.getBoundingClientRect();
const diff = y - (rect.top + rect.height / 2);
if (diff < 0 && diff > offset) { offset = diff; nearest = el; }
}
return nearest;
}
13.2 使用示例
// App.jsx
import { useState } from 'react';
import { useSortable } from './useSortable';
export default function App() {
const [items, setItems] = useState(
Array.from({length: 10}, (_, i) => ({ id: String(i+1), text: `条目 ${i+1}` }))
);
const { ref } = useSortable({ items, onChange: setItems });
return (
<div style={{ padding: 16 }}>
<h2>React 拖拽排序</h2>
<ul ref={ref} style={{ listStyle:'none', padding:0, width:300 }}>
{items.map((it, idx) => (
<li key={it.id} data-id={it.id} data-index={idx}
style={{ display:'flex', gap:8, background:'#f5f5f5', padding:'8px 10px', borderRadius:8, margin:'8px 0', transition:'transform .25s ease' }}
role="option" tabIndex={0}>
<span data-handle style={{ width:18, height:18, background:'#ccc', borderRadius:4, flex:'none', cursor:'grab' }}/>
<span style={{ flex:1 }}>{it.text}</span>
</li>
))}
</ul>
</div>
);
}
14. Vue 3 版:指令 v-sortable 与组件
14.1 指令版(更贴近 DOM)
<template>
<div class="wrap">
<h2>Vue 3 拖拽排序(指令)</h2>
<ul v-sortable="{ items, onChange: handleChange }" class="vlist">
<li v-for="(it, i) in items" :key="it.id" :data-id="it.id" :data-index="i" class="vitem" role="option" tabindex="0">
<span class="vhandle" data-handle></span>
<span class="vlabel">{{ it.text }}</span>
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
const items = ref(Array.from({length: 12}, (_,i) => ({ id: String(i+1), text: `任务 ${i+1}`})));
function handleChange(next) { items.value = next; }
</script>
<script>
function arrayMove(arr, from, to) {
const next = arr.slice();
const [m] = next.splice(from, 1);
next.splice(to, 0, m);
return next;
}
export default {
directives: {
sortable: {
mounted(el, binding) {
const { items, onChange } = binding.value;
const state = { dragging: null, positions: null };
function measure() {
const map = new Map();
el.querySelectorAll('[data-id]').forEach(n => map.set(n.dataset.id, n.getBoundingClientRect()));
return map;
}
function playFLIP(first) {
el.querySelectorAll('[data-id]').forEach(n => {
const last = n.getBoundingClientRect();
const f = first.get(n.dataset.id);
if (!f) return;
const dx = f.left - last.left;
const dy = f.top - last.top;
if (dx || dy) {
n.style.transform = `translate(${dx}px, ${dy}px)`;
n.style.transition = 'none';
n.getBoundingClientRect();
requestAnimationFrame(() => {
n.style.transition = 'transform .25s ease';
n.style.transform = '';
});
}
});
}
function getInsertPosition(container, y, excludeEl) {
const items = [...container.querySelectorAll('[data-id]')].filter(el => el !== excludeEl);
let nearest = null; let offset = Number.NEGATIVE_INFINITY;
for (const el of items) {
const rect = el.getBoundingClientRect();
const diff = y - (rect.top + rect.height / 2);
if (diff < 0 && diff > offset) { offset = diff; nearest = el; }
}
return nearest;
}
const onPointerDown = (e) => {
const handle = e.target.closest('[data-handle]');
if (!handle) return;
const item = e.target.closest('[data-id]');
if (!item) return;
e.preventDefault();
const rect = item.getBoundingClientRect();
state.dragging = {
id: item.dataset.id, node: item,
offsetX: e.clientX - rect.left, offsetY: e.clientY - rect.top,
originIndex: Number(item.dataset.index),
ghost: null, placeholder: null
};
item.setAttribute('aria-grabbed','true');
const ph = document.createElement('div');
ph.className = 'vplaceholder'; ph.style.height = rect.height + 'px';
state.dragging.placeholder = ph;
item.parentNode.insertBefore(ph, item.nextSibling);
state.positions = measure();
const ghost = item.cloneNode(true);
ghost.className += ' vghost';
ghost.style.cssText = `position:fixed;left:0;top:0;z-index:9999;pointer-events:none;transform:translate3d(${e.clientX - rect.left}px,${e.clientY - rect.top}px,0);width:${rect.width}px;height:${rect.height}px`;
ghost.querySelector('[data-handle]')?.remove();
document.body.appendChild(ghost);
state.dragging.ghost = ghost;
item.style.opacity = '0'; item.style.pointerEvents='none';
};
const onPointerMove = (e) => {
if (!state.dragging) return;
const d = state.dragging;
d.ghost.style.transform = `translate3d(${e.clientX - d.offsetX}px,${e.clientY - d.offsetY}px,0)`;
const refEl = getInsertPosition(el, e.clientY, d.node);
if (refEl) el.insertBefore(d.placeholder, refEl); else el.appendChild(d.placeholder);
playFLIP(state.positions);
};
const onPointerUp = (e) => {
if (!state.dragging) return;
const d = state.dragging;
const children = [...el.children];
const phIndex = children.indexOf(d.placeholder);
const to = phIndex; // 简化
const from = d.originIndex;
d.node.style.opacity=''; d.node.style.pointerEvents='';
d.node.setAttribute('aria-grabbed','false');
d.placeholder.remove(); d.ghost.remove();
state.dragging = null;
onChange(arrayMove(items.value ?? items, from, to));
requestAnimationFrame(() => playFLIP(state.positions));
};
el.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
el._cleanup = () => {
el.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
};
},
unmounted(el) { el._cleanup && el._cleanup(); }
}
}
}
</script>
<style scoped>
.wrap { padding: 16px; }
.vlist { list-style: none; padding: 0; width: 320px; }
.vitem { display: flex; gap: 8px; background: #f5f5f5; padding: 8px 10px; border-radius: 8px; margin: 8px 0; transition: transform .25s ease; }
.vhandle { width: 18px; height: 18px; background: #ccc; border-radius: 4px; cursor: grab; flex: none; }
.vplaceholder { height: 0; margin: 8px 0; background: rgba(0,125,250,.15); transition: height .2s ease; }
.vghost { opacity: 0.95; }
</style>
15. 与成熟库对比
-
SortableJS
-
上手简单、生态成熟、支持手柄/交换/多容器、PC 端友好。
-
移动端可用但细节不如完全自控;动画可自定但需要 Hook。
-
-
@dnd-kit(React)
-
现代、以 Pointer 为核心,扩展性强;专注于 React 生态;可组合传感器与碰撞检测。
-
学习曲线略高;网格/复杂布局需要自配策略。
-
-
VueDraggableNext(基于 Sortable)
-
Vue 生态首选之一;组合式 API 友好;事件齐全。
-
仍受 Sortable 能力边界影响。
-
何时用库? 快速交付/MVP;需求接近库默认能力;团队已有使用经验。
何时自研? 复杂动画、移动端体验要打磨、个性化碰撞/吸附、多容器联动、虚拟化。
16. 常见坑位 & Debug 清单
-
滚动穿透:拖拽时禁止页面滚动?移动端可给
body { touch-action: none; }或在拖拽时设置document.body.style.overflow = 'hidden'(注意副作用)。 -
选择文本:拖拽时可能选中文本,给容器加
user-select: none(仅拖拽期间)。 -
指针捕获:
setPointerCapture让移动过程中事件稳定派发到同一元素。 -
变换坐标系:祖先元素有
transform会影响position: fixed的参考系,ghost 可能“跟丢”,可改为position: absolute附着在最近定位容器。 -
高频测量:
getBoundingClientRect()开销不小,尽量在关键节点测量,不要每帧测量所有项。 -
过渡冲突:FLIP 前要
transition: none,下一帧再恢复。 -
触屏延迟:现代浏览器默认无 300ms 延迟;若遇老机型,使用
touch-action优化点击延迟。 -
可访问性:ARIA 提示文本过多会打断阅读;可使用
aria-live="polite"并节流播报。 -
禁用项:给不可拖拽项加
pointer-events: none的手柄,或在pointerdown阶段判断data-disabled。 -
SSR/水合:在 React/Vue 的 SSR 场景中,拖拽仅客户端启用,事件绑定放在
useEffect/onMounted。
17. 性能优化与大列表/虚拟化
-
虚拟列表:仅渲染可视区 + buffer;拖拽时需要“幽灵项”保持视觉连续;排序提交基于索引映射而非 DOM 顺序。
-
批量样式修改:把需要修改的元素收集后统一下一帧改样式,减少样式计算次数。
-
只测量必要项:常规只测量邻近项;或使用 空间索引(如 grid 每格缓存 rect)。
-
变更最小原则:只在占位符变化时才触发 FLIP;
requestAnimationFrame合帧。 -
合并自动滚动与移动:避免两个 rAF 互相竞争。
18. 扩展:多容器/嵌套排序/穿梭
-
多容器:每个容器维护自己的列表与边界;拖影跨容器时,切换“当前激活容器”;命中判定在容器矩形内进行。
-
嵌套:父列表与子列表使用不同层级的手柄与命中策略;阻止事件向上冒泡避免冲突。
-
穿梭(左右两列互相移动):当拖影中心进入另一个容器区域,创建新占位符并更新“当前容器指针”;松手后提交跨容器移动。
19. 单元测试与可维护性建议
-
核心算法可测:
-
arrayMove、命中判定(半分/中心点)、自动滚动计算速度函数。
-
-
事件模拟:在 JSDOM 或 Cypress 中模拟 pointer 事件序列(down → move → up)。
-
A11y:使用 jest-axe 或 Storybook a11y 插件检查基础问题。
-
解耦:将 测量、命中、动画、滚动 抽成独立模块,主逻辑组合调用。
-
类型化:使用 TypeScript 声明
DragState、Rect、Point、CollisionStrategy等类型。
20. 总结与落地清单
-
选型:PC 简单可用 HTML5 DnD;移动端与复杂动画用 Pointer 自研或 dnd-kit/SortableJS。
-
关键技术:占位符 + FLIP + 半分/中心点命中 + 自动滚动 + 手柄 + 轴向锁。
-
A11y/Keyboard:
role/tabindex/aria-live;支持 ↑↓ 或 Space/Enter 交互。 -
性能:只测量必要项;rAF 合帧;transform 动画;必要时上虚拟化。
-
工程化:把“测量/命中/动画/滚动”做成模块或 Hook/指令,单测覆盖算法层。
附:可直接复制使用的工具函数集合(TS/JS)
// types.ts
export type Id = string | number;
export interface Item { id: Id; [k: string]: any }
export interface Rect { top: number; left: number; width: number; height: number; }
export interface Point { x: number; y: number; }
export type Collision = (rects: Rect[], point: Point) => number; // 返回命中索引
// arrayMove.ts
export function arrayMove<T>(arr: T[], from: number, to: number) {
const next = arr.slice();
const [m] = next.splice(from, 1);
next.splice(to, 0, m);
return next;
}
// measure.ts
export function measureRects(nodes: Element[]): Rect[] {
return nodes.map(n => {
const r = n.getBoundingClientRect();
return { top: r.top, left: r.left, width: r.width, height: r.height };
});
}
// collisions.ts
export function halfSplitCollision(rects: Rect[], point: Point): number {
// 列表半分:找到第一个中线在 point.y 之上的项,插入其前
let target = rects.length;
for (let i = 0; i < rects.length; i++) {
const r = rects[i];
const mid = r.top + r.height / 2;
if (point.y < mid) { target = i; break; }
}
return target;
}
export function centerNearestCollision(rects: Rect[], point: Point): number {
// 网格:选择最靠近中心的项
let best = 0, bestDist = Infinity;
for (let i = 0; i < rects.length; i++) {
const r = rects[i];
const cx = r.left + r.width/2;
const cy = r.top + r.height/2;
const dx = point.x - cx;
const dy = point.y - cy;
const dist = dx*dx + dy*dy;
if (dist < bestDist) { bestDist = dist; best = i; }
}
return best;
}
最后
以上提供了从原理到工程实践的完整路径,以及 Vanilla/React/Vue 三套可落地代码。你可以直接把 Vanilla 版本保存为 HTML 文件试跑,再根据项目选型移植到 React/Vue。
如果你希望我根据你的实际 UI/数据结构定制一个可插拔的小库(含类型声明、完整注释、A11y 提示与自动滚动策略)
更多推荐
所有评论(0)