Skip to content

拖拽排序实战

概述

拖拽排序是现代Web应用中常见的交互模式,广泛应用于任务管理、内容编排、列表组织等场景。本文将通过实战案例深入讲解如何实现各种拖拽排序功能,涵盖单列表、多列表、嵌套列表、网格布局等复杂场景,并提供完整的代码实现和最佳实践。

基础排序实现

简单列表排序

tsx
import { DndContext, closestCenter, DragEndEvent } from '@dnd-kit/core';
import {
  arrayMove,
  SortableContext,
  useSortable,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

interface Item {
  id: string;
  content: string;
}

function SimpleSortableList() {
  const [items, setItems] = useState<Item[]>([
    { id: '1', content: 'Item 1' },
    { id: '2', content: 'Item 2' },
    { id: '3', content: 'Item 3' },
    { id: '4', content: 'Item 4' },
    { id: '5', content: 'Item 5' },
  ]);
  
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    
    if (over && active.id !== over.id) {
      setItems((items) => {
        const oldIndex = items.findIndex((item) => item.id === active.id);
        const newIndex = items.findIndex((item) => item.id === over.id);
        
        return arrayMove(items, oldIndex, newIndex);
      });
    }
  };
  
  return (
    <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
      <SortableContext items={items} strategy={verticalListSortingStrategy}>
        <div className="sortable-list">
          {items.map((item) => (
            <SortableItem key={item.id} item={item} />
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}

function SortableItem({ item }: { item: Item }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: item.id });
  
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };
  
  return (
    <div
      ref={setNodeRef}
      style={style}
      className="sortable-item"
      {...attributes}
      {...listeners}
    >
      <div className="drag-handle">⋮⋮</div>
      <div className="content">{item.content}</div>
    </div>
  );
}

带拖拽句柄的排序

tsx
function SortableItemWithHandle({ item }: { item: Item }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: item.id });
  
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };
  
  return (
    <div ref={setNodeRef} style={style} className="sortable-item">
      <button
        className="drag-handle"
        {...attributes}
        {...listeners}
        aria-label="Drag handle"
      >
        <svg width="20" height="20" viewBox="0 0 20 20">
          <path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z" />
        </svg>
      </button>
      <div className="content">{item.content}</div>
      <button className="delete-button" onClick={() => handleDelete(item.id)}>
        ×
      </button>
    </div>
  );
}

多列表排序

看板式多列表

tsx
interface Column {
  id: string;
  title: string;
}

interface Task {
  id: string;
  content: string;
  columnId: string;
}

function MultiColumnBoard() {
  const [columns] = useState<Column[]>([
    { id: 'todo', title: 'To Do' },
    { id: 'inProgress', title: 'In Progress' },
    { id: 'review', title: 'Review' },
    { id: 'done', title: 'Done' },
  ]);
  
  const [tasks, setTasks] = useState<Task[]>([
    { id: '1', content: 'Design homepage', columnId: 'todo' },
    { id: '2', content: 'Implement API', columnId: 'todo' },
    { id: '3', content: 'Write tests', columnId: 'inProgress' },
    { id: '4', content: 'Code review', columnId: 'review' },
    { id: '5', content: 'Deploy to prod', columnId: 'done' },
  ]);
  
  const [activeId, setActiveId] = useState<string | null>(null);
  
  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: {
        distance: 8,
      },
    }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );
  
  const findContainer = (id: string) => {
    if (columns.find((col) => col.id === id)) {
      return id;
    }
    
    return tasks.find((task) => task.id === id)?.columnId;
  };
  
  const handleDragStart = (event: DragStartEvent) => {
    setActiveId(event.active.id as string);
  };
  
  const handleDragOver = (event: DragOverEvent) => {
    const { active, over } = event;
    if (!over) return;
    
    const activeId = active.id as string;
    const overId = over.id as string;
    
    const activeContainer = findContainer(activeId);
    const overContainer = findContainer(overId);
    
    if (!activeContainer || !overContainer) return;
    
    if (activeContainer !== overContainer) {
      setTasks((tasks) => {
        const activeIndex = tasks.findIndex((t) => t.id === activeId);
        const overIndex = tasks.findIndex((t) => t.id === overId);
        
        const activeTask = tasks[activeIndex];
        const newTasks = [...tasks];
        
        newTasks[activeIndex] = { ...activeTask, columnId: overContainer };
        
        return arrayMove(newTasks, activeIndex, overIndex >= 0 ? overIndex : newTasks.length - 1);
      });
    }
  };
  
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    
    if (!over) {
      setActiveId(null);
      return;
    }
    
    const activeId = active.id as string;
    const overId = over.id as string;
    
    const activeContainer = findContainer(activeId);
    const overContainer = findContainer(overId);
    
    if (!activeContainer || !overContainer) {
      setActiveId(null);
      return;
    }
    
    if (activeContainer === overContainer) {
      const activeIndex = tasks.findIndex((t) => t.id === activeId);
      const overIndex = tasks.findIndex((t) => t.id === overId);
      
      if (activeIndex !== overIndex) {
        setTasks((tasks) => arrayMove(tasks, activeIndex, overIndex));
      }
    }
    
    setActiveId(null);
  };
  
  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCorners}
      onDragStart={handleDragStart}
      onDragOver={handleDragOver}
      onDragEnd={handleDragEnd}
    >
      <div className="board">
        {columns.map((column) => (
          <BoardColumn
            key={column.id}
            column={column}
            tasks={tasks.filter((t) => t.columnId === column.id)}
          />
        ))}
      </div>
      
      <DragOverlay>
        {activeId ? (
          <TaskCard task={tasks.find((t) => t.id === activeId)!} />
        ) : null}
      </DragOverlay>
    </DndContext>
  );
}

