React 基础体系 · 第 38/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 拖拽交互:排序、跨容器、触摸、键盘和状态一致性
拖拽看起来像“按住一个元素,把它移动到另一个位置”,但一个可用的 React 拖拽交互实际上同时包含几件事:
- 排序:同一容器内改变项目顺序。
- 跨容器移动:把项目从一个列表移动到另一个列表。
- 触摸交互:在手机或平板上用手指完成拖动。
- 键盘交互:不依赖鼠标或触摸完成拾取、移动和放置。
- 状态一致性:界面状态、拖拽中的临时状态、服务端持久化状态之间不能互相覆盖或产生重复项目。
这些问题不能只靠一个 onDragEnd 解决。首先需要明确拖拽事件模型,再选择实现方式。
一、先区分两种拖拽模型
Web 平台中常见的拖拽有两类。
1. 原生 HTML Drag and Drop
原生模型使用 draggable 属性和一组事件:
<div
draggable
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
项目
</div>
典型流程是:
dragstart
↓
dragenter / dragover
↓
drop
↓
dragend
要让目标元素能够接收 drop,通常必须在 dragover 中调用:
event.preventDefault();
否则浏览器不会把它视为有效放置目标。
原生模型的优点是浏览器已经处理了部分拖拽生命周期,并且适合桌面端文件拖入、跨窗口拖拽等场景。缺点也很明显:
- 触摸设备的支持依赖浏览器,行为不完全一致;
- 键盘操作不是原生
draggable自动提供的; - 拖拽预览图和命中区域可定制程度有限;
dataTransfer主要传输字符串或文件,不等同于 React 状态;- 在复杂排序、跨容器和自定义动画中,事件时序较难控制。
2. 基于 Pointer Events 的自定义拖拽
另一种方式是使用统一的指针事件:
pointerdown
↓
pointermove
↓
pointerup / pointercancel
鼠标、触摸和触控笔都可以通过 Pointer Events 表示。组件自己负责:
- 记录当前拖拽项目;
- 计算指针位于哪个容器;
- 计算目标插入位置;
- 更新 React 状态;
- 处理取消、滚动和无效放置。
对于“列表排序 + 跨容器 + 触摸 + 键盘”的业务组件,自定义 Pointer Events 通常更容易建立统一的状态模型。键盘操作则不模拟指针事件,而是直接调用相同的“移动项目”状态转换函数。
因此,本文使用以下策略:
- 文件拖入或跨窗口拖拽:考虑原生 HTML Drag and Drop;
- 应用内部项目排序:使用 Pointer Events;
- 键盘:调用与指针拖拽相同的纯状态操作;
- 所有移动结果:通过不可变 reducer 更新。
二、拖拽排序本质上是一个插入问题
假设一个列表为:
[A, B, C, D]
把 B 拖到 D 的上半部分,结果应为:
[A, B, C, D]
因为 B 原本已经位于 D 之前。
把 B 拖到 D 的下半部分,结果为:
[A, C, D, B]
因此,“拖到了哪个元素”还不够,还要判断指针位于目标元素的上半部还是下半部。
设目标元素的矩形为:
[top, bottom]
其垂直中心为:
给定指针纵坐标 y:
- 当
y < middle时,插入到目标元素之前; - 当
y >= middle时,插入到目标元素之后。
例如:
目标 D 的 top = 300
目标 D 的 bottom = 380
middle = 340
- 指针
y = 320:插入到D之前; - 指针
y = 360:插入到D之后。
为什么必须先移除源项目
设列表为:
[A, B, C, D]
现在拖动 B,目标是 D 的下方。
正确步骤是:
1. 移除 B [A, C, D]
2. 在 D 后插入 [A, C, D, B]
如果先按原列表索引计算,再移除源项目,索引会发生偏移,常见错误是把项目插入到目标前面或多偏移一位。
跨容器时也一样:
todo = [A, B, C]
done = [X, Y]
把 B 放到 done 中 Y 后面:
1. todo 移除 B => [A, C]
2. done 插入 B => [X, Y, B]
这说明拖拽排序的核心不是 DOM 移动,而是一个纯函数:
moveItem(state, itemId, targetContainerId, targetIndex)
DOM 只是状态的投影。
三、状态模型:项目、容器和拖拽会话分开
不要把完整对象直接复制到每个列表中。更稳定的模型是:
type Item = {
id: string;
title: string;
};
type Column = {
id: string;
title: string;
itemIds: string[];
};
type BoardState = {
items: Record<string, Item>;
columns: Column[];
};
例如:
const initialState: BoardState = {
items: {
a: { id: "a", title: "设计接口" },
b: { id: "b", title: "实现组件" },
c: { id: "c", title: "编写测试" },
d: { id: "d", title: "发布版本" },
},
columns: [
{ id: "todo", title: "待处理", itemIds: ["a", "b", "c"] },
{ id: "done", title: "已完成", itemIds: ["d"] },
],
};
这种归一化结构有三个好处:
- 项目内容只有一个来源,修改标题不会出现副本不一致;
- 排序只修改
itemIds,不会误复制整个对象; - 可以用项目 ID 作为 React
key,不会因排序导致组件身份错乱。
拖拽会话则属于临时交互状态,不应持久化到服务端:
type DragSession = {
itemId: string;
pointerId: number;
sourceColumnId: string;
};
它可以放在 useRef 中,因为它主要用于处理连续的指针事件:
const dragSessionRef = useRef<DragSession | null>(null);
而列表的实际顺序属于 React 状态,应通过 useReducer 管理。两者职责不同:
useRef:保存正在进行的指针会话,不用于触发渲染;useReducer:保存用户已经产生的业务结果,用于渲染和持久化。
四、用纯 reducer 实现同容器和跨容器移动
下面的 reducer 是整个拖拽系统的核心。它不依赖 DOM,也不依赖 Pointer Events,因此指针、键盘、按钮和服务端回放都可以复用它。
import { useReducer } from "react";
type Item = {
id: string;
title: string;
};
type Column = {
id: string;
title: string;
itemIds: string[];
};
type BoardState = {
items: Record<string, Item>;
columns: Column[];
};
type MoveAction = {
type: "move";
itemId: string;
toColumnId: string;
toIndex: number;
};
type Action = MoveAction;
function moveItem(
state: BoardState,
itemId: string,
toColumnId: string,
requestedIndex: number,
): BoardState {
const sourceColumn = state.columns.find((column) =>
column.itemIds.includes(itemId),
);
const targetColumn = state.columns.find(
(column) => column.id === toColumnId,
);
if (!sourceColumn || !targetColumn) {
return state;
}
// 先从所有容器中移除,保证项目不会重复出现。
const columnsWithoutItem = state.columns.map((column) => ({
...column,
itemIds: column.itemIds.filter((id) => id !== itemId),
}));
const targetIndex = Math.max(
0,
Math.min(requestedIndex, targetColumn.itemIds.length),
);
const columns = columnsWithoutItem.map((column) => {
if (column.id !== toColumnId) {
return column;
}
const itemIds = [...column.itemIds];
itemIds.splice(targetIndex, 0, itemId);
return {
...column,
itemIds,
};
});
return {
...state,
columns,
};
}
function boardReducer(state: BoardState, action: Action): BoardState {
switch (action.type) {
case "move":
return moveItem(
state,
action.itemId,
action.toColumnId,
action.toIndex,
);
default:
return state;
}
}
这里有一个容易忽略的边界:当源容器和目标容器相同时,targetColumn.itemIds.length 仍然包含被拖项目。上面的实现虽然能完成许多移动,但目标索引必须由调用方按照“移除源项目后的列表”计算,否则可能产生偏移。
更严谨的实现是先生成移除后的列,再基于移除后的目标列进行插入:
function moveItem(
state: BoardState,
itemId: string,
toColumnId: string,
requestedIndex: number,
): BoardState {
const exists = state.columns.some((column) =>
column.itemIds.includes(itemId),
);
if (!exists) {
return state;
}
const columnsWithoutItem = state.columns.map((column) => ({
...column,
itemIds: column.itemIds.filter((id) => id !== itemId),
}));
const targetColumn = columnsWithoutItem.find(
(column) => column.id === toColumnId,
);
if (!targetColumn) {
return state;
}
const targetIndex = Math.max(
0,
Math.min(requestedIndex, targetColumn.itemIds.length),
);
const columns = columnsWithoutItem.map((column) => {
if (column.id !== toColumnId) {
return column;
}
const itemIds = [...column.itemIds];
itemIds.splice(targetIndex, 0, itemId);
return {
...column,
itemIds,
};
});
return { ...state, columns };
}
这个函数满足两个重要不变量:
每个 itemId 至多出现在一个容器中;
移动前后项目总数不变。
如果还要求所有已知项目都必须位于某个容器中,则还可以在开发环境增加断言:
function assertBoard(state: BoardState) {
const ids = state.columns.flatMap((column) => column.itemIds);
const uniqueIds = new Set(ids);
if (ids.length !== uniqueIds.size) {
throw new Error("同一个项目出现在多个容器中");
}
for (const id of ids) {
if (!state.items[id]) {
throw new Error(`找不到项目 ${id}`);
}
}
}
五、完整的 Pointer Events 拖拽路径
下面的示例展示一个可以运行的基础看板组件。它支持:
- 同容器排序;
- 跨容器移动;
- 鼠标;
- 触摸和触控笔;
- 键盘上下移动;
- 键盘左右跨容器移动;
- 拖拽完成后的状态更新。
为了把重点放在机制上,示例没有实现拖拽占位符动画,也没有接入服务端。
"use client";
import {
useEffect,
useReducer,
useRef,
type PointerEvent as ReactPointerEvent,
type KeyboardEvent,
} from "react";
type Item = {
id: string;
title: string;
};
type Column = {
id: string;
title: string;
itemIds: string[];
};
type BoardState = {
items: Record<string, Item>;
columns: Column[];
};
type Action =
| {
type: "move";
itemId: string;
toColumnId: string;
toIndex: number;
};
type DragSession = {
itemId: string;
pointerId: number;
sourceColumnId: string;
};
const initialState: BoardState = {
items: {
a: { id: "a", title: "设计接口" },
b: { id: "b", title: "实现组件" },
c: { id: "c", title: "编写测试" },
d: { id: "d", title: "发布版本" },
},
columns: [
{ id: "todo", title: "待处理", itemIds: ["a", "b", "c"] },
{ id: "done", title: "已完成", itemIds: ["d"] },
],
};
function moveItem(
state: BoardState,
itemId: string,
toColumnId: string,
requestedIndex: number,
): BoardState {
const columnsWithoutItem = state.columns.map((column) => ({
...column,
itemIds: column.itemIds.filter((id) => id !== itemId),
}));
const targetColumn = columnsWithoutItem.find(
(column) => column.id === toColumnId,
);
if (!targetColumn) {
return state;
}
const targetIndex = Math.max(
0,
Math.min(requestedIndex, targetColumn.itemIds.length),
);
return {
...state,
columns: columnsWithoutItem.map((column) => {
if (column.id !== toColumnId) {
return column;
}
const itemIds = [...column.itemIds];
itemIds.splice(targetIndex, 0, itemId);
return { ...column, itemIds };
}),
};
}
function reducer(state: BoardState, action: Action): BoardState {
if (action.type === "move") {
return moveItem(
state,
action.itemId,
action.toColumnId,
action.toIndex,
);
}
return state;
}
function getDropPosition(
x: number,
y: number,
activeItemId: string,
): { columnId: string; index: number } | null {
const element = document.elementFromPoint(x, y);
if (!element) {
return null;
}
const container = element.closest<HTMLElement>("[data-column-id]");
if (!container) {
return null;
}
const columnId = container.dataset.columnId;
if (!columnId) {
return null;
}
const itemElements = Array.from(
container.querySelectorAll<HTMLElement>("[data-item-id]"),
).filter((item) => item.dataset.itemId !== activeItemId);
for (let index = 0; index < itemElements.length; index += 1) {
const itemElement = itemElements[index];
const rect = itemElement.getBoundingClientRect();
const middle = rect.top + rect.height / 2;
if (y < middle) {
return { columnId, index };
}
}
return {
columnId,
index: itemElements.length,
};
}
export function Board() {
const [state, dispatch] = useReducer(reducer, initialState);
const dragSessionRef = useRef<DragSession | null>(null);
const boardRef = useRef<HTMLDivElement>(null);
function startPointerDrag(
event: ReactPointerEvent<HTMLDivElement>,
itemId: string,
sourceColumnId: string,
) {
if (event.button !== 0 && event.pointerType === "mouse") {
return;
}
event.preventDefault();
dragSessionRef.current = {
itemId,
pointerId: event.pointerId,
sourceColumnId,
};
event.currentTarget.setPointerCapture(event.pointerId);
}
function updatePointerDrag(event: ReactPointerEvent<HTMLDivElement>) {
const session = dragSessionRef.current;
if (!session || session.pointerId !== event.pointerId) {
return;
}
const position = getDropPosition(
event.clientX,
event.clientY,
session.itemId,
);
if (!position) {
return;
}
dispatch({
type: "move",
itemId: session.itemId,
toColumnId: position.columnId,
toIndex: position.index,
});
}
function finishPointerDrag(event: ReactPointerEvent<HTMLDivElement>) {
const session = dragSessionRef.current;
if (!session || session.pointerId !== event.pointerId) {
return;
}
dragSessionRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
}
function moveByKeyboard(itemId: string, direction: "up" | "down" | "left" | "right") {
const columnIndex = state.columns.findIndex((column) =>
column.itemIds.includes(itemId),
);
if (columnIndex < 0) {
return;
}
const sourceColumn = state.columns[columnIndex];
const sourceIndex = sourceColumn.itemIds.indexOf(itemId);
if (direction === "up" && sourceIndex > 0) {
dispatch({
type: "move",
itemId,
toColumnId: sourceColumn.id,
toIndex: sourceIndex - 1,
});
return;
}
if (
direction === "down" &&
sourceIndex < sourceColumn.itemIds.length - 1
) {
dispatch({
type: "move",
itemId,
toColumnId: sourceColumn.id,
toIndex: sourceIndex + 1,
});
return;
}
if (direction === "left" && columnIndex > 0) {
const targetColumn = state.columns[columnIndex - 1];
dispatch({
type: "move",
itemId,
toColumnId: targetColumn.id,
toIndex: targetColumn.itemIds.length,
});
return;
}
if (direction === "right" && columnIndex < state.columns.length - 1) {
const targetColumn = state.columns[columnIndex + 1];
dispatch({
type: "move",
itemId,
toColumnId: targetColumn.id,
toIndex: targetColumn.itemIds.length,
});
}
}
function handleKeyDown(
event: KeyboardEvent<HTMLDivElement>,
itemId: string,
) {
if (event.key === "ArrowUp") {
event.preventDefault();
moveByKeyboard(itemId, "up");
} else if (event.key === "ArrowDown") {
event.preventDefault();
moveByKeyboard(itemId, "down");
} else if (event.key === "ArrowLeft") {
event.preventDefault();
moveByKeyboard(itemId, "left");
} else if (event.key === "ArrowRight") {
event.preventDefault();
moveByKeyboard(itemId, "right");
}
}
useEffect(() => {
function cancelOnWindowBlur() {
dragSessionRef.current = null;
}
window.addEventListener("blur", cancelOnWindowBlur);
return () => {
window.removeEventListener("blur", cancelOnWindowBlur);
};
}, []);
return (
<div
ref={boardRef}
className="board"
onPointerMove={updatePointerDrag}
onPointerUp={finishPointerDrag}
onPointerCancel={finishPointerDrag}
>
{state.columns.map((column) => (
<section
key={column.id}
data-column-id={column.id}
className="column"
aria-labelledby={`${column.id}-title`}
>
<h2 id={`${column.id}-title`}>{column.title}</h2>
<div className="column-list">
{column.itemIds.map((itemId) => {
const item = state.items[itemId];
return (
<div
key={item.id}
data-item-id={item.id}
className="card"
tabIndex={0}
role="listitem"
onPointerDown={(event) =>
startPointerDrag(event, item.id, column.id)
}
onKeyDown={(event) => handleKeyDown(event, item.id)}
>
{item.title}
</div>
);
})}
</div>
</section>
))}
</div>
);
}
配套的最小 CSS 至少应包含:
.board {
display: flex;
gap: 16px;
align-items: flex-start;
}
.column {
width: 260px;
min-height: 240px;
padding: 12px;
background: #f4f5f7;
border-radius: 8px;
}
.column-list {
min-height: 120px;
}
.card {
margin: 8px 0;
padding: 12px;
background: white;
border: 1px solid #d9dce1;
border-radius: 6px;
user-select: none;
/* 允许应用接管卡片上的触摸拖拽手势。 */
touch-action: none;
}
.card:focus-visible {
outline: 3px solid #4c9ffe;
outline-offset: 2px;
}
这段代码的实际工作过程
以手指拖动项目 b 为例:
1. pointerdown
dragSessionRef = { itemId: "b", pointerId: 7, ... }
2. setPointerCapture(7)
后续 pointermove 即使离开原卡片,也继续发送到该元素
3. pointermove
使用 clientX/clientY 调用 elementFromPoint
4. 找到 data-column-id
确定当前位于 todo 或 done
5. 遍历目标容器中的卡片
通过每个卡片的几何中心计算插入索引
6. dispatch({ type: "move", ... })
reducer 生成新的列数组
7. React 重新渲染
DOM 顺序反映新的 itemIds 顺序
8. pointerup 或 pointercancel
清理 dragSessionRef
setPointerCapture 很重要。没有它,指针离开卡片后,后续事件可能转移到其他元素,拖动过程中会出现“移动几厘米就失效”的表现。
六、示例中的一个生产风险:不要无条件每帧 dispatch
上面的代码为了展示机制,在每次 pointermove 时都派发移动操作。生产实现通常应避免这样做,因为:
- 指针事件频率可能高于渲染频率;
- 目标位置没有变化时,重复 dispatch 没有意义;
- 状态不断变化会让命中计算依赖的 DOM 也不断变化;
- 拖动中的卡片如果没有单独占位符,可能出现抖动。
更稳妥的方式是把“当前投影位置”放入引用中,只在位置真正变化时 dispatch:
const lastDropRef = useRef<string>("");
function dispatchIfPositionChanged(
itemId: string,
columnId: string,
index: number,
) {
const positionKey = `${itemId}:${columnId}:${index}`;
if (lastDropRef.current === positionKey) {
return;
}
lastDropRef.current = positionKey;
dispatch({
type: "move",
itemId,
toColumnId: columnId,
toIndex: index,
});
}
更复杂的实现会区分:
业务状态:拖拽前或最近一次确认的顺序
临时投影:当前指针位置对应的占位位置
拖动过程中只更新投影,pointerup 时才提交最终排序。这种方式可以减少 reducer 更新和 DOM 重排,但需要额外渲染一个占位符,并确保占位符不会被再次当成拖拽目标。
七、触摸拖拽:关键不是监听 Touch Events
现代实现一般优先使用 Pointer Events,而不是同时维护:
mousedown / mousemove / mouseup
touchstart / touchmove / touchend
分别维护两套逻辑容易导致:
- 鼠标和触摸的坐标处理不同;
preventDefault()时机不一致;- 触摸滚动和拖拽手势互相抢占;
touchend、取消和页面切换时清理不完整。
touch-action 的作用
touch-action 告诉浏览器:某个元素上的触摸手势由浏览器默认处理,还是允许页面脚本接管。
例如:
.card {
touch-action: none;
}
表示卡片上的平移、缩放等默认触摸行为都不由浏览器处理。这样脚本才能稳定收到移动事件。
但 touch-action: none 也有副作用:用户无法在卡片区域正常滚动页面。如果列表很长,通常不应把整个卡片都设为 none,而应提供一个拖拽手柄:
<div className="card">
<button
type="button"
className="drag-handle"
aria-label="拖动项目"
onPointerDown={/* 在此开始拖拽 */}
>
⋮⋮
</button>
<span>{item.title}</span>
</div>
.card {
touch-action: pan-y;
}
.drag-handle {
touch-action: none;
cursor: grab;
}
这里的取舍是:
- 整张卡片可拖:交互简单,但可能妨碍滚动和点击;
- 只有手柄可拖:滚动和点击更稳定,但用户需要找到手柄;
- 长按后进入拖拽:能减少误触,但需要处理长按计时、取消和触觉反馈。
preventDefault() 的边界
preventDefault() 应只在确认要开始拖拽,或明确要阻止浏览器默认行为时调用。过早调用可能导致:
- 链接无法打开;
- 按钮点击失效;
- 页面滚动被阻止;
- 辅助技术的默认交互受到影响。
因此,卡片中若包含按钮、链接或输入框,应判断事件目标是否属于交互控件,不要把所有 pointerdown 都解释成拖拽。
八、键盘拖拽不是“模拟鼠标移动”
键盘无法提供连续二维坐标,因此键盘交互应定义为离散状态转换。
一种清晰的约定是:
ArrowUp 在当前容器中向前移动一位
ArrowDown 在当前容器中向后移动一位
ArrowLeft 移动到左侧容器末尾
ArrowRight 移动到右侧容器末尾
也可以采用更接近“拾取—移动—放置”的模式:
Space 拾取或放下
ArrowUp/Down 移动占位位置
ArrowLeft/Right 切换容器
Escape 取消拖拽
两种模式都可以,但必须在界面中说明,并保证焦点和状态反馈一致。
键盘移动的索引推导
假设当前容器为:
[A, B, C]
当前项目是 B,索引为 1。
按下 ArrowDown:
目标索引 = 1 + 1 = 2
reducer 移除 B:
[A, C]
再按索引 2 插入:
[A, C, B]
按下 ArrowUp:
目标索引 = 1 - 1 = 0
移除后:
[A, C]
插入索引 0:
[B, A, C]
这就是为什么键盘操作应调用同一个 moveItem,而不是在 DOM 上直接交换节点。
焦点管理
使用稳定的 key={item.id} 后,React 会尽量复用对应 DOM 节点。但跨容器移动后,焦点是否仍然符合用户预期,不能只依赖浏览器实现。
可以在移动后显式恢复焦点:
const focusItemIdRef = useRef<string | null>(null);
function moveByKeyboard(...) {
focusItemIdRef.current = itemId;
dispatch({
type: "move",
itemId,
toColumnId,
toIndex,
});
}
useLayoutEffect(() => {
const id = focusItemIdRef.current;
if (!id) {
return;
}
const element = document.querySelector<HTMLElement>(
`[data-item-id="${CSS.escape(id)}"]`,
);
element?.focus();
focusItemIdRef.current = null;
}, [state]);
如果组件在服务端渲染,useLayoutEffect 需要放在客户端组件中使用,或者使用项目框架提供的等价客户端边界,避免服务端执行浏览器 API。
可访问性反馈
仅有 tabIndex={0} 不等于键盘拖拽可访问。还需要:
- 可见的焦点样式;
- 明确的键盘操作说明;
- 拾取、移动、放置和取消的状态反馈;
- 让屏幕阅读器知道项目当前所在容器和位置。
例如可以维护一个只读状态区域:
<div aria-live="polite" className="sr-only">
{announcement}
</div>
状态文本可以是:
已拾取“实现组件”,当前位于“待处理”第 2 项,共 3 项。
已移动到“已完成”第 1 项。
已放置“实现组件”。
aria-live="polite" 适合一般状态通知;如果更新过于频繁,每一次指针移动都播报会让辅助技术无法使用,因此指针拖拽通常只在进入新位置、放下或取消时播报。
九、跨容器拖拽的命中算法
跨容器比同列表排序多一个步骤:必须先确定目标容器。
一个常见的 DOM 标记方式是:
<section data-column-id="todo">
<div data-item-id="a">设计接口</div>
<div data-item-id="b">实现组件</div>
</section>
在指针移动时:
const element = document.elementFromPoint(clientX, clientY);
const container = element?.closest("[data-column-id]");
得到容器后,再计算插入索引。
空容器必须有命中区域
如果目标容器没有项目:
<section data-column-id="done">
<div className="column-list" />
</section>
querySelectorAll("[data-item-id]") 得不到任何元素,因此代码必须把空容器本身视为有效目标,并返回索引 0。
同时,空列表的容器应有可见或至少有足够的最小高度:
.column-list {
min-height: 80px;
}
否则用户很难把项目放进空容器,elementFromPoint 也可能命中容器外部。
不要把滚动容器坐标混为一谈
getBoundingClientRect() 和 clientX/clientY 都使用视口坐标,因此可以直接比较。以下坐标不能直接混用:
pageX/pageY:包含页面滚动偏移;clientX/clientY:相对于视口;offsetTop/offsetLeft:相对于 offset parent;scrollTop/scrollLeft:滚动容器内部偏移。
如果使用 clientY,目标矩形也应使用 getBoundingClientRect()。如果改用页面坐标,就必须同时加上页面滚动偏移。
十、原生 HTML5 拖拽何时更合适
如果需求是把文件从操作系统拖进网页,Pointer Events 无法替代原生 DataTransfer。
文件拖入示例:
function FileDropZone() {
function handleDragOver(event: React.DragEvent<HTMLDivElement>) {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
for (const file of files) {
console.log(file.name, file.type, file.size);
}
}
return (
<div onDragOver={handleDragOver} onDrop={handleDrop}>
将文件拖到这里
</div>
);
}
这里的因果关系是:
dragover 不 preventDefault
→ 浏览器认为目标不接受放置
→ drop 不触发
但对于应用内部的看板排序,不建议把 dataTransfer 当成业务状态容器:
event.dataTransfer.setData("text/plain", itemId);
它最多传递一个 ID。真正的排序仍然要由 React reducer 完成,否则会出现 DOM 已经变化、React 状态却没有变化的问题。下一次渲染会把 DOM 恢复成旧顺序。
十一、React 状态一致性:不要直接修改数组和对象
以下代码是错误的:
state.columns[0].itemIds.splice(1, 1);
setState(state);
问题有两个:
- 直接修改旧状态;
- 传入同一个顶层对象,React 可能无法根据引用变化识别更新。
正确方式是返回新的对象、数组和必要的嵌套层:
setState((previous) => ({
...previous,
columns: previous.columns.map((column) =>
column.id === "todo"
? {
...column,
itemIds: column.itemIds.filter((id) => id !== "b"),
}
: column,
),
}));
useReducer 更适合拖拽,因为所有输入都可以归纳为动作:
pointermove → move
keyboard arrow → move
服务端回放 → move
撤销 → 反向 move
为什么函数式更新很重要
事件处理函数可能闭包捕获旧状态。尤其是拖拽过程中连续产生事件时,下面这种写法容易覆盖中间更新:
setColumns(columnsAfterMove);
如果多个事件都基于同一个旧的 columns 计算结果,后来的更新可能丢失前面的更新。
函数式更新会读取 React 当前排队中的最新状态:
setColumns((currentColumns) => calculateNext(currentColumns));
useReducer 的 dispatch 也适合表达这种顺序操作,因为 reducer 接收的是当前状态,而不是事件处理函数创建时捕获的状态。
十二、拖拽状态与服务端状态不是同一个时序
拖拽完成后通常还要持久化排序:
await fetch("/api/board/order", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
columnId: "done",
itemIds: ["d", "b"],
}),
});
此时至少存在三种状态:
S0:服务端确认的顺序
S1:用户刚刚完成的本地顺序
S2:网络请求返回后的服务端顺序
最简单的流程是:
用户放下
↓
立即更新为 S1
↓
发送 S1
↓
成功:把 S1 视为已确认
失败:恢复 S0 或重新获取
错误的流程是:
请求 A 发送 S1
请求 B 发送 S2
B 先返回
A 后返回
直接使用最后一次返回结果
这样可能把较新的操作覆盖成较旧的顺序。
使用版本号避免旧响应覆盖新操作
服务端可以返回版本号:
type BoardSnapshot = {
version: number;
columns: Column[];
};
客户端发送:
type SaveOrderRequest = {
baseVersion: number;
columns: Array<{
id: string;
itemIds: string[];
}>;
};
服务端处理逻辑应类似:
请求携带 baseVersion = 10
当前服务端 version = 10
→ 接受更新,保存为 version = 11
另一个请求仍携带 baseVersion = 10
当前服务端 version = 11
→ 返回 409 Conflict
客户端收到 409 时不能静默覆盖本地状态,应该:
- 重新获取服务端快照;
- 告知用户顺序发生冲突;
- 选择重新应用本地操作,或让用户确认。
如果业务允许“最后写入者获胜”,也应明确这是产品策略,而不是偶然由网络时序决定的结果。
十三、异步保存的一个可靠实现
下面展示一个简化的“本地立即更新、串行保存”模型:
import { useEffect, useRef, useState } from "react";
type PersistedOrder = {
version: number;
columns: Array<{
id: string;
itemIds: string[];
}>;
};
function serializeOrder(state: BoardState): PersistedOrder["columns"] {
return state.columns.map((column) => ({
id: column.id,
itemIds: [...column.itemIds],
}));
}
function usePersistedBoard(state: BoardState, version: number) {
const pendingRef = useRef<PersistedOrder | null>(null);
const savingRef = useRef(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
pendingRef.current = {
version,
columns: serializeOrder(state),
};
if (savingRef.current) {
return;
}
let cancelled = false;
async function flush() {
savingRef.current = true;
try {
while (pendingRef.current && !cancelled) {
const payload = pendingRef.current;
pendingRef.current = null;
const response = await fetch("/api/board/order", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
if (response.status === 409) {
throw new Error("服务端版本冲突");
}
throw new Error(`保存失败:${response.status}`);
}
}
if (!cancelled) {
setSaveError(null);
}
} catch (error) {
if (!cancelled) {
setSaveError(
error instanceof Error ? error.message : "未知保存错误",
);
}
} finally {
savingRef.current = false;
}
}
void flush();
return () => {
cancelled = true;
};
}, [state, version]);
return { saveError };
}
这个例子使用“待发送的最新快照”而不是无限累积请求:
本地变化 S1 → pending = S1 → 发送
本地变化 S2 → pending = S2
本地变化 S3 → pending = S3
请求 S1 完成 → 继续发送 S3
它适合“最终只关心当前顺序”的看板。若每一次移动都有独立业务含义,例如库存转移或审批流转,则不应简单覆盖中间操作,而应保存操作日志或使用服务端事务。
十四、服务端渲染与客户端边界
拖拽依赖浏览器对象和布局信息:
document.elementFromPoint(...)
element.getBoundingClientRect()
window.addEventListener(...)
PointerEvent
这些 API 在服务端不存在。
在支持 React Server Components 的框架中,页面可以由服务端组件负责:
- 获取初始数据;
- 执行权限检查;
- 输出静态结构。
但包含拖拽逻辑的组件必须位于客户端边界。例如在 Next.js App Router 中,文件顶部可以使用:
"use client";
客户端组件接收的初始数据必须是可序列化的普通数据。不要把以下内容从服务端组件直接传入客户端组件:
- DOM 节点;
File;- 函数;
- 类实例;
- 未完成的 Promise,除非框架明确支持该传递方式。
服务端渲染时还要避免依赖随机 ID 或当前布局,否则服务端 HTML 和客户端首次渲染结果可能不一致。项目 ID 应由数据源提供,或者在客户端初始化,而不是每次渲染都用 Math.random() 生成。
十五、常见失败表现与诊断方法
1. 拖出卡片后移动事件消失
表现:拖动只在卡片内部有效,移出几像素后停止。
原因:没有使用 Pointer Capture,或者事件监听只绑定在瞬间命中的目标上。
检查:
event.currentTarget.setPointerCapture(event.pointerId);
同时确认 pointercancel 和 pointerup 都会清理会话。
2. drop 从不触发
表现:使用原生 HTML 拖拽时,onDrop 没有执行。
原因:目标的 dragover 没有调用 preventDefault()。
检查:
function handleDragOver(event: React.DragEvent) {
event.preventDefault();
}
如果需求是触摸排序,则不要继续修补原生 drop,应评估改用 Pointer Events。
3. 项目视觉位置变了,刷新后又恢复
表现:拖动过程中看起来成功,刷新或下一次渲染后顺序恢复。
原因:只改了 DOM 或使用了 dataTransfer,没有更新 React 状态。
检查:
key={item.id}
以及排序后是否确实 dispatch 了 reducer action。不要使用:
key={index}
因为索引不是项目身份。排序后使用索引作为 key,React 可能复用错误的组件实例,导致输入框、焦点和内部状态跟随错误项目。
4. 项目出现两次
表现:跨容器移动后,源列表和目标列表都保留了同一个项目。
原因:只向目标容器插入,没有先从源容器删除,或者源容器 ID 失效后没有校验。
检查:
const columnsWithoutItem = columns.map((column) => ({
...column,
itemIds: column.itemIds.filter((id) => id !== itemId),
}));
然后只在目标容器插入一次。
5. 同列表拖动总是偏移一位
表现:拖到目标下方,结果却出现在目标上方或后面多一位。
原因:目标索引基于包含源项目的旧数组计算,随后又执行了删除。
修复:先构造移除源项目后的容器,再在该数组上计算和插入目标索引。
6. 手机上拖动变成页面滚动
表现:手指移动时页面滚动,组件没有连续收到拖动事件。
原因:浏览器默认触摸行为仍然接管了手势,或者 touch-action 配置与交互区域不匹配。
诊断:
- 确认是否收到
pointerdown; - 确认
pointerType是否为"touch"; - 检查卡片或拖拽手柄的
touch-action; - 检查是否过早或过晚调用了
preventDefault(); - 在可滚动容器中确认是否把整个滚动区域错误地设置成
touch-action: none。
7. 键盘可以移动,但屏幕阅读器不知道发生了什么
表现:顺序确实变化,辅助技术用户却无法确认当前项目位置。
原因:只实现了 keydown,没有提供状态公告和明确的焦点策略。
修复:
- 为项目提供可聚焦元素;
- 使用
aria-live宣布移动结果; - 在项目被移动后恢复焦点;
- 在界面中写出可用按键;
- 不要依赖已经过时或不被当前辅助技术可靠支持的拖放 ARIA 属性。
十六、拖拽取消与故障路径
一个完整的拖拽会话不只有成功放置:
stateDiagram-v2
[*] --> Idle
Idle --> Dragging: pointerdown
Dragging --> Dragging: pointermove
Dragging --> Committed: pointerup + 有效目标
Dragging --> Cancelled: pointercancel
Dragging --> Cancelled: Escape
Dragging --> Cancelled: window blur
Committed --> Saving: 异步持久化
Saving --> Saved: 2xx
Saving --> Conflict: 409
Saving --> Failed: 其他错误
Conflict --> Reloading
Failed --> Retryable
Saved --> Idle
Cancelled --> Idle
Reloading --> Idle
Retryable --> Saving: 重试
关键路径如下:
pointercancel:浏览器或系统中断指针操作;window.blur:用户切换窗口,原页面可能收不到pointerup;Escape:用户主动取消;- 无效目标:指针离开所有容器;
- 保存失败:本地顺序与服务端确认顺序不一致。
如果只在 pointerup 中清理状态,遇到窗口失焦时可能永久停留在“正在拖拽”状态。至少应监听:
useEffect(() => {
const cancel = () => {
dragSessionRef.current = null;
};
window.addEventListener("blur", cancel);
return () => {
window.removeEventListener("blur", cancel);
};
}, []);
键盘取消则应在键盘处理器中清理会话,并恢复拖拽前顺序:
if (event.key === "Escape") {
event.preventDefault();
// 恢复 snapshot,清理 drag session
}
如果拖动过程中已经实时修改了列表,取消操作必须保存拖拽前的快照;如果使用“只更新占位符、放下时提交”的模型,取消则只需移除占位符。
十七、推荐的数据流
对于复杂拖拽,清晰的数据流比事件数量更重要:
用户输入
├─ Pointer Events
├─ Keyboard Events
└─ 原生文件 Drop
↓
统一解析为业务动作
↓
reducer 计算新状态
↓
React 渲染容器和项目
↓
可选:提交服务端
↓
成功、冲突或失败处理
例如:
type BoardAction =
| {
type: "move";
itemId: string;
toColumnId: string;
toIndex: number;
}
| {
type: "cancelDrag";
snapshot: BoardState;
};
指针事件不应直接修改 style.top 来代表最终排序,也不应直接调用服务端 API 来决定当前 UI 是否移动。事件层只负责识别用户意图:
“项目 b 要移动到 done 的索引 1”
业务层负责判断这个动作是否合法并生成新状态。
十八、实现取舍
适合自己实现的场景
- 容器数量和交互规则明确;
- 需要特殊的跨容器业务限制;
- 需要严格控制触摸、键盘和服务端同步;
- 可以为命中测试、占位符和无障碍反馈编写测试。
适合使用拖拽库的场景
- 需要复杂碰撞检测;
- 需要自动滚动;
- 需要拖拽预览层和占位符动画;
- 需要处理嵌套容器、虚拟列表或多选拖拽;
- 团队不希望维护大量浏览器边界代码。
使用库并不会消除核心问题。仍然要检查:
- 是否真的支持触摸;
- 是否提供键盘路径;
- 是否支持跨容器;
- 是否使用稳定 ID;
- 是否在 React 状态中表达最终顺序;
- 是否能处理取消、滚动和异步保存;
- 是否适配当前框架的客户端边界。
库负责输入和几何计算,业务代码仍应拥有 reducer、数据不变量和服务端冲突策略。
十九、测试重点应放在状态不变量
拖拽测试不应只验证“鼠标拖动后看起来对了”。至少应验证以下情况:
同容器:首位 → 末位
同容器:末位 → 首位
同容器:拖回原位置
跨容器:非空 → 非空
跨容器:非空 → 空
连续快速移动
pointercancel
window blur
Escape 取消
键盘上下移动
键盘左右跨容器
保存请求乱序返回
服务端返回 409
reducer 可以独立测试:
const next = moveItem(
initialState,
"b",
"done",
1,
);
console.log(next.columns);
/*
[
{ id: "todo", itemIds: ["a", "c"] },
{ id: "done", itemIds: ["d", "b"] }
]
*/
还应测试不变量:
const allIds = next.columns.flatMap((column) => column.itemIds);
expect(new Set(allIds).size).toBe(allIds.length);
expect(allIds.sort()).toEqual(["a", "b", "c", "d"].sort());
这样即使日后替换指针实现、接入拖拽库或增加服务端同步,核心业务行为仍然有独立保障。
拖拽交互的稳定基础可以概括为:
指针事件负责识别位置;
键盘事件负责识别离散动作;
几何算法负责计算插入点;
reducer 负责产生唯一、不可变的状态;
服务端协议负责处理确认、冲突和恢复。
只要这些职责没有混在 DOM 操作、事件闭包和异步请求中,排序、跨容器、触摸、键盘以及状态一致性就可以在同一个可验证的数据流中统一起来。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 文件上传:拖拽、分片、进度、取消、重试和预览
- 下一篇:React 表格与虚拟列表:列模型、窗口、动态高度和交互
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论