拖拽与手势实现:Pointer Events、DnD 与触控细节
本文目录
"把这个列表做成可拖拽排序"听起来是个小需求,落到代码上是一串细节:鼠标和触摸事件怎么统一、指针移出元素后事件去哪了、放进滚动容器后怎么自动滚动、两根手指同时按下算什么、键盘用户怎么完成同一个操作。这篇文章按实现顺序过一遍这些细节,给出可以直接对照的结构。原生 HTML5 拖放的适用边界在中间一节单独讨论。
为什么以 Pointer Events 为基础
鼠标事件、触摸事件、指针事件三套 API 的关系:
| 能力 | Mouse Events | Touch Events | Pointer Events |
|---|---|---|---|
| 鼠标 / 触摸 / 笔 | 只覆盖鼠标 | 只覆盖触摸 | 一套全覆盖 |
| 多指信息 | 无 | 有(touches 列表) | 有(pointerId) |
| 悬停(未按下) | 有 | 无 | 有(pointerType: mouse 时) |
| 指针捕获 | 无 | 隐式跟随目标 | 显式 setPointerCapture |
| 滚动让位 | 不涉及 | 需要 touch-action 控制 | 同样需要 touch-action |
Pointer Events 把三类输入抽象成统一的事件流(pointerdown/pointermove/pointerup/pointercancel),并通过 pointerType 保留输入类型的差异信息。写自定义拖拽、缩放、圈选,先选它;Touch Events 只在需要处理复杂多点手势(比如自己算 pinch 方向与旋转)时才直接用。
const handle = document.querySelector<HTMLElement>('.drag-handle')!;
let activePointer: number | null = null; // 记录正在拖拽的那根指针
handle.addEventListener('pointerdown', (event) => {
if (activePointer !== null) return; // 已有一根指针在拖
if (event.pointerType === 'mouse' && event.button !== 0) return; // 鼠标只认左键
activePointer = event.pointerId;
handle.setPointerCapture(event.pointerId);
handle.classList.add('is-dragging');
});
handle.addEventListener('pointerup', (event) => {
if (event.pointerId !== activePointer) return;
activePointer = null;
handle.classList.remove('is-dragging');
});
touch-action: none 是这套代码的前提,没有它浏览器会在触摸滚动、双击缩放、下拉刷新等场景直接接管指针,你收到的可能是 pointercancel 而不是 pointermove:
.drag-handle {
touch-action: none; /* 在这个元素上,触摸手势全部让位给自定义逻辑 */
user-select: none; /* 拖拽中不要选中文本 */
cursor: grab;
}
.drag-handle.is-dragging { cursor: grabbing; }
把 touch-action: none 只放在手柄上,容器其余区域保持默认——整页禁掉 touch-action 会让移动端用户没法滚动页面。
setPointerCapture:一次捕获解决"指针跟丢"
自实现拖拽最经典的 bug 是:指针移动太快,移出了元素边界,pointermove 就不再派发到你的监听器上,拖拽"卡死"在半路。setPointerCapture(pointerId) 之后,该指针的后续事件(move/up/cancel)会持续派发给捕获它的元素,无论指针在哪:
handle.addEventListener('pointerdown', (event) => {
handle.setPointerCapture(event.pointerId);
// 捕获是显式的:releasePointerCapture 或指针 up 后自动释放
const rect = handle.getBoundingClientRect();
const grabOffset = {
x: event.clientX - rect.left, // 记录按下点相对元素的位置
y: event.clientY - rect.top,
};
dragState = { pointerId: event.pointerId, grabOffset };
});
三个细节:
- 触摸指针有隐式捕获:触屏上按下的目标元素天然持续接收该指针的事件,
setPointerCapture对鼠标和触控笔才是关键补充。统一写上捕获,行为在三端一致。 pointercancel必须处理:浏览器接管(来电、手势识别、滚动让位)、元素被移除都会触发它。这时的正确动作是中止拖拽并还原状态,而不是当pointerup处理。- 捕获后
elementFromPoint命中检测不受影响(见下一节),它只改事件派发目标。
原生 HTML5 DnD 的适用与限制
原生拖放(draggable="true" + dragstart/dragover/drop + DataTransfer)不是不能用,而是只在一个窄区间里好用:
| 维度 | 适用 | 限制 |
|---|---|---|
| 平台 | 桌面浏览器 | 移动端基本不可用:iOS/Android 浏览器不把触摸映射到 DnD 事件 |
| 场景 | OS 文件投放、跨窗口/跨应用拖动、选中文本拖放 | 精确的跟手 UI:拖动影像是快照,不能实时样式 |
| 数据 | DataTransfer 支持多种类型 | 拖动中的样式几乎不可定制,dragover 派发频率受浏览器节流 |
| 语义 | 免费 | 无内置键盘替代路径,需要自己补 |
结论:从操作系统往页面里拖文件、从别的应用拖内容进来,用原生 DnD;产品内部的排序、换位、移动,用 Pointer Events 自实现。两者经常共存:上传区收文件走 DnD,看板列表拖卡片走指针。原生 DnD 的 DataTransfer 细节与文件安全处理另有专文,本文聚焦自实现路径。
自实现拖拽:命中检测与坐标换算
拖拽循环里每个 pointermove 都要回答两个问题:拖拽元素去哪、指针下面是什么。
坐标系统先对齐。pointermove 的 clientX/clientY 与 getBoundingClientRect() 同为视口坐标,可以直接相减:
| 坐标系 | 参照物 | 典型来源 | 拖拽中的用途 |
|---|---|---|---|
| client | 视口左上角 | clientX/Y、getBoundingClientRect() | 主坐标系,换算基准 |
| page | 文档左上角(含滚动) | pageX/Y | 指针跨容器计算 |
| screen | 屏幕左上角 | screenX/Y | 很少用 |
| offset | 目标元素内边距盒 | offsetX/Y | 只在单元素内有效 |
位置更新用增量,不用绝对值重算。拖拽元素本身被 transform 移动后,getBoundingClientRect() 的返回值也会变;如果每帧重新 rect.left + delta,误差会滚雪球:
type DragState = { pointerId: number; startX: number; startY: number; baseX: number; baseY: number };
let dragState: DragState | null = null;
let pending: { x: number; y: number } | null = null;
let rafId = 0;
handle.addEventListener('pointerdown', (event) => {
handle.setPointerCapture(event.pointerId);
// 基准取"元素当前 transform 之前"的静态位置,只量一次
const style = getComputedStyle(card);
const m = new DOMMatrix(style.transform === 'none' ? undefined : style.transform);
dragState = {
pointerId: event.pointerId,
startX: event.clientX, startY: event.clientY,
baseX: m.m41, baseY: m.m42, // 已有的 translate 作为基准
};
});
handle.addEventListener('pointermove', (event) => {
if (!dragState || event.pointerId !== dragState.pointerId) return;
pending = { x: event.clientX, y: event.clientY };
if (!rafId) rafId = requestAnimationFrame(applyTransform);
});
function applyTransform(): void {
rafId = 0;
if (!dragState || !pending) return;
const dx = pending.x - dragState.startX;
const dy = pending.y - dragState.startY;
card.style.transform =
`translate(${dragState.baseX + dx}px, ${dragState.baseY + dy}px)`;
}
两个要点:move 只把最新坐标存下来,真正的 DOM 更新合帧到 rAF 里做(pointermove 的派发频率可能远高于帧率);位移通过 transform 应用,不碰 left/top,拖拽全程不触发布局。
命中检测用 elementFromPoint,拖拽元素自身要排除:
card.classList.add('is-dragging'); // 拖拽期间不响应指针,让检测穿透自己
function hitTest(x: number, y: number): HTMLElement | null {
const under = document.elementFromPoint(x, y);
return under?.closest<HTMLElement>('[data-dropzone]') ?? null;
}
.card.is-dragging {
pointer-events: none; /* 关键:让 elementFromPoint 命中下层的投放区 */
z-index: 10;
}
注意 closest 向上找容器,这样投放区内部的子元素(图标、文本)不会破坏命中。命中结果驱动高亮:进入加 .is-over,离开移除,dropzone 变化时还要处理"从 A 区移到 B 区"的中间态。
滚动容器与 auto-scroll
列表在滚动容器里时,拖到容器边缘附近应该自动滚动。实现是边缘检测 + rAF 循环:
const EDGE = 56; // 距边缘多少像素内开始滚
const SPEED = 12; // 每帧滚动像素,实际产品应按距离边缘的远近分档
function autoScrollLoop(container: HTMLElement, clientY: number): void {
const rect = container.getBoundingClientRect();
let delta = 0;
if (clientY < rect.top + EDGE) delta = -SPEED * (1 - (clientY - rect.top) / EDGE);
else if (clientY > rect.bottom - EDGE) delta = SPEED * (1 - (rect.bottom - clientY) / EDGE);
if (delta) {
container.scrollTop += delta;
scheduleHitTest(clientY); // 容器动了,命中结果要重算
}
}
边界情况逐条列出来,漏一个就是一个工单:
- 拖拽结束时停止 auto-scroll 循环(
pointerup/pointercancel两个出口)。 - 容器滚到底(
scrollTop到上限)时 delta 不为零也没用,循环要能自然退出或降频。 - 页面本身也滚动时,两层滚动要分别检测:先看命中元素最近的滚动祖先,再决定滚容器还是滚页面。
overscroll-behavior: contain加在容器上,避免容器滚到底后带动整页弹跳。- 拖拽期间指针不动、但容器在自动滚的场景,命中检测要靠 auto-scroll 循环驱动,而不是只靠
pointermove。
多指与手势冲突
真实手指不止一根。第二根手指按下时,常见处理是"只认第一根,忽略其余":
handle.addEventListener('pointerdown', (event) => {
if (activePointer !== null) return; // 第二根手指直接忽略
activePointer = event.pointerId;
// ...
});
handle.addEventListener('pointermove', (event) => {
if (event.pointerId !== activePointer) return; // 只处理活动指针
// ...
});
handle.addEventListener('pointercancel', (event) => {
if (event.pointerId !== activePointer) return;
abortDrag(); // 浏览器接管:还原位置、清状态、移除高亮
});
需要 pinch(双指缩放)时,同一套事件里维护 Map<pointerId, {x, y}>:两根指针都活跃时,算两指距离与初始距离的比值应用 scale,此时拖拽应让位给缩放,切换要有明确的状态机,不能两个手势同时生效。另外触控笔的按钮、橡皮擦模式会体现在 event.buttons 与 event.pointerType,笔输入还要考虑压感(pressure)——除非产品明确需要,先按"笔=手指"处理,保持行为可预期。
无障碍:键盘也能完成拖拽
WCAG 2.2 的 Dragging Movements 准则要求:拖拽不能是唯一操作方式。最省事且最稳的替代是在每个条目旁放上移/下移按钮;要支持"键盘拖拽"则是下面这组:
<ul class="sortable" role="list">
<li class="item" tabindex="0" aria-roledescription="可排序条目">
<span class="item-label">准备发布</span>
<button type="button" data-action="up" aria-label="上移">↑</button>
<button type="button" data-action="down" aria-label="下移">↓</button>
</li>
</ul>
<p class="sr-live" aria-live="polite" id="sort-live"></p>
const live = document.querySelector('#sort-live')!;
item.addEventListener('keydown', (event) => {
const items = [...list.querySelectorAll('.item')] as HTMLElement[];
const index = items.indexOf(item);
let next = -1;
switch (event.key) {
case 'ArrowUp': next = Math.max(0, index - 1); break;
case 'ArrowDown': next = Math.min(items.length - 1, index + 1); break;
case 'Home': next = 0; break;
case 'End': next = items.length - 1; break;
default: return;
}
event.preventDefault();
if (next === index) return;
moveItem(index, next); // 与拖拽共用同一条数据路径
item.focus(); // 焦点跟着条目走
live.textContent = `已移到第 ${next + 1} 位,共 ${items.length} 项`;
});
要点:键盘路径与拖拽路径最终调用同一个 moveItem,数据变更逻辑只有一份;每次移动后通过 aria-live 区域播报新位置,读屏用户才拿得到反馈;焦点管理(移动后 focus 回条目)决定键盘流程是否连续。Escape 在拖拽中作为"取消"也应支持:还原到拖拽前的位置。
注意事项与边界
touch-action: none写在交互元素上而不是容器上,否则移动端页面滚动被破坏。pointercancel是高频真实事件(滚动让位、系统手势、来电),中止逻辑要与正常放下一样完整:还原样式、清高亮、停 auto-scroll。- iOS Safari 与部分 Android WebView 上
pointermove的派发节流策略不同,DOM 更新必须合帧到 rAF,不要假设事件频率等于帧率。 - 拖拽中的元素避免触发 reflow:位置用
transform,高亮用类名切换已有样式,不要在move回调里读布局再写样式。 - 拖拽目标位置若来自
offsetX/offsetY,注意它相对的是event.target——捕获后 target 固定,但换算起点容易错,统一用 client 坐标最不容易出事。 - 命中检测依赖
elementFromPoint,元素pointer-events: none的时段内用户无法与它交互,拖拽结束要立刻恢复。 - 嵌套滚动容器(弹窗内列表)要显式指定 auto-scroll 的目标容器,否则会出现内外两层同时滚。
- 拖拽释放后的数据提交要考虑失败回滚:乐观更新 UI 前先想好接口报错时怎么还原,FLIP 之类的过渡动画只是包装。
- 测试矩阵至少覆盖:桌面鼠标、触屏单指、触屏滚动手势冲突、键盘、读屏器播报。
总结
自实现拖拽的骨架是:Pointer Events 统一三类输入,setPointerCapture 保证指针不丢,client 坐标 + rAF 合帧 + transform 驱动位置更新,elementFromPoint 做命中,auto-scroll 补齐滚动容器,pointercancel 兜住一切中断。原生 DnD 只留给 OS 文件与跨应用拖入。无障碍上,键盘路径与拖拽路径共用同一份数据变更函数,配 aria-live 播报位置,才算完整交付。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。