在当今的 Web 应用中,拖拽排序功能几乎成为了交互设计的标配。无论是任务管理工具中的任务排序、图片画廊中的图片重排,还是仪表板中的组件布局调整,直观的拖拽操作都能极大提升用户体验。然而,实现一个稳定、流畅且功能完整的拖拽排序功能并非易事,需要考虑性能、无障碍访问、移动端适配等诸多因素。
本文将带你从零开始,使用 React 和现代 Web API 构建一个功能完整的拖拽排序列表组件。我们将不仅实现基础的拖拽功能,还会深入探讨性能优化、状态管理和用户体验的细节。
为什么不用现成的库?
市面上已有不少优秀的拖拽库,如 react-dnd、dnd-kit、react-beautiful-dnd 等。它们功能强大,但在某些场景下可能显得过于臃肿,或者无法完全满足定制化需求。通过自己实现,我们可以:
- 更小的包体积:只包含我们需要的功能
- 更好的性能控制:针对特定场景进行优化
- 更深入的理解:掌握拖拽交互的核心原理
- 更强的定制能力:完全按照需求设计 API
核心实现原理
现代浏览器的拖拽 API 主要基于 HTML5 的 Drag and Drop API,但原生 API 在复杂场景下使用较为繁琐。我们将结合 Pointer Events API 来实现更精细的控制。
技术选型
- React 18+:使用函数组件和 Hooks
- TypeScript:类型安全
- CSS Modules:样式隔离
- Pointer Events:统一处理鼠标、触摸和笔输入
实现步骤
1. 基础组件结构
首先,我们定义组件的 Props 接口和基础结构:
// types.ts
export interface DraggableItem<T = any> {
id: string;
data: T;
index: number;
}
export interface DragSortListProps<T> {
items: DraggableItem<T>[];
onChange: (items: DraggableItem<T>[]) => void;
renderItem: (item: DraggableItem<T>, isDragging: boolean) => React.ReactNode;
keyExtractor?: (item: DraggableItem<T>) => string;
className?: string;
dragHandleSelector?: string;
animationDuration?: number;
}
2. 核心 Hook:useDragSort
我们将拖拽逻辑封装在一个自定义 Hook 中,实现关注点分离:
// useDragSort.ts
import { useState, useRef, useEffect, useCallback } from 'react';
interface UseDragSortOptions<T> {
items: DraggableItem<T>[];
onChange: (items: DraggableItem<T>[]) => void;
containerRef: React.RefObject<HTMLElement>;
dragHandleSelector?: string;
}
export function useDragSort<T>({
items,
onChange,
containerRef,
dragHandleSelector,
}: UseDragSortOptions<T>) {
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const dragStartY = useRef(0);
const currentIndex = useRef(0);
const itemsRef = useRef(items);
// 同步最新的 items
useEffect(() => {
itemsRef.current = items;
}, [items]);
// 计算拖拽位置
const calculateNewIndex = useCallback((clientY: number) => {
if (!containerRef.current) return -1;
const containerRect = containerRef.current.getBoundingClientRect();
const relativeY = clientY - containerRect.top;
const itemHeight = containerRect.height / itemsRef.current.length;
return Math.max(0, Math.min(
itemsRef.current.length - 1,
Math.floor(relativeY / itemHeight)
));
}, []);
// 处理拖拽开始
const handleDragStart = useCallback((id: string, clientY: number) => {
setDraggingId(id);
dragStartY.current = clientY;
const item = itemsRef.current.find(item => item.id === id);
if (item) {
currentIndex.current = item.index;
}
}, []);
// 处理拖拽移动
const handleDragMove = useCallback((clientY: number) => {
if (!draggingId) return;
const newIndex = calculateNewIndex(clientY);
const currentItem = itemsRef.current.find(item => item.id === draggingId);
if (currentItem && newIndex !== currentItem.index) {
setDragOverId(itemsRef.current[newIndex]?.id || null);
}
}, [draggingId, calculateNewIndex]);
// 处理拖拽结束
const handleDragEnd = useCallback(() => {
if (!draggingId || !dragOverId) {
setDraggingId(null);
setDragOverId(null);
return;
}
const oldIndex = currentIndex.current;
const newIndex = itemsRef.current.findIndex(item => item.id === dragOverId);
if (oldIndex !== newIndex && newIndex >= 0) {
const newItems = [...itemsRef.current];
const [draggedItem] = newItems.splice(oldIndex, 1);
newItems.splice(newIndex, 0, draggedItem);
// 更新索引
const updatedItems = newItems.map((item, index) => ({
...item,
index,
}));
onChange(updatedItems);
}
setDraggingId(null);
setDragOverId(null);
}, [draggingId, dragOverId, onChange]);
// 添加事件监听
useEffect(() => {
const handlePointerMove = (e: PointerEvent) => {
handleDragMove(e.clientY);
};
const handlePointerUp = () => {
handleDragEnd();
};
if (draggingId) {
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
}
return () => {
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
};
}, [draggingId, handleDragMove, handleDragEnd]);
return {
draggingId,
dragOverId,
handleDragStart,
};
}
3. 完整组件实现
现在,我们将 Hook 集成到完整的组件中:
// DragSortList.tsx
import React, { useRef } from 'react';
import { useDragSort } from './useDragSort';
import styles from './DragSortList.module.css';
export function DragSortList<T>({
items,
onChange,
renderItem,
keyExtractor = (item) => item.id,
className = '',
dragHandleSelector = '.drag-handle',
animationDuration = 200,
}: DragSortListProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const {
draggingId,
dragOverId,
handleDragStart,
} = useDragSort({
items,
onChange,
containerRef,
dragHandleSelector,
});
// 处理拖拽手柄的指针事件
const handlePointerDown = (e: React.PointerEvent, id: string) => {
// 检查是否点击了拖拽手柄
const target = e.target as HTMLElement;
const handle = dragHandleSelector
? target.closest(dragHandleSelector)
: target;
if (handle) {
e.preventDefault();
handleDragStart(id, e.clientY);
}
};
return (
<div
ref={containerRef}
className={`${styles.container} ${className}`}
style={{
'--animation-duration': `${animationDuration}ms`,
} as React.CSSProperties}
>
{items.map((item) => {
const isDragging = item.id === draggingId;
const isDragOver = item.id === dragOverId;
return (
<div
key={keyExtractor(item)}
className={`
${styles.item}
${isDragging ? styles.dragging : ''}
${isDragOver ? styles.dragOver : ''}
`}
onPointerDown={(e) => handlePointerDown(e, item.id)}
data-id={item.id}
>
{renderItem(item, isDragging)}
</div>
);
})}
</div>
);
}
4. 样式实现
使用 CSS Modules 实现流畅的动画效果:
/* DragSortList.module.css */
.container {
position: relative;
user-select: none;
}
.item {
position: relative;
transition: transform var(--animation-duration) ease;
touch-action: none; /* 防止触摸滚动干扰 */
}
.item.dragging {
position: fixed;
z-index: 1000;
opacity: 0.8;
transform: scale(1.05);