function BoardColumn({ column, tasks }: { column: Column; tasks: Task[] }) {
  const { setNodeRef } = useDroppable({
    id: column.id,
  });
  
  return (
    <div ref={setNodeRef} className="board-column">
      <h3 className="column-header">
        {column.title}
        <span className="task-count">{tasks.length}</span>
      </h3>
      
      <SortableContext items={tasks} strategy={verticalListSortingStrategy}>
        <div className="task-list">
          {tasks.map((task) => (
            <TaskCard key={task.id} task={task} />
          ))}
        </div>
      </SortableContext>
    </div>
  );
}

function TaskCard({ task }: { task: Task }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: task.id });
  
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.3 : 1,
  };
  
  return (
    <div ref={setNodeRef} style={style} className="task-card" {...attributes} {...listeners}>
      {task.content}
    </div>
  );
}

跨容器拖拽

tsx
function CrossContainerDrag() {
  const [containers, setContainers] = useState({
    container1: ['item-1', 'item-2', 'item-3'],
    container2: ['item-4', 'item-5'],
    container3: ['item-6', 'item-7', 'item-8'],
  });
  
  const [activeId, setActiveId] = useState<string | null>(null);
  
  const findContainer = (id: string): string | undefined => {
    if (id in containers) {
      return id;
    }
    
    return Object.keys(containers).find((key) =>
      containers[key as keyof typeof containers].includes(id)
    );
  };
  
  const handleDragStart = (event: DragStartEvent) => {
    setActiveId(event.active.id as string);
  };
  
  const handleDragOver = (event: DragOverEvent) => {
    const { active, over } = event;
    if (!over) return;
    
    const activeContainer = findContainer(active.id as string);
    const overContainer = findContainer(over.id as string);
    
    if (!activeContainer || !overContainer || activeContainer === overContainer) {
      return;
    }
    
    setContainers((prev) => {
      const activeItems = prev[activeContainer as keyof typeof prev];
      const overItems = prev[overContainer as keyof typeof prev];
      
      const activeIndex = activeItems.indexOf(active.id as string);
      const overIndex = overItems.indexOf(over.id as string);
      
      const newActiveItems = activeItems.filter((item) => item !== active.id);
      const newOverItems = [...overItems];
      newOverItems.splice(overIndex >= 0 ? overIndex : newOverItems.length, 0, active.id as string);
      
      return {
        ...prev,
        [activeContainer]: newActiveItems,
        [overContainer]: newOverItems,
      };
    });
  };
  
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    
    if (!over) {
      setActiveId(null);
      return;
    }
    
    const activeContainer = findContainer(active.id as string);
    const overContainer = findContainer(over.id as string);
    
    if (activeContainer && overContainer && activeContainer === overContainer) {
      const items = containers[activeContainer as keyof typeof containers];
      const activeIndex = items.indexOf(active.id as string);
      const overIndex = items.indexOf(over.id as string);
      
      if (activeIndex !== overIndex) {
        setContainers((prev) => ({
          ...prev,
          [activeContainer]: arrayMove(items, activeIndex, overIndex),
        }));
      }
    }
    
    setActiveId(null);
  };
  
  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragStart={handleDragStart}
      onDragOver={handleDragOver}
      onDragEnd={handleDragEnd}
    >
      <div className="containers-wrapper">
        {Object.keys(containers).map((containerId) => (
          <DroppableContainer
            key={containerId}
            id={containerId}
            items={containers[containerId as keyof typeof containers]}
          />
        ))}
      </div>
      
      <DragOverlay>
        {activeId ? <div className="drag-overlay-item">{activeId}</div> : null}
      </DragOverlay>
    </DndContext>
  );
}

