Tips & Tricks (更新: 2026/6/2)

用 Claude Code 实现拖拽:React 与 dnd-kit 实战指南

用 Claude Code 构建可访问的 React 拖拽排序,包含 dnd-kit、TypeScript 代码、常见坑和验证清单。

用 Claude Code 实现拖拽:React 与 dnd-kit 实战指南

拖拽功能看起来只是“按住然后移动”,但真正上线时要处理鼠标、触控、键盘、屏幕阅读器、保存时机、误操作取消和移动端布局。

如果只对 Claude Code 说“做一个 drag and drop”,它通常能生成一个视觉上能动的版本,但不一定适合生产环境。更可靠的做法是先说明数据结构、输入方式、键盘规则和验收标准。

本文使用 React + TypeScript + dnd-kit 做一个可运行的任务排序示例,并说明 3 个以上真实用例、常见坑、官方文档链接、站内延伸阅读和商业转化 CTA。

适用场景

场景例子审查重点
列表排序待读文章、任务优先级、菜单顺序只在 drop 后保存
看板销售线索、开发工单、招聘流程支持空列和跨列移动
文件上传图片、PDF、CSV类型和大小验证
仪表盘编辑图表和组件重排移动端最好单独进入编辑模式

基本概念是:用户操作 → sensor 检测输入 → DndContext 发出事件 → 更新 React state → UI 重新渲染。不要把 DOM 顺序当成真实数据,真实顺序应该放在 state 里。

Native API 还是 dnd-kit

MDN 的 HTML Drag and Drop API适合文件拖入浏览器、从外部应用传递数据等场景。React 内部的列表排序、看板和可访问交互,更推荐 dnd-kit。

方案适合弱点
HTML Drag and Drop API文件拖放、外部数据传递触控和复杂排序控制较难
dnd-kitReact 列表、看板、网格需要理解 sensors 和 context

官方参考:dnd-kit Getting starteddnd-kit Accessibility。如果你正在做文件上传,也可以参考站内的文件上传指南

给 Claude Code 的任务描述

{
  "goal": "Build an accessible React drag-and-drop sortable list with TypeScript.",
  "stack": ["React", "TypeScript", "@dnd-kit/core", "@dnd-kit/sortable"],
  "requirements": [
    "Support pointer and keyboard operation",
    "Keep React state as the source of truth",
    "Use a real button as the drag handle",
    "Show visible focus styles",
    "Save only after drag end"
  ],
  "verification": [
    "Move an item with mouse or trackpad",
    "Move an item with keyboard",
    "Cancel a drag with Escape",
    "Check the layout on mobile width"
  ]
}

延伸阅读可以看React 开发指南可访问性实现测试策略

安装

npm create vite@latest dnd-demo -- --template react-ts
cd dnd-demo
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
npm run dev

数据模型

创建 src/taskModel.ts

export type Task = {
  id: string;
  title: string;
  note: string;
};

export const initialTasks: Task[] = [
  { id: "task-1", title: "Write acceptance criteria", note: "Define the done state before coding." },
  { id: "task-2", title: "Build sortable UI", note: "Use dnd-kit sensors and React state." },
  { id: "task-3", title: "Check keyboard flow", note: "Tab, Space, Arrow keys, Enter, Escape." },
  { id: "task-4", title: "Review file fallback", note: "Keep input type=file for upload screens." },
];

React 组件

src/App.tsx 替换为下面的代码。

import { useState, type CSSProperties } from "react";
import {
  DndContext,
  KeyboardSensor,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  SortableContext,
  arrayMove,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import "./App.css";
import { initialTasks, type Task } from "./taskModel";

export default function App() {
  const [tasks, setTasks] = useState<Task[]>(initialTasks);
  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  );

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;

    if (!over || active.id === over.id) {
      return;
    }

    setTasks((currentTasks) => {
      const oldIndex = currentTasks.findIndex((task) => task.id === active.id);
      const newIndex = currentTasks.findIndex((task) => task.id === over.id);

      if (oldIndex < 0 || newIndex < 0) {
        return currentTasks;
      }

      return arrayMove(currentTasks, oldIndex, newIndex);
    });
  }

  return (
    <main className="appShell">
      <h1>Accessible task ordering</h1>
      <p id="dnd-help" className="helpText">
        Use Tab to reach a handle. Press Space or Enter to lift, arrow keys to move,
        Space or Enter to drop, and Escape to cancel.
      </p>

      <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
        <SortableContext items={tasks.map((task) => task.id)} strategy={verticalListSortingStrategy}>
          <ul className="taskList" aria-describedby="dnd-help">
            {tasks.map((task) => (
              <SortableTask key={task.id} task={task} />
            ))}
          </ul>
        </SortableContext>
      </DndContext>
    </main>
  );
}

