React 基础体系 · 第 38/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 拖拽交互:排序、跨容器、触摸、键盘和状态一致性

拖拽看起来像“按住一个元素,把它移动到另一个位置”,但一个可用的 React 拖拽交互实际上同时包含几件事:

  1. 排序:同一容器内改变项目顺序。
  2. 跨容器移动:把项目从一个列表移动到另一个列表。
  3. 触摸交互:在手机或平板上用手指完成拖动。
  4. 键盘交互:不依赖鼠标或触摸完成拾取、移动和放置。
  5. 状态一致性:界面状态、拖拽中的临时状态、服务端持久化状态之间不能互相覆盖或产生重复项目。

这些问题不能只靠一个 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]

其垂直中心为:

middle=top+bottom2middle = \frac{top + bottom}{2}

给定指针纵坐标 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 放到 doneY 后面:

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"] },
  ],
};

这种归一化结构有三个好处:

  1. 项目内容只有一个来源,修改标题不会出现副本不一致;
  2. 排序只修改 itemIds,不会误复制整个对象;
  3. 可以用项目 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);

问题有两个:

  1. 直接修改旧状态;
  2. 传入同一个顶层对象,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 时不能静默覆盖本地状态,应该:

  1. 重新获取服务端快照;
  2. 告知用户顺序发生冲突;
  3. 选择重新应用本地操作,或让用户确认。

如果业务允许“最后写入者获胜”,也应明确这是产品策略,而不是偶然由网络时序决定的结果。


十三、异步保存的一个可靠实现

下面展示一个简化的“本地立即更新、串行保存”模型:

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);

同时确认 pointercancelpointerup 都会清理会话。


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 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。