嵌套列表排序

树形结构拖拽

tsx
interface TreeNode {
  id: string;
  content: string;
  children?: TreeNode[];
}

function NestedSortableTree() {
  const [tree, setTree] = useState<TreeNode[]>([
    {
      id: '1',
      content: 'Root 1',
      children: [
        { id: '1-1', content: 'Child 1-1' },
        {
          id: '1-2',
          content: 'Child 1-2',
          children: [
            { id: '1-2-1', content: 'Grandchild 1-2-1' },
          ],
        },
      ],
    },
    {
      id: '2',
      content: 'Root 2',
      children: [
        { id: '2-1', content: 'Child 2-1' },
      ],
    },
  ]);
  
  const [activeId, setActiveId] = useState<string | null>(null);
  
  const flattenTree = (nodes: TreeNode[], parentId: string | null = null): FlatNode[] => {
    return nodes.reduce<FlatNode[]>((acc, node) => {
      const flatNode: FlatNode = {
        id: node.id,
        content: node.content,
        parentId,
        children: node.children?.map((child) => child.id) || [],
      };
      
      acc.push(flatNode);
      
      if (node.children) {
        acc.push(...flattenTree(node.children, node.id));
      }
      
      return acc;
    }, []);
  };
  
  const buildTree = (flatNodes: FlatNode[]): TreeNode[] => {
    const nodeMap = new Map<string, TreeNode>();
    const roots: TreeNode[] = [];
    
    flatNodes.forEach((node) => {
      nodeMap.set(node.id, {
        id: node.id,
        content: node.content,
        children: [],
      });
    });
    
    flatNodes.forEach((node) => {
      const treeNode = nodeMap.get(node.id)!;
      
      if (node.parentId) {
        const parent = nodeMap.get(node.parentId);
        if (parent) {
          if (!parent.children) parent.children = [];
          parent.children.push(treeNode);
        }
      } else {
        roots.push(treeNode);
      }
    });
    
    return roots;
  };
  
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    
    if (!over || active.id === over.id) {
      setActiveId(null);
      return;
    }
    
    const flatNodes = flattenTree(tree);
    const activeNode = flatNodes.find((n) => n.id === active.id);
    const overNode = flatNodes.find((n) => n.id === over.id);
    
    if (!activeNode || !overNode) {
      setActiveId(null);
      return;
    }
    
    // 更新树结构
    const updatedFlatNodes = flatNodes.map((node) => {
      if (node.id === activeNode.id) {
        return { ...node, parentId: overNode.id };
      }
      return node;
    });
    
    setTree(buildTree(updatedFlatNodes));
    setActiveId(null);
  };
  
  return (
    <DndContext onDragStart={(e) => setActiveId(e.active.id as string)} onDragEnd={handleDragEnd}>
      <TreeNodeList nodes={tree} />
      
      <DragOverlay>
        {activeId ? <div className="tree-node-overlay">{activeId}</div> : null}
      </DragOverlay>
    </DndContext>
  );
}

function TreeNodeList({ nodes }: { nodes: TreeNode[] }) {
  return (
    <SortableContext items={nodes.map((n) => n.id)}>
      {nodes.map((node) => (
        <TreeNodeItem key={node.id} node={node} />
      ))}
    </SortableContext>
  );
}

function TreeNodeItem({ node }: { node: TreeNode }) {
  const [isExpanded, setIsExpanded] = useState(true);
  
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: node.id });
  
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };
  
  return (
    <div className="tree-node">
      <div ref={setNodeRef} style={style} className="tree-node-content" {...attributes} {...listeners}>
        {node.children && node.children.length > 0 && (
          <button
            className="expand-button"
            onClick={() => setIsExpanded(!isExpanded)}
          >
            {isExpanded ? '▼' : '▶'}
          </button>
        )}
        <span>{node.content}</span>
      </div>
      
      {isExpanded && node.children && node.children.length > 0 && (
        <div className="tree-node-children">
          <TreeNodeList nodes={node.children} />
        </div>
      )}
    </div>
  );
}