function SortableTask({ task }: { task: Task }) {
  const {
    attributes,
    listeners,
    setActivatorNodeRef,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: task.id });

  const style: CSSProperties = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  return (
    <li ref={setNodeRef} style={style} className={`taskCard ${isDragging ? "dragging" : ""}`}>
      <button
        ref={setActivatorNodeRef}
        type="button"
        className="dragHandle"
        aria-label={`Move ${task.title}`}
        {...attributes}
        {...listeners}
      >
        <span aria-hidden="true">↕</span>
      </button>
      <div>
        <h2>{task.title}</h2>
        <p>{task.note}</p>
      </div>
    </li>
  );
}

CSS

创建或替换 src/App.css

:root {
  color: #172033;
  background: #f6f7fb;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}

body {
  margin: 0;
}

button {
  font: inherit;
}

.appShell {
  max-width: 760px;
  margin: 0 auto;
  padding: 32px 20px;
}

.helpText {
  color: #526070;
  line-height: 1.6;
}

.taskList {
  display: grid;
  gap: 12px;
  padding: 0;
  list-style: none;
}

.taskCard {
  display: grid;
  grid-template-columns: 44px minmax(0, 1fr);
  gap: 12px;
  align-items: start;
  padding: 14px;
  border: 1px solid #d9deea;
  border-radius: 8px;
  background: #ffffff;
  box-shadow: 0 8px 24px rgba(23, 32, 51, 0.08);
}

.dragging {
  opacity: 0.58;
}

.taskCard h2 {
  margin: 0 0 4px;
  font-size: 1rem;
}

.taskCard p {
  margin: 0;
  color: #526070;
  line-height: 1.5;
}

.dragHandle {
  display: grid;
  width: 36px;
  height: 36px;
  place-items: center;
  border: 1px solid #c8d0df;
  border-radius: 8px;
  background: #f8fafc;
  cursor: grab;
}

.dragHandle:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .taskCard {
    transition: none;
  }
}

可访问性与常见坑

发布前至少检查:Tab 能到达每个拖拽按钮;Space/Enter 可以开始和结束拖拽;方向键可以移动;Escape 可以取消;焦点样式清楚;移动端宽度不遮挡文字。文件上传场景不要只依赖拖拽,保留真实的 <input type="file">

常见坑有四个:在每次 dragover 时保存到服务器;忘记空列;只支持鼠标;让 Claude Code 自由选择库和交互规则。把验收标准写清楚,代码质量会稳定很多。

商业 CTA 与验证记录

如果团队要把这种交互放进内部产品,建议把规则写进 CLAUDE.md,并参考CLAUDE.md 模板。ClaudeCodeLab 提供Claude Code 培训与咨询以及模板和教材,可以把拖拽、上传、看板等 UI 变成团队可复用的开发标准。

实际验证时,先确定 state 结构再让 Claude Code 编码,返工最少。把键盘移动、Escape 取消、移动端宽度和保存时机加入清单后,能很快发现“鼠标看起来能用但还不能上线”的版本。

总结

用 Claude Code 实现拖拽时,真正决定质量的不是它能多快写出一段会动的代码,而是你有没有在动手前把数据结构、输入方式、键盘规则和保存时机讲清楚。先确定 state 作为唯一可信来源,再让 Claude Code 围绕这个结构生成 sensor、DndContext 和排序逻辑,返工会明显减少。

库的选择也要按场景来。React 内部的列表排序、看板和需要可访问性的交互优先用 dnd-kit,同时启用 PointerSensorKeyboardSensor,并把真正的 <button> 用作拖拽 handle;只有从桌面拖入文件、与外部应用传递数据这类场景,才更适合 MDN 的原生 HTML Drag and Drop API。两者不是二选一的对立,而是各自覆盖不同的需求。

发布前请把这份清单走一遍:用鼠标或触控板能移动条目,用键盘也能移动,Space 或 Enter 能抬起和放下,Escape 能取消,焦点样式清晰可见,移动端宽度下文字不被遮挡。文件上传场景不要只依赖拖拽,务必保留 <input type="file"> 作为键盘可用的后备。把这些验收标准交给 Claude Code 当作 review checklist 的一部分,让它逐条用文件名和操作步骤给出证据,就能在早期发现那种“鼠标看起来能用、却还不能上线”的版本。

几个最常见的坑也值得在收尾时再强调一遍。第一,不要在每次 dragover 时都向服务器保存——拖动过程中事件会反复触发,保存应当只在拖放确定后执行,中间状态交给 React state 即可。第二,别忘了把空列也设为可放置区域,否则一旦某列没有卡片就会卡住,在真实看板里很快就会暴露。第三,永远把 state 当作排序的真实来源,而不是去读取 DOM 顺序;要持久化时,把 state 里的数组发给 API。把这几条连同键盘和可访问性要求一起写进 CLAUDE.md,团队后续生成的拖拽代码质量会稳定得多。

以这个示例为基础,你可以扩展到任务管理、文章排序、图片画廊、销售看板、仪表盘编辑等界面。无论应用到哪一种,先把 state 的形状、保存时机和键盘替代操作定下来,都是最省力、也最稳妥的第一步。

#Claude Code #拖拽 #React #UI #可访问性
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。