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