网格布局排序

响应式网格拖拽

tsx
function GridSortable() {
  const [items, setItems] = useState(
    Array.from({ length: 20 }, (_, i) => ({
      id: `item-${i + 1}`,
      content: `Item ${i + 1}`,
    }))
  );
  
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor)
  );
  
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    
    if (over && active.id !== over.id) {
      setItems((items) => {
        const oldIndex = items.findIndex((item) => item.id === active.id);
        const newIndex = items.findIndex((item) => item.id === over.id);
        
        return arrayMove(items, oldIndex, newIndex);
      });
    }
  };
  
  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={handleDragEnd}
    >
      <SortableContext items={items} strategy={rectSortingStrategy}>
        <div className="grid-container">
          {items.map((item) => (
            <GridItem key={item.id} item={item} />
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}

function GridItem({ item }: { item: { id: string; content: string } }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: item.id });
  
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };
  
  return (
    <div
      ref={setNodeRef}
      style={style}
      className="grid-item"
      {...attributes}
      {...listeners}
    >
      {item.content}
    </div>
  );
}

瀑布流布局拖拽

tsx
function MasonryGrid() {
  const [items, setItems] = useState(
    Array.from({ length: 15 }, (_, i) => ({
      id: `item-${i + 1}`,
      content: `Item ${i + 1}`,
      height: Math.floor(Math.random() * 200) + 100,
    }))
  );
  
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    
    if (over && active.id !== over.id) {
      setItems((items) => {
        const oldIndex = items.findIndex((item) => item.id === active.id);
        const newIndex = items.findIndex((item) => item.id === over.id);
        
        return arrayMove(items, oldIndex, newIndex);
      });
    }
  };
  
  return (
    <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
      <SortableContext items={items} strategy={rectSortingStrategy}>
        <div className="masonry-grid">
          {items.map((item) => (
            <MasonryItem key={item.id} item={item} />
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}

function MasonryItem({ item }: { item: { id: string; content: string; height: number } }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: item.id });
  
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
    height: item.height,
  };
  
  return (
    <div
      ref={setNodeRef}
      style={style}
      className="masonry-item"
      {...attributes}
      {...listeners}
    >
      {item.content}
    </div>
  );
}

高级功能实现

分组与折叠

tsx
interface GroupedItem {
  id: string;
  content: string;
  groupId: string;
}

interface Group {
  id: string;
  title: string;
  isCollapsed: boolean;
}

function GroupedSortable() {
  const [groups, setGroups] = useState<Group[]>([
    { id: 'group-1', title: 'Group 1', isCollapsed: false },
    { id: 'group-2', title: 'Group 2', isCollapsed: false },
  ]);
  
  const [items, setItems] = useState<GroupedItem[]>([
    { id: '1', content: 'Item 1', groupId: 'group-1' },
    { id: '2', content: 'Item 2', groupId: 'group-1' },
    { id: '3', content: 'Item 3', groupId: 'group-2' },
  ]);
  
  const toggleGroup = (groupId: string) => {
    setGroups((groups) =>
      groups.map((g) =>
        g.id === groupId ? { ...g, isCollapsed: !g.isCollapsed } : g
      )
    );
  };
  
  const handleDragEnd = (event: DragEndEvent) => {
    // 处理拖拽结束
  };
  
  return (
    <DndContext onDragEnd={handleDragEnd}>
      {groups.map((group) => (
        <div key={group.id} className="group">
          <div className="group-header" onClick={() => toggleGroup(group.id)}>
            <span>{group.isCollapsed ? '▶' : '▼'}</span>
            <h3>{group.title}</h3>
            <span className="item-count">
              {items.filter((i) => i.groupId === group.id).length}
            </span>
          </div>
          
          {!group.isCollapsed && (
            <SortableContext
              items={items.filter((i) => i.groupId === group.id)}
              strategy={verticalListSortingStrategy}
            >
              {items
                .filter((i) => i.groupId === group.id)
                .map((item) => (
                  <SortableItem key={item.id} item={item} />
                ))}
            </SortableContext>
          )}
        </div>
      ))}
    </DndContext>
  );
}

拖拽复制

