从零到一:构建一个现代化的 React 拖拽排序列表

4 阅读1分钟

在当今的 Web 应用中,拖拽排序功能几乎成为了交互设计的标配。无论是任务管理工具中的任务排序、图片画廊中的图片重排,还是仪表板中的组件布局调整,直观的拖拽操作都能极大提升用户体验。然而,实现一个稳定、流畅且功能完整的拖拽排序功能并非易事,需要考虑性能、无障碍访问、移动端适配等诸多因素。

本文将带你从零开始,使用 React 和现代 Web API 构建一个功能完整的拖拽排序列表组件。我们将不仅实现基础的拖拽功能,还会深入探讨性能优化、状态管理和用户体验的细节。

为什么不用现成的库?

市面上已有不少优秀的拖拽库,如 react-dnddnd-kitreact-beautiful-dnd 等。它们功能强大,但在某些场景下可能显得过于臃肿,或者无法完全满足定制化需求。通过自己实现,我们可以:

  1. 更小的包体积:只包含我们需要的功能
  2. 更好的性能控制:针对特定场景进行优化
  3. 更深入的理解:掌握拖拽交互的核心原理
  4. 更强的定制能力:完全按照需求设计 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);