
一句话:页面内的拖拽不要用 HTML5 Drag and Drop API(移动端基本不可用、无法做视觉跟随),而应该用 Pointer Events +
setPointerCapture+touch-action自己实现——核心是「一个 pointerId 贯穿全程、捕获指针、把移动换算成transform」,再加「拖拽阈值、自动滚动、键盘替代」三件事。
一、三种方案:先排除掉错的那个
| 方案 | 机制 | 适合 | 致命限制 |
|---|---|---|---|
| HTML5 Drag & Drop | draggable + dragstart/dragover/drop + dataTransfer |
⭐ 文件上传、跨应用/跨窗口拖拽 | ⚠️ 移动端(iOS Safari)基本不支持;⚠️ 拖拽图像只是个「半透明快照」,无法做实时跟随与自适应布局;⚠️ 事件序列复杂(dragenter/dragleave 会因「穿过子元素」反复触发) |
| Pointer Events(推荐) | pointerdown/move/up/cancel + setPointerCapture |
⭐ 页面内拖拽、排序、调整尺寸、手势 | 要自己写「阈值、自动滚动、键盘替代」 |
| 库(dnd-kit / pragmatic-drag-and-drop / 已停止维护的 react-beautiful-dnd) | 封装了上面的一切 | 生产项目中的「复杂排序」 | 体积 + 抽象成本 |
⭐⭐⭐ 「为什么页面内排序不能用 HTML5 DnD」——三条硬伤:
① 【移动端不可用】
iOS Safari 对 `draggable` 的支持基本等于没有(需要 polyfill,而 polyfill 又不可靠)。
⭐ 而移动端恰恰是「排序/拖拽」需求最多的地方。
② 【视觉无法控制】
HTML5 DnD 拖的是「浏览器生成的半透明快照」——
你不能让「拖拽中的元素」跟随指针做位移/缩放,也不能「实时重排周围元素」。
⭐ 而现代拖拽体验的核心恰恰是「实时反馈」。
③ 【事件序列烦人】
`dragenter`/`dragleave` 会因为「指针穿过子元素」而反复触发,
要写一堆「判断是否真的离开」的逻辑。
✅ 结论:
· 「上传文件 / 从桌面拖入」→ 用 HTML5 DnD(它才有 dataTransfer 与 OS 集成)
· 「页面内排序 / 拖动面板 / 调整尺寸」→ 用 **Pointer Events**
二、Pointer Events:三个必须理解的东西
⭐⭐⭐ ① 【`setPointerCapture`——拖拽不丢的关键】
el.setPointerCapture(e.pointerId)
作用:把「这个 pointerId 的后续事件」**全部重定向到 el**——
即使指针已经移出 el(甚至移出窗口),`pointermove`/`pointerup` 仍然发给 el。
⭐ 没有它,你会遇到「鼠标移出元素后拖拽就断了」。
✅ 它会在 `pointerup`/`pointercancel` 时**自动释放**(也可以手动 `releasePointerCapture`)。
⚠️ 一个元素同一时间只能捕获「一个 pointerId」(多点触控时要注意)。
⭐⭐⭐ ② 【`touch-action`——让「拖拽」与「滚动」不打架】
.draggable { touch-action: none; }
作用:告诉浏览器「**这个元素上的默认手势(滚动/缩放/双击放大)由我接管**」。
⚠️⚠️ 必须在 **CSS 里静态声明**(不能在 `pointerdown` 里再设)——
因为「用户手指一动,滚动就已经开始了」,此时再设已经晚了。
⚠️ `touch-action: none` 会让「在这个元素上的滚动完全失效」→ 要谨慎:
· 整块可拖区域设 `none` → 用户在它上面就不能滚页面(体验差)
· ✅ 更稳的做法:**只给「拖拽手柄」设 `none`**(如列表项右侧的拖拽图标)
· 或按方向限制:`touch-action: pan-y`(只允许垂直滚动,水平拖拽交给自己)
⭐⭐⭐ ③ 【`pointercancel`——必须处理】
系统会在「它要接管这次交互」时发 `pointercancel`(如:滚动开始、
来电、系统手势、浏览器切换标签)。
⭐ 收到它必须「当作 pointerup 处理」并清理状态;
⚠️ 不处理的话,你的拖拽状态会「卡死」(元素一直跟着鼠标/一直高亮)。
// ✅ Pointer Events 的完整生命周期(含「阈值」与「取消」)
interface DragCallbacks {
onStart?: (info: PointerInfo) => void;
onMove?: (info: PointerInfo) => void;
onEnd?: (info: PointerInfo, cancelled: boolean) => void;
}
interface PointerInfo {
pointerId: number;
/** 相对「按下点」的位移 */
dx: number;
dy: number;
/** 当前指针位置(视口坐标) */
clientX: number;
clientY: number;
pointerType: string;
/** 原始事件(可能需要 preventDefault) */
event: PointerEvent;
}
function startDrag(el: HTMLElement, e: PointerEvent, callbacks: DragCallbacks, threshold = 5) {
const startX = e.clientX;
const startY = e.clientY;
const pointerId = e.pointerId;
let started = false; // ⭐ 是否已越过阈值(越过才算「真的在拖」)
let cancelled = false;
// ⭐ 捕获指针(之后事件都发给 el)
el.setPointerCapture(pointerId);
const info = (ev: PointerEvent): PointerInfo => ({
pointerId,
dx: ev.clientX - startX,
dy: ev.clientY - startY,
clientX: ev.clientX,
clientY: ev.clientY,
pointerType: ev.pointerType,
event: ev,
});
const onMove = (ev: PointerEvent) => {
if (ev.pointerId !== pointerId) return; // ⭐ 多点触控:只认自己的那个
const d = info(ev);
if (!started) {
// ⭐ 阈值:避免「想点击却触发了拖拽」
if (Math.hypot(d.dx, d.dy) < threshold) return;
started = true;
callbacks.onStart?.(d);
}
callbacks.onMove?.(d);
};
const cleanup = (ev: PointerEvent, isCancelled: boolean) => {
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onCancel);
if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId);
if (started) callbacks.onEnd?.(info(ev), isCancelled);
};
const onUp = (ev: PointerEvent) => { if (ev.pointerId === pointerId) cleanup(ev, false); };
const onCancel = (ev: PointerEvent) => { cancelled = true; cleanup(ev, true); }; // ⭐ 必须处理
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onCancel);
return () => { if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId); };
}
三、完整实现(1):useDrag(单元素拖拽,200 行)
// use-drag.ts —— 可拖拽(含边界限制、键盘、无障碍)
import { useCallback, useRef, useState, useEffect } from 'react';
export interface Position { x: number; y: number }
export interface UseDragOptions {
/** 初始位置 */
initial?: Position;
/** 拖拽前必须移动的像素(避免与「点击」冲突) */
threshold?: number;
/** 限制在容器内 */
bounds?: { left: number; top: number; right: number; bottom: number } | (() => DOMRect | null);
/** 是否用「受控」位置(不传则内部维护) */
position?: Position;
onPositionChange?: (pos: Position, info: { phase: 'start' | 'move' | 'end'; cancelled: boolean }) => void;
disabled?: boolean;
}
/** ⭐ 一个「可拖拽 + 可键盘操作」的元素 */
export function useDrag(options: UseDragOptions = {}) {
const {
initial = { x: 0, y: 0 },
threshold = 5,
bounds,
position: controlledPosition,
onPositionChange,
disabled = false,
} = options;
const [innerPosition, setInnerPosition] = useState(initial);
const position = controlledPosition ?? innerPosition;
const ref = useRef(null);
const stateRef = useRef<{
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
started: boolean;
lastValid: Position;
} | null>(null);
const [dragging, setDragging] = useState(false);
const resolveBounds = useCallback((): { left: number; top: number; right: number; bottom: number } | null => {
if (!bounds) return null;
if (typeof bounds === 'function') {
const rect = bounds();
if (!rect) return null;
return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
}
return bounds;
}, [bounds]);
/** 把「目标位置」钳制到边界内 */
const clamp = useCallback((pos: Position): Position => {
const b = resolveBounds();
const el = ref.current;
if (!b || !el) return pos;
const elRect = el.getBoundingClientRect();
const width = elRect.width;
const height = elRect.height;
// ⭐ 用「元素的左上角」做约束(简化模型:假设元素位置就是 transform 位移)
return {
x: Math.min(Math.max(pos.x, b.left), b.right - width),
y: Math.min(Math.max(pos.y, b.top), b.bottom - height),
};
}, [resolveBounds]);
const commit = useCallback(
(pos: Position, phase: 'start' | 'move' | 'end', cancelled: boolean) => {
const clamped = clamp(pos);
if (controlledPosition) onPositionChange?.(clamped, { phase, cancelled });
else {
setInnerPosition(clamped);
onPositionChange?.(clamped, { phase, cancelled });
}
},
[clamp, controlledPosition, onPositionChange]
);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
if (disabled) return;
if (e.button !== 0 && e.pointerType === 'mouse') return; // ⭐ 只处理左键(鼠标)
const el = ref.current;
if (!el) return;
// ⭐ 捕获指针(事件重定向到 el,移出元素也不丢)
el.setPointerCapture(e.pointerId);
stateRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
originX: position.x,
originY: position.y,
started: false,
lastValid: position,
};
},
[disabled, position.x, position.y]
);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
const s = stateRef.current;
if (!s || e.pointerId !== s.pointerId) return;
const dx = e.clientX - s.startX;
const dy = e.clientY - s.startY;
if (!s.started) {
if (Math.hypot(dx, dy) < threshold) return; // ⭐ 阈值
s.started = true;
setDragging(true);
commit({ x: s.originX + dx, y: s.originY + dy }, 'start', false);
return;
}
const clamped = clamp({ x: s.originX + dx, y: s.originY + dy });
s.lastValid = clamped;
commit(clamped, 'move', false);
},
[threshold, commit, clamp]
);
const finish = useCallback(
(cancelled: boolean) => {
const s = stateRef.current;
if (!s) return;
const el = ref.current;
if (el && el.hasPointerCapture(s.pointerId)) el.releasePointerCapture(s.pointerId);
if (s.started) {
commit(cancelled ? s.lastValid : s.lastValid, 'end', cancelled);
setDragging(false);
}
stateRef.current = null;
},
[commit]
);
const onPointerUp = useCallback((e: React.PointerEvent) => {
const s = stateRef.current;
if (s && e.pointerId === s.pointerId) finish(false);
}, [finish]);
const onPointerCancel = useCallback((e: React.PointerEvent) => {
const s = stateRef.current;
if (s && e.pointerId === s.pointerId) finish(true); // ⭐ 系统接管
}, [finish]);
/** ⭐ 键盘操作(无障碍:WCAG 要求「拖拽必须有键盘替代」) */
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (disabled) return;
const step = e.shiftKey ? 20 : 2; // ⭐ Shift 加速
let next: Position | null = null;
switch (e.key) {
case 'ArrowLeft': next = { x: position.x - step, y: position.y }; break;
case 'ArrowRight': next = { x: position.x + step, y: position.y }; break;
case 'ArrowUp': next = { x: position.x, y: position.y - step }; break;
case 'ArrowDown': next = { x: position.x, y: position.y + step }; break;
case 'Home': next = { x: (resolveBounds()?.left ?? 0), y: position.y }; break;
default: return;
}
e.preventDefault(); // ⭐ 阻止页面滚动
commit(next, 'move', false);
commit(next, 'end', false);
},
[disabled, position, commit, resolveBounds]
);
/** ⭐ 返回「展开到元素上的 props」 */
const dragHandleProps = {
ref,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onKeyDown,
tabIndex: disabled ? -1 : 0, // ⭐ 可聚焦(键盘可用)
role: 'button' as const,
'aria-roledescription': '可拖拽元素',
'aria-label': '可用方向键移动位置',
style: {
// ⭐⭐ 关键:让浏览器把「这个元素上的手势」交给我们(必须在 CSS 里静态声明)
touchAction: 'none',
cursor: dragging ? 'grabbing' : 'grab',
// ⭐ 用 transform 做位移(只走合成,见 5.17)
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
userSelect: 'none' as const,
willChange: dragging ? 'transform' : undefined,
} as React.CSSProperties,
};
return { dragHandleProps, position, dragging, setPosition: setInnerPosition };
}
// ✅ 用法:一个「可拖拽 + 边界限制 + 键盘可操作」的卡片
export function DraggableCard() {
const containerRef = useRef(null);
const { dragHandleProps, position, dragging } = useDrag({
initial: { x: 24, y: 24 },
// ⭐ 边界用函数(每次拖拽时实时读取容器尺寸)
bounds: () => containerRef.current?.getBoundingClientRect() ?? null,
});
return (
拖我(或用方向键)
x {position.x.toFixed(0)} / y {position.y.toFixed(0)}
);
}
四、完整实现(2):useSortable(列表排序,260 行)
// use-sortable.ts —— 垂直列表排序(含自动滚动 + 键盘替代)
import { useCallback, useRef, useState } from 'react';
interface SortableOptions {
/** 当前顺序的 id 列表(受控) */
ids: string[];
onReorder: (nextIds: string[]) => void;
/** 拖拽手柄的选择器(没有手柄则整项可拖) */
handleSelector?: string;
/** 触发自动滚动的边缘距离 */
autoScrollEdge?: number;
/** 自动滚动最大速度(px/帧) */
autoScrollMaxSpeed?: number;
disabled?: boolean;
}
export function useSortable(options: SortableOptions) {
const {
ids, onReorder,
handleSelector,
autoScrollEdge = 48,
autoScrollMaxSpeed = 12,
disabled = false,
} = options;
const containerRef = useRef(null);
const [activeId, setActiveId] = useState(null);
const [overIndex, setOverIndex] = useState(-1);
/** 拖拽内部状态 */
const stateRef = useRef<{
pointerId: number;
activeId: string;
startY: number;
itemRects: Array<{ id: string; top: number; height: number }>;
currentIndex: number;
started: boolean;
scrollRaf: number;
lastClientY: number;
} | null>(null);
/** ⭐ 记录「拖拽开始时」所有项的位置(后续用「指针 Y 落在哪一项的区间」判断目标位置) */
const captureItemRects = useCallback(() => {
const container = containerRef.current;
if (!container) return [];
// ⭐ 批量读(避免布局抖动,见 1.14)
const items = Array.from(container.querySelectorAll('[data-sortable-item]'));
const rects = items.map((el) => {
const r = el.getBoundingClientRect();
return { id: el.dataset.sortableItem!, top: r.top, height: r.height };
});
return rects;
}, []);
/** ⭐ 自动滚动:指针靠近容器边缘时滚动容器 */
const ensureAutoScroll = useCallback((clientY: number) => {
const state = stateRef.current;
const container = containerRef.current;
if (!state || !container) return;
state.lastClientY = clientY;
if (state.scrollRaf) return; // 已在滚动中
const tick = () => {
const s = stateRef.current;
const c = containerRef.current;
if (!s || !c) { return; }
const rect = c.getBoundingClientRect();
const distTop = s.lastClientY - rect.top;
const distBottom = rect.bottom - s.lastClientY;
let delta = 0;
if (distTop < autoScrollEdge) {
delta = -Math.round(((autoScrollEdge - distTop) / autoScrollEdge) * autoScrollMaxSpeed);
} else if (distBottom < autoScrollEdge) {
delta = Math.round(((autoScrollEdge - distBottom) / autoScrollEdge) * autoScrollMaxSpeed);
}
if (delta !== 0) {
c.scrollTop += delta;
updateOverIndex(s.lastClientY); // ⭐ 滚动后要重新算目标位置
s.scrollRaf = requestAnimationFrame(tick);
} else {
s.scrollRaf = 0;
}
};
state.scrollRaf = requestAnimationFrame(tick);
}, [autoScrollEdge, autoScrollMaxSpeed]);
/** ⭐ 根据「指针的 Y 坐标」算出「应该插入到第几项」 */
const updateOverIndex = useCallback((clientY: number) => {
const s = stateRef.current;
const container = containerRef.current;
if (!s || !container) return;
// ⭐ 关键:拖拽会改变 DOM,所以「每次都用『当前 DOM 的 rect』重算」
const items = Array.from(container.querySelectorAll('[data-sortable-item]'));
const rects = items.map((el) => el.getBoundingClientRect());
let index = rects.length;
for (let i = 0; i < rects.length; i++) {
const r = rects[i]!;
// ⭐ 用「项的中心线」作为判据(比「边界」更符合直觉)
if (clientY < r.top + r.height / 2) { index = i; break; }
}
if (index !== s.currentIndex) {
s.currentIndex = index;
setOverIndex(index);
// ⭐ 实时重排(让用户看到「拖到哪儿」)
const without = ids.filter((id) => id !== s.activeId);
const next = [...without.slice(0, index), s.activeId, ...without.slice(index)];
if (next.join(',') !== ids.join(',')) onReorder(next);
}
}, [ids, onReorder]);
const onPointerDown = useCallback(
(e: React.PointerEvent, id: string) => {
if (disabled) return;
if (e.button !== 0 && e.pointerType === 'mouse') return;
const itemEl = (e.target as HTMLElement).closest('[data-sortable-item]');
if (!itemEl) return;
// ⭐ 如果配了「手柄」,只有从手柄按下才算拖拽
if (handleSelector && !(e.target as HTMLElement).closest(handleSelector)) return;
itemEl.setPointerCapture(e.pointerId);
stateRef.current = {
pointerId: e.pointerId,
activeId: id,
startY: e.clientY,
itemRects: captureItemRects(),
currentIndex: ids.indexOf(id),
started: false,
scrollRaf: 0,
lastClientY: e.clientY,
};
setActiveId(id);
},
[disabled, handleSelector, captureItemRects, ids]
);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
const s = stateRef.current;
if (!s || e.pointerId !== s.pointerId) return;
if (!s.started) {
if (Math.abs(e.clientY - s.startY) < 4) return; // ⭐ 阈值(纵向)
s.started = true;
}
updateOverIndex(e.clientY);
ensureAutoScroll(e.clientY);
},
[updateOverIndex, ensureAutoScroll]
);
const finish = useCallback((_cancelled: boolean) => {
const s = stateRef.current;
if (!s) return;
const container = containerRef.current;
if (container) {
const el = container.querySelector(`[data-sortable-item="${CSS.escape(s.activeId)}"]`);
if (el?.hasPointerCapture(s.pointerId)) el.releasePointerCapture(s.pointerId);
}
if (s.scrollRaf) cancelAnimationFrame(s.scrollRaf);
stateRef.current = null;
setActiveId(null);
setOverIndex(-1);
}, []);
const onPointerUp = useCallback((e: React.PointerEvent) => {
const s = stateRef.current;
if (s && e.pointerId === s.pointerId) finish(false);
}, [finish]);
const onPointerCancel = useCallback((e: React.PointerEvent) => {
const s = stateRef.current;
if (s && e.pointerId === s.pointerId) finish(true); // ⭐ 系统接管(如滚动开始)
}, [finish]);
/** ⭐ 键盘替代:Space 拾起 → 方向键移动 → Space 放下(WCAG 要求) */
const [keyboardGrabbed, setKeyboardGrabbed] = useState(null);
const onKeyDown = useCallback(
(e: React.KeyboardEvent, id: string) => {
if (disabled) return;
const index = ids.indexOf(id);
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setKeyboardGrabbed((cur) => (cur === id ? null : id));
return;
}
if (keyboardGrabbed !== id) return;
let nextIndex = -1;
if (e.key === 'ArrowUp') nextIndex = Math.max(0, index - 1);
else if (e.key === 'ArrowDown') nextIndex = Math.min(ids.length - 1, index + 1);
else return;
e.preventDefault();
if (nextIndex === index) return;
const next = ids.slice();
next.splice(index, 1);
next.splice(nextIndex, 0, id);
onReorder(next);
// ⭐ 移动后保持焦点(并把焦点移到新位置的元素上)
requestAnimationFrame(() => {
const el = containerRef.current?.querySelector(
`[data-sortable-item="${CSS.escape(id)}"]`
);
el?.focus();
});
},
[disabled, ids, keyboardGrabbed, onReorder]
);
/** 给「每一项」展开的 props */
const getItemProps = useCallback(
(id: string) => {
const index = ids.indexOf(id);
const isActive = activeId === id;
return {
'data-sortable-item': id,
tabIndex: disabled ? -1 : 0,
role: 'option' as const,
'aria-selected': isActive,
'aria-grabbed': keyboardGrabbed === id, // ⭐ 旧 API,但被广泛支持
'aria-posinset': index + 1,
'aria-setsize': ids.length,
onPointerDown: (e: React.PointerEvent) => onPointerDown(e, id),
onPointerMove,
onPointerUp,
onPointerCancel,
onKeyDown: (e: React.KeyboardEvent) => onKeyDown(e, id),
style: {
// ⚠️ 整项设 none 会让「在这个列表项上的滚动失效」→ 有手柄时只给手柄设
touchAction: handleSelector ? 'auto' : ('none' as const),
userSelect: 'none' as const,
cursor: isActive ? 'grabbing' : 'grab',
// ⭐ 拖拽中的项「抬起」(视觉反馈,只改 transform/box-shadow)
transform: isActive ? 'scale(1.02)' : undefined,
boxShadow: isActive ? '0 8px 24px rgba(0,0,0,.18)' : undefined,
zIndex: isActive ? 10 : undefined,
position: isActive ? ('relative' as const) : undefined,
} as React.CSSProperties,
};
},
[ids, activeId, keyboardGrabbed, disabled, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onKeyDown, handleSelector]
);
/** 给「手柄」展开的 props(有手柄时,只有手柄触发拖拽) */
const getHandleProps = useCallback(
(id: string) => ({
'data-sortable-handle': true,
'aria-hidden': true,
style: {
// ⭐⭐ 手柄上必须设 touch-action: none(否则手指一动就变成滚动)
touchAction: 'none' as const,
cursor: 'grab',
userSelect: 'none' as const,
} as React.CSSProperties,
onPointerDown: (e: React.PointerEvent) => onPointerDown(e, id),
onPointerMove,
onPointerUp,
onPointerCancel,
}),
[onPointerDown, onPointerMove, onPointerUp, onPointerCancel]
);
return {
containerRef,
activeId,
overIndex,
keyboardGrabbed,
getItemProps,
getHandleProps,
/** ⭐ 容器 props */
containerProps: {
ref: containerRef,
role: 'listbox' as const,
'aria-label': '可排序列表(拖拽或用空格 + 方向键调整顺序)',
style: { touchAction: handleSelector ? 'auto' : ('none' as const) } as React.CSSProperties,
},
};
}
// ✅ 用法:可排序列表(配合 FLIP 动画,见 1.15)
export function SortableDemo() {
const [ids, setIds] = useState(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']);
const labels: Record = {
a: '苹果', b: '香蕉', c: '橙子', d: '西瓜', e: '葡萄', f: '草莓', g: '芒果', h: '荔枝',
};
const sortable = useSortable({
ids,
onReorder: setIds,
handleSelector: '[data-sortable-handle]', // ⭐ 用「手柄」拖拽,列表本身仍可滚动
});
const flipRef = useFlip(ids.join(',')); // FLIP 让「重排」有过渡
return (
{
sortable.containerRef.current = node; // ⭐ 同时满足 useSortable 与 useFlip
flipRef.current = node;
}}
>
{ids.map((id) => (
{/* ⭐ 拖拽手柄(只有它设 touch-action: none) */}
⠿
{labels[id]}
{/* ⭐ 大字号的按钮替代方案(键盘/无障碍最省心的做法) */}
))}
);
}
⭐⭐⭐ 这个排序实现里有五个「必须做对」的点:
① 【拖拽开始时「记录一次」所有项的位置,之后实时重算】
- 拖拽中「DOM 会被重排」,所以每次算「目标位置」都必须
用「当前 DOM 的 rect」(而不是「开始时缓存的那份」)
- 用「项的中心线」判断插入位置(比「边界」更符合直觉)
② 【实时重排(边拖边看)】
⭐ 用户拖到哪儿,列表就立刻重排到哪儿 → 这才是现代拖拽体验。
(配合 FLIP 动画,重排本身也是平滑的,见 1.15)
③ 【自动滚动】
指针靠近容器上下边缘时按距离线性调速(`scrollTop += delta`),
⭐ 且「滚动后要重新计算目标位置」(否则滚动时目标不动 → 抖)。
④ 【键盘替代(WCAG 要求)】
「空格拾起 → 方向键移动 → 空格放下」,⭐ 并提供「上移/下移按钮」
(按钮是最省心、最容易被屏幕阅读器理解的方案)。
⑤ 【`touch-action` 的取舍】
整项设 `none` → 用户在列表项上不能滚动页面(体验差);
⭐ 所以「**用拖拽手柄**,只给手柄设 `none`」是最佳实践。
五、它的坑
// ① 用 HTML5 DnD 做「页面内排序」(移动端直接不可用 + 无法视觉跟随)
// ❌ 用于排序
// ✅ Pointer Events
// ② 忘了 setPointerCapture(鼠标移出元素后拖拽就断)
el.addEventListener('pointermove', ...); // ⚠️ 只在元素内有效
// ✅ el.setPointerCapture(e.pointerId)
// ③ 忘了在 CSS 里静态声明 touch-action(第一次触摸变成滚动)
el.style.touchAction = 'none'; // 在 pointerdown 里设置 // ❌ 太晚
// ✅ CSS: .draggable { touch-action: none; }
// ④ 整块拖拽区域设 touch-action: none(用户不能滚动页面)
// ✅ 只给「拖拽手柄」设 none
// ⑤ 不处理 pointercancel(状态卡死:元素一直跟着鼠标 / 一直高亮)
// ✅ pointercancel 当作 pointerup 处理并清理
// ⑥ 没有拖拽阈值(想点击却触发了拖拽)
pointerdown → 立刻进入拖拽态 // ⚠️
// ✅ 移动超过 5~10px 才认为「真的在拖」
// ⑦ 多点触控时「多个 pointerId 混用」
// ⚠️ 第二根手指按下会覆盖状态
// ✅ 每个事件都判断 `e.pointerId === myPointerId`
// ⑧ 用 `mousedown/mousemove`(移动端无效、且不支持笔/触控)
// ✅ Pointer Events(统一鼠标/触摸/笔)
// ⑨ 用 `left/top` 做拖拽位移(每帧重排)
el.style.left = `${x}px`; // ❌
// ✅ `transform: translate3d(...)`
// ⑩ 拖拽中「每次 pointermove 都读 rect」(布局抖动 + 掉帧)
onPointerMove = (e) => { const r = el.getBoundingClientRect(); ... } // ⚠️ 高频
// ✅ 拖拽开始时「缓存读数」,移动中只做数学计算
// ⑪ 自动滚动的 rAF 没有取消(滚动停不下来)
// ✅ finish 时 `cancelAnimationFrame`
// ⑫ 滚动了但「目标位置没重算」(拖拽时元素抖动 / 位置不动)
// ✅ 滚动后重新计算目标 index
// ⑬ 拖拽中「改变了元素尺寸」(如加了 padding/border)导致测量错位
// ⚠️ transform 不影响布局,所以「用 transform 做位移」是最稳的
// ✅ 视觉反馈(缩放/阴影)也用「不影响布局」的属性(transform/box-shadow/opacity)
// ⑭ 忘了「阻止文本选中」(拖拽时选中一堆文字)
// ✅ `user-select: none`
// ⑮ 拖拽结束时「没有 settle 到最终位置」(元素停在半个位置上)
// ✅ `end` 阶段把位置对齐到「目标项的位置」(列表排序)或「合法边界」(自由拖拽)
// ⑯ 没有「拒绝无效放置」的反馈(拖到不允许的区域也默默放下)
// ✅ 高亮「可放置区域」/ 用 `not-allowed` 光标 / 回弹动画
// ⑰ 拖拽与「滚动容器」冲突(在可滚动的列表里拖拽很别扭)
// ⚠️ 长按 500ms 才进入拖拽(移动端惯例)可以缓解
// ✅ 或用「拖拽手柄」明确区分「拖这段是拖拽,其余地方是滚动」
// ⑱ 没有「长按」判定(移动端:用户想滚动列表,结果开始拖拽)
// ✅ 移动端可加「长按 300~500ms 后进入拖拽」
// 或用「手柄模式」(最稳,推荐)
// ⑲ 无障碍缺失(只用鼠标拖拽 → 键盘/屏幕阅读器用户无法排序)
// ⭐ WCAG 要求「拖拽操作必须有替代方式」
// ✅ 「空格拾起 + 方向键移动 + 空格放下」+ 「上移/下移按钮」
// + `aria-roledescription="可拖拽元素"` + 操作后播报(`aria-live`)
// ⑳ 拖拽中的「焦点/滚动位置」丢失
// ⚠️ DOM 重排后焦点可能跑到 body
// ✅ 重排后 `el.focus()`(本实现里做了)
// ㉑ iframe / 跨域 场景下「指针事件被 iframe 截获」
// ⚠️ 指针移入 iframe 后,父页面收不到 pointermove
// ✅ 在拖拽期间给 iframe 加 `pointer-events: none` 覆盖层
// ㉒ 拖拽「跨出窗口」后事件丢失(指针出了浏览器)
// ⭐ `setPointerCapture` 只能捕获「同一个文档内」的移动;
// 跨出窗口时依赖 `pointerup` 前的最后一次位置
// ✅ 监听 `window.blur` 或加「离开窗口即结束拖拽」的兜底
// ㉓ 用 `transform: scale()` 做「抬起」反馈但影响了测量
// ⚠️ `getBoundingClientRect` 会包含 transform 的缩放 → 后续测量被污染
// ✅ 「抬起」用 box-shadow + z-index(不改变几何),或把 scale 放在内层元素
// ㉔ 拖拽时「页面其他地方也在响应」(比如 hover 效果乱闪)
// ✅ 拖拽进行中给 body 加一个类(`body.is-dragging * { pointer-events: none }`
// 或 `body.is-dragging { cursor: grabbing }`)
// ⑳ 的完整处理(重排后恢复焦点 + 播报)
function announce(message: string) {
let el = document.getElementById('a11y-live');
if (!el) {
el = document.createElement('div');
el.id = 'a11y-live';
el.setAttribute('aria-live', 'polite');
el.setAttribute('aria-atomic', 'true');
Object.assign(el.style, {
position: 'absolute', width: 1, height: 1, overflow: 'hidden',
clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap',
});
document.body.appendChild(el);
}
el.textContent = message;
}
// 移动后:
onReorder(next);
announce(`已移动到第 ${nextIndex + 1} 位,共 ${next.length} 项`);
// ㉑㉒ 的完整处理(拖拽期间「屏蔽 iframe」+「跨窗口兜底」)
useEffect(() => {
if (!activeId) return;
// ⭐ 拖拽期间把页面里的 iframe 设为「不接收指针事件」
const iframes = Array.from(document.querySelectorAll('iframe'));
iframes.forEach((f) => f.style.setProperty('pointer-events', 'none', 'important'));
document.body.classList.add('is-dragging');
// ⭐ 窗口失焦 → 结束拖拽(避免「状态卡死」)
const onBlur = () => finish(true);
window.addEventListener('blur', onBlur);
return () => {
iframes.forEach((f) => f.style.removeProperty('pointer-events'));
document.body.classList.remove('is-dragging');
window.removeEventListener('blur', onBlur);
};
}, [activeId, finish]);
/* CSS */
// body.is-dragging { cursor: grabbing; user-select: none; }
// body.is-dragging * { cursor: grabbing !important; }
六、面试延伸
- 「页面内的拖拽为什么不用 HTML5 Drag and Drop API?」
三条硬伤:① ⭐ 移动端(iOS Safari)基本不支持——而「排序/拖拽」的需求恰恰在移动端最多(需要 polyfill,而 polyfill 不可靠);② ⭐ 视觉无法控制——HTML5 DnD 拖的是「浏览器生成的半透明快照」,你不能让拖拽中的元素跟随指针做位移/缩放,也不能实时重排周围元素,而现代拖拽体验的核心正是「实时反馈」;③ 事件序列烦人——dragenter/dragleave 会因「指针穿过子元素」反复触发,要写一堆「是否真的离开」的判断。结论:「文件上传 / 从桌面拖入 / 跨应用拖放」用 HTML5 DnD(它才有 dataTransfer 与 OS 集成),「页面内排序 / 拖动面板 / 调整尺寸」用 Pointer Events。
- 「
setPointerCapture 有什么用?」
它把「某个 pointerId 的后续事件全部重定向到该元素」——即使指针已经移出这个元素(甚至移出窗口),pointermove/pointerup 仍然发给它。⭐ 没有它,就会遇到「鼠标移出元素后拖拽直接断掉」这个经典问题(因为事件不再发给你了)。用法:在 pointerdown 里 el.setPointerCapture(e.pointerId);它在 pointerup/pointercancel 时自动释放(也可手动 releasePointerCapture)。⚠️ 一个元素同一时间只能捕获一个 pointerId(多点触控要自己管理)。
- 「
touch-action 为什么必须在 CSS 里静态声明?」
因为 touch-action 决定的是「浏览器是否处理该元素上的默认手势(滚动/缩放)」——用户在触摸屏上「手指一动」,浏览器就立刻决定「这是滚动还是交给页面」。如果你在 pointerdown 里再设置,已经晚了(滚动已经开始,pointercancel 已经发出)。⭐ 所以必须静态写在 CSS 里。另一个关键取舍:touch-action: none 会让「在这个元素上的滚动完全失效」→ 如果整块可拖区域都设 none,用户在它上面就不能滚页面(体验差)。✅ 最佳实践是「用拖拽手柄,只给手柄设 none」,或按方向限制(touch-action: pan-y 只允许垂直滚动)。
- 「
pointercancel 是什么?为什么必须处理?」
它表示「系统要接管这次交互」——典型场景:浏览器判定为滚动、来电、系统手势(如从边缘滑动)、切到别的标签页、指针设备断开。⭐ 必须当作 pointerup 处理(清理拖拽状态、释放捕获、复位样式);不处理的后果是「状态卡死」——元素一直跟着鼠标、一直高亮、一直禁止滚动。实践中:把 pointercancel 与 pointerup 指向同一个 finish(cancelled) 函数,并在里面区分「是否已真正开始拖拽」。
- 「拖拽为什么要设阈值?」
为了区分「点击」与「拖拽」:如果不设阈值,用户「想点一下」但手指/鼠标抖了 1px,就会进入拖拽态——于是「点击事件被吞掉」「元素开始跟着动」。常见做法是「移动超过 5~10px 才认为真的在拖」(触摸端因为手指抖动更大,可以用更大阈值,或改用长按 300~500ms 进入拖拽)。⭐ 另一个相关细节:移动端用「长按进入拖拽」是行业惯例(因为「短按拖动」与「滚动」天然冲突),但如果用了「拖拽手柄」,就不需要长按了(手柄已经明确表达了意图)。
- 「拖拽排序怎么实现『实时重排』?」
核心是「边拖边算目标位置并立即更新顺序」:① 拖拽开始时记录「所有项的位置」(⭐ 只记录一次);② 每次 pointermove 时,用 「指针的 Y 坐标」与「当前 DOM 中各项的中心线」比较,算出「应该插入到第几项」——⭐ 必须用「当前 DOM 的 rect」(因为拖拽中 DOM 已经被重排过,用开始时缓存的位置会算错);③ 算出新顺序后立即 onReorder(nextIds),让列表实时重排(配合 FLIP 让重排有过渡,见 1.15);④ 自动滚动——指针靠近容器上下边缘时按距离线性调 scrollTop,⚠️ 并且滚动后要重新计算目标位置(否则滚动时目标不动,会抖)。
- 「拖拽的无障碍怎么做?」
⭐ WCAG 明确要求「拖拽操作必须有非拖拽的替代方式」。三层做法:① 键盘拖拽——「空格/Enter 拾起 → 方向键移动 → 空格/Enter 放下」(这是 dnd-kit 等库的标准模式);② ⭐ 更省心的是「上移/下移按钮」——它最容易被屏幕阅读器理解,且不依赖「模式」(对认知障碍用户也友好);③ 语义与播报——role="listbox" + role="option" + aria-posinset/aria-setsize,并用 aria-live="polite" 的隐藏区域播报「已移动到第 N 位,共 M 项」;操作后恢复焦点(DOM 重排后焦点容易掉到 body)。
- 「拖拽有什么性能陷阱?」
四个:① ⭐ 在 pointermove 里读布局属性(getBoundingClientRect)——pointermove 频率可达每秒上百次,每次都读会触发强制同步布局(见 1.14)→ ✅ 拖拽开始时缓存读数,移动中只做数学计算;② 用 left/top 做位移——每帧重排 → ✅ 用 transform: translate3d()(只走合成,见 5.17);③ 自动滚动用 setInterval → ✅ 用 requestAnimationFrame(跟帧、且能被取消);④ 「抬起」反馈用 scale 会污染后续的 getBoundingClientRect(它包含 transform)→ ✅ 用 box-shadow + z-index 做「抬起」,或把 scale 放在内层元素上。
一句话速记
页面内拖拽不用 HTML5 DnD(移动端不可用 + 无法视觉跟随 + dragenter/leave 反复触发),用 Pointer Events;三个必须理解的:⭐ setPointerCapture(pointerId)(事件重定向到元素,指针移出也不丢)、⭐ touch-action: none 必须写在 CSS 里(动态设太晚;⚠️ 整块设会禁用滚动 → 只给拖拽手柄设)、⭐ pointercancel 必须当作 pointerup 处理(系统接管,不处理会状态卡死);实现要点是「一个 pointerId 贯穿全程(多点触控要判断) + 拖拽阈值 5~10px(区分点击) + 缓存读数(pointermove 里不读布局) + transform 位移」;排序要「用当前 DOM 的 rect 与项的中心线实时算目标位置并立即 onReorder(配合 FLIP 做过渡)+ 自动滚动(rAF + 滚动后重算目标)」;无障碍必须做(WCAG 要求):「空格拾起 + 方向键移动 + 空格放下」或者上移/下移按钮(最省心)+ aria-live 播报 + 操作后恢复焦点;跨 iframe/跨窗口要兜底(拖拽期间给 iframe pointer-events: none、window.blur 结束拖拽)。
未经允许不得转载:陌上寒 » 1.16 拖拽与手势
相关推荐
评论 抢沙发



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!