tsx
function DragToCopy() {
  const [sourceItems] = useState([
    { id: 'source-1', content: 'Template 1' },
    { id: 'source-2', content: 'Template 2' },
    { id: 'source-3', content: 'Template 3' },
  ]);
  
  const [targetItems, setTargetItems] = useState<Array<{ id: string; content: string }>>([]);
  
  const sensors = useSensors(useSensor(PointerSensor));
  
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    
    if (!over || over.id !== 'target-container') return;
    
    const sourceItem = sourceItems.find((item) => item.id === active.id);
    
    if (sourceItem) {
      const newItem = {
        id: `${sourceItem.id}-${Date.now()}`,
        content: sourceItem.content,
      };
      
      setTargetItems((items) => [...items, newItem]);
    }
  };
  
  return (
    <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
      <div className="drag-copy-container">
        <div className="source-panel">
          <h3>Templates</h3>
          {sourceItems.map((item) => (
            <DraggableTemplate key={item.id} item={item} />
          ))}
        </div>
        
        <DropTarget id="target-container">
          <h3>Drop Here</h3>
          {targetItems.map((item) => (
            <div key={item.id} className="copied-item">
              {item.content}
            </div>
          ))}
        </DropTarget>
      </div>
    </DndContext>
  );
}

function DraggableTemplate({ item }: { item: { id: string; content: string } }) {
  const { attributes, listeners, setNodeRef, transform } = useDraggable({
    id: item.id,
  });
  
  const style = {
    transform: CSS.Transform.toString(transform),
  };
  
  return (
    <div
      ref={setNodeRef}
      style={style}
      className="template-item"
      {...attributes}
      {...listeners}
    >
      {item.content}
    </div>
  );
}

拖拽限制

tsx
function RestrictedDrag() {
  const [items, setItems] = useState([
    { id: '1', content: 'Draggable', locked: false },
    { id: '2', content: 'Locked', locked: true },
    { id: '3', content: 'Draggable', locked: false },
  ]);
  
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    
    if (!over || active.id === over.id) return;
    
    const activeItem = items.find((i) => i.id === active.id);
    const overItem = items.find((i) => i.id === over.id);
    
    // 不允许拖放到锁定项
    if (overItem?.locked) return;
    
    setItems((items) => {
      const oldIndex = items.findIndex((i) => i.id === active.id);
      const newIndex = items.findIndex((i) => i.id === over.id);
      
      return arrayMove(items, oldIndex, newIndex);
    });
  };
  
  return (
    <DndContext onDragEnd={handleDragEnd}>
      <SortableContext items={items}>
        {items.map((item) => (
          <RestrictedItem key={item.id} item={item} />
        ))}
      </SortableContext>
    </DndContext>
  );
}

function RestrictedItem({ item }: { item: { id: string; content: string; locked: boolean } }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
  } = useSortable({
    id: item.id,
    disabled: item.locked,
  });
  
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: item.locked ? 0.5 : 1,
    cursor: item.locked ? 'not-allowed' : 'grab',
  };
  
  return (
    <div
      ref={setNodeRef}
      style={style}
      className="restricted-item"
      {...(!item.locked ? { ...attributes, ...listeners } : {})}
    >
      {item.locked && <span className="lock-icon">🔒</span>}
      {item.content}
    </div>
  );
}

性能优化

虚拟滚动集成

tsx
import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualSortableList() {
  const [items, setItems] = useState(
    Array.from({ length: 10000 }, (_, i) => ({
      id: `item-${i}`,
      content: `Item ${i}`,
    }))
  );
  
  const parentRef = useRef<HTMLDivElement>(null);
  
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
    overscan: 5,
  });
  
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    
    if (over && active.id !== over.id) {
      setItems((items) => {
        const oldIndex = items.findIndex((item) => item.id === active.id);
        const newIndex = items.findIndex((item) => item.id === over.id);
        
        return arrayMove(items, oldIndex, newIndex);
      });
    }
  };
  
  return (
    <DndContext onDragEnd={handleDragEnd}>
      <div ref={parentRef} className="virtual-list-container">
        <div
          style={{
            height: virtualizer.getTotalSize(),
            width: '100%',
            position: 'relative',
          }}
        >
          <SortableContext items={items}>
            {virtualizer.getVirtualItems().map((virtualRow) => {
              const item = items[virtualRow.index];
              return (
                <div
                  key={item.id}
                  style={{
                    position: 'absolute',
                    top: 0,
                    left: 0,
                    width: '100%',
                    height: virtualRow.size,
                    transform: `translateY(${virtualRow.start}px)`,
                  }}
                >
                  <VirtualSortableItem item={item} />
                </div>
              );
            })}
          </SortableContext>
        </div>
      </div>
    </DndContext>
  );
}

批量操作优化

tsx
function BatchOperations() {
  const [items, setItems] = useState<Item[]>([]);
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
  
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    
    if (!over || active.id === over.id) return;
    
    // 如果拖拽的项在选中列表中,移动所有选中项
    if (selectedIds.has(active.id as string)) {
      const selectedItems = items.filter((item) => selectedIds.has(item.id));
      const unselectedItems = items.filter((item) => !selectedIds.has(item.id));
      
      const overIndex = items.findIndex((item) => item.id === over.id);
      
      const newItems = [...unselectedItems];
      newItems.splice(overIndex, 0, ...selectedItems);
      
      setItems(newItems);
    } else {
      // 单个项移动
      const oldIndex = items.findIndex((item) => item.id === active.id);
      const newIndex = items.findIndex((item) => item.id === over.id);
      
      setItems(arrayMove(items, oldIndex, newIndex));
    }
  };
  
  const toggleSelection = (id: string) => {
    setSelectedIds((prev) => {
      const newSet = new Set(prev);
      if (newSet.has(id)) {
        newSet.delete(id);
      } else {
        newSet.add(id);
      }
      return newSet;
    });
  };
  
  return (
    <DndContext onDragEnd={handleDragEnd}>
      <SortableContext items={items}>
        {items.map((item) => (
          <SelectableItem
            key={item.id}
            item={item}
            isSelected={selectedIds.has(item.id)}
            onSelect={() => toggleSelection(item.id)}
          />
        ))}
      </SortableContext>
    </DndContext>
  );
}

数据持久化

本地存储

tsx
function PersistentSortable() {
  const STORAGE_KEY = 'sortable-items';
  
  const [items, setItems] = useState<Item[]>(() => {
    const saved = localStorage.getItem(STORAGE_KEY);
    return saved ? JSON.parse(saved) : defaultItems;
  });
  
  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
  }, [items]);
  
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    
    if (over && active.id !== over.id) {
      setItems((items) => {
        const oldIndex = items.findIndex((item) => item.id === active.id);
        const newIndex = items.findIndex((item) => item.id === over.id);
        
        return arrayMove(items, oldIndex, newIndex);
      });
    }
  };
  
  return (
    <DndContext onDragEnd={handleDragEnd}>
      <SortableContext items={items}>
        {items.map((item) => (
          <SortableItem key={item.id} item={item} />
        ))}
      </SortableContext>
    </DndContext>
  );
}

服务器同步

tsx
function ServerSyncedSortable() {
  const [items, setItems] = useState<Item[]>([]);
  const [isSaving, setIsSaving] = useState(false);
  
  const debouncedSave = useMemo(
    () =>
      debounce(async (newItems: Item[]) => {
        setIsSaving(true);
        try {
          await fetch('/api/save-order', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ items: newItems }),
          });
        } catch (error) {
          console.error('Failed to save order:', error);
        } finally {
          setIsSaving(false);
        }
      }, 1000),
    []
  );
  
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    
    if (over && active.id !== over.id) {
      setItems((items) => {
        const oldIndex = items.findIndex((item) => item.id === active.id);
        const newIndex = items.findIndex((item) => item.id === over.id);
        const newItems = arrayMove(items, oldIndex, newIndex);
        
        debouncedSave(newItems);
        
        return newItems;
      });
    }
  };
  
  return (
    <>
      {isSaving && <div className="saving-indicator">Saving...</div>}
      <DndContext onDragEnd={handleDragEnd}>
        <SortableContext items={items}>
          {items.map((item) => (
            <SortableItem key={item.id} item={item} />
          ))}
        </SortableContext>
      </DndContext>
    </>
  );
}

最佳实践总结

性能优化

✅ 使用虚拟滚动处理大列表
✅ 使用DragOverlay减少重渲染
✅ 合理使用useMemo和useCallback
✅ 防抖API调用
✅ 优化碰撞检测算法

用户体验

✅ 提供清晰的拖拽反馈
✅ 支持键盘操作
✅ 显示保存状态
✅ 提供撤销功能
✅ 处理错误状态

可访问性

✅ ARIA标签支持
✅ 键盘导航
✅ 屏幕阅读器支持
✅ 焦点管理
✅ 语义化HTML

拖拽排序是提升用户体验的重要交互方式。通过合理的设计和实现,可以为用户提供直观、流畅的操作体验。掌握这些实战技巧,你可以在各种场景中灵活应用拖拽排序功能。