阅读视图

发现新文章,点击刷新页面。

学习React-DnD:实现多任务项拖拽-useDrop处理

在上一篇技术分享中,我们聚焦于useDrag钩子实现Todo任务项的拖动触发逻辑,完成了任务的选中与拖拽启动功能。而拖拽交互的闭环,必然离不开放置接收环节——当用户将任务拖拽到目标位置时,如何精准判断拖动类型(单个/多个)并执行对应的排序逻辑,才是确保交互流畅性的核心。本文将详细拆解这一环节的实现思路,重点解析批量排序操作的设计与hover事件的逻辑处理。

SelectTodosDrop.gif

核心问题:区分拖动类型,匹配差异化逻辑

任务拖拽的放置接收环节,首要解决的问题是“识别当前拖动场景”。当用户拖拽任务时,存在两种典型场景:单个任务独立拖动、多个已选中任务批量拖动。这两种场景的排序逻辑存在本质差异:单个任务只需处理“源位置”与“目标位置”的双向交换;而批量任务则需要先提取所有选中项,再整体插入目标位置,同时保持选中项内部的相对顺序。

针对这一差异,我们的技术方案分为两步:一是新增批量排序的Context操作类型,专门处理多任务排序逻辑;二是在useDrop的hover事件中添加类型判断,根据是否为批量拖动执行对应逻辑。

第一步:实现批量排序操作BATCH_REORDER_TODOS

单个任务排序可通过简单的数组元素交换实现,但批量排序需要处理“选中项提取-目标位置计算-选中项插入”三个核心步骤。为此,我们新增BATCH_REORDER_TODOS操作类型,封装完整的批量排序逻辑。

1.1 核心设计思路

批量排序的核心需求是:将所有选中的任务作为一个整体,移动到目标位置,并保持选中项在原始数组中的相对顺序。具体思路如下:

  • 边界校验:排除“无选中任务”“目标位置越界”等无效场景;
  • 选中项排序:确保选中任务的顺序与原始数组一致,避免排序混乱;
  • 分离数组:将原始任务数组拆分为“选中任务”和“非选中任务”两个集合;
  • 目标位置校准:计算选中任务在非选中数组中的实际插入位置;
  • 数组重组:将选中任务整体插入目标位置,形成新的任务数组。

1.2 完整代码实现与解析

以下是BATCH_REORDER_TODOS在Context reducer中的实现代码,关键步骤已添加详细注释:

case ActionTypes.BATCH_REORDER_TODOS:
  {
    // 从action中获取目标位置索引和移动方向
    const { destinationIndex, direction } = action.payload;

    // 边界条件检查:无选中任务/目标位置越界则返回原状态
    if (!state.selectedTodos.length || destinationIndex < 0 || destinationIndex >= state.todos.length) {
      return state;
    }

    // 复制原始任务数组,避免直接修改state
    let newTodos = [...state.todos];

    // 关键:按选中任务在原始数组中的顺序重新排序
    // 通过findIndex匹配id,确保排序与原始位置一致
    let tempSelectedTodos = [...state.selectedTodos].sort((a, b) => {
      return newTodos.findIndex(todo => todo.id === a.id) - newTodos.findIndex(todo => todo.id === b.id);
    });

    // 创建选中任务ID集合,用于快速过滤非选中任务
    const selectedTodoIds = new Set(tempSelectedTodos.map(todo => todo.id));

    // 从原始数组中移除所有选中任务,得到非选中任务数组
    const nonSelectedTodos = newTodos.filter(todo => !selectedTodoIds.has(todo.id));

    // 计算选中任务在非选中数组中的实际插入位置
    // 原理:通过目标位置的任务ID,找到其在非选中数组中的索引
    let actualDestinationIndex = nonSelectedTodos.findIndex(todo => todo.id === state.todos[destinationIndex].id);

    // 确保目标位置不超出非选中数组范围
    actualDestinationIndex = Math.min(actualDestinationIndex, nonSelectedTodos.length);

    // 根据移动方向微调目标位置:UP表示向上移动,需将插入位置后移一位
    if(direction === 'UP'){
      actualDestinationIndex++;
    }

    // 重组数组:将选中任务整体插入目标位置
    const resultTodos = [
      ...nonSelectedTodos.slice(0, actualDestinationIndex), // 目标位置前的非选中任务
      ...tempSelectedTodos, // 选中任务整体插入
      ...nonSelectedTodos.slice(actualDestinationIndex) // 目标位置后的非选中任务
    ];

    // 返回新状态,保持选中状态方便用户继续操作
    return {
      ...state,
      todos: resultTodos,
      selectedTodos: [...state.selectedTodos],
    }
  }

1.3 配套Action创建函数

为了在组件中调用批量排序逻辑,我们需要创建对应的action创建函数,将目标位置和移动方向作为参数传递:

// 批量重新排序任务的action创建函数
batchReorderTodos: (destinationIndex, direction) => {
  dispatch({
    type: ActionTypes.BATCH_REORDER_TODOS,
    payload: { destinationIndex, direction },
  });
},

第二步:优化useDrop的hover事件,区分拖动类型

useDrop钩子的hover事件是处理“放置接收”的关键——当拖拽的任务悬停在目标任务上时,需要实时计算位置并触发排序。我们在此处添加“是否为批量拖动”的判断,分别执行单个和批量排序逻辑。

2.1 核心交互逻辑

hover事件的核心需求是“精准判断插入位置”:

  • 批量拖动时:根据鼠标在目标任务上的垂直位置(上半部分/下半部分),确定整体插入方向(上方/下方);
  • 单个拖动时:直接匹配源任务与目标任务的位置,执行交换排序。

同时需要避免“自我交换”问题——当拖拽的任务与目标任务为同一任务(单个拖动),或目标任务属于选中任务集合(批量拖动)时,不执行任何操作。

2.2 完整hover事件代码实现

hover: (item, monitor) => {
  if (item.selected) {
    // 场景一:批量拖拽逻辑
    // 避免将选中任务拖到自身集合内
    if (item.selectedTodos.some(selectedTodo => selectedTodo.id === todo.id)) return;

    // 获取目标任务在原始数组中的索引
    const destinationIndex = todos.findIndex(oneTodo => oneTodo.id === todo.id);

    // 计算鼠标在目标任务元素上的垂直偏移量
    // monitor.getClientOffset().y:鼠标在视口中的Y坐标
    // divRef.current.getBoundingClientRect().top:目标元素顶部在视口中的Y坐标
    const hoverOffset = monitor.getClientOffset().y - divRef.current.getBoundingClientRect().top;
    // 目标元素的半高,作为判断插入方向的阈值
    const halfHeight = divRef.current.offsetHeight / 2;

    if (hoverOffset > halfHeight) {
      // 鼠标在目标元素下半部分:将选中任务插入到目标元素下方
      batchReorderTodos(destinationIndex, 'UP');
    } else {
      // 鼠标在目标元素上半部分:将选中任务插入到目标元素上方
      batchReorderTodos(destinationIndex, 'DOWN');
    }
  } else {
    // 场景二:单个拖拽逻辑
    // 避免任务自我交换
    if (item.id === todo.id) return;

    // 关键:通过ID获取实时索引,而非依赖初始索引(避免快速拖拽导致的索引混乱)
    const sourceIndex = todos.findIndex(oneTodo => oneTodo.id === item.id);
    const destinationIndex = todos.findIndex(oneTodo => oneTodo.id === todo.id);

    // 执行单个任务排序
    reorderTodos(sourceIndex, destinationIndex);
  }
},

2.3 关键技术点解析

上述代码中,有两个极易踩坑的技术点需要重点关注:

  1. 索引获取方式:放弃“依赖初始索引”的方式,改用findIndex通过任务ID获取实时索引。这是因为快速拖拽过程中,任务数组顺序会动态变化,初始索引会失效,而ID作为唯一标识能确保索引精准。
  2. 批量拖动的位置判断:通过“鼠标垂直偏移量+元素半高”的组合,实现“hover上半部分插上方,hover下半部分插下方”的自然交互。这种设计符合用户直觉,避免了“拖拽到任务边缘时位置判断模糊”的问题。

功能闭环:从拖动到放置的交互优化

通过新增批量排序操作和优化hover事件逻辑,我们完成了Todo任务拖拽的完整功能闭环。实际使用中,用户可通过以下流程完成拖拽操作:

  1. 单个任务拖拽:直接拖动目标任务,悬停到目标位置即可完成排序;
  2. 批量任务拖拽:先选中多个任务(可通过Ctrl/Shift键辅助),拖动任意选中任务,整体悬停到目标位置,根据鼠标位置完成批量插入。

这种实现方式既保证了操作的灵活性,又通过边界校验(如越界判断、自我交换排除)确保了功能的稳定性。下图为批量拖拽的实际效果演示:

总结

本文通过“批量排序操作封装+hover事件类型区分”的技术方案,解决了Todo任务拖拽中“放置接收”的核心问题。核心亮点在于:

  • 用ID作为索引匹配的唯一标识,避免了动态排序中的索引混乱问题;
  • 批量排序时保持选中项的原始顺序,符合用户操作预期;
  • 基于鼠标位置的精细判断,提升了拖拽交互的流畅性。

学习React-DnD:实现多任务项拖拽-useDrag处理

在完成任务项的选中与取消选中功能后,核心需求升级为“选中状态下拖拽触发批量移动,未选中时保持单个拖拽”,同时需实现“所有选中项同步透明”的视觉反馈。本文将基于React DnD的useDrag钩子,完整拆解这一功能的实现逻辑。

一、核心设计思路

多选拖拽的核心是“状态识别”与“数据联动”,需解决两个关键问题:

  1. 拖拽模式判断:通过任务项的选中状态(todo.selected)和选中集合(selectedTodos),区分“单个拖拽”和“多选拖拽”。
  2. 批量状态同步:拖拽任意一个选中项时,让所有选中项同步响应拖拽状态(如透明效果),而非仅拖拽触发项。

最终实现效果如示例所示:勾选多个任务项后,拖拽其中任意一项,所有选中项均变为透明并同步移动。

SelectTodosDrag.gif

二、关键技术点:React DnD useDrag 配置优化

拖拽功能的核心是useDrag钩子的配置,我们需要通过修改item(拖拽数据)和isDragging(拖拽状态判断)两个关键属性,实现模式切换与状态同步。

2.1 拖拽数据(item):携带模式标识

拖拽时传递的数据需包含“当前任务信息”和“选中集合(可选)”,让接收方(useDrop)能识别拖拽模式。当任务项处于选中状态且存在选中集合时,携带selectedTodos标识为多选拖拽。

    // TodoItem组件中useDrag的item配置
    item: () => {
      // 构建拖拽数据,区分单个/多选拖拽
      return {
        id: todo.id,               // 当前任务ID(必传,单个拖拽核心标识)
        index: index,              // 当前任务在列表中的原始索引
        selected: todo.selected,   // 当前任务的选中状态
        // 关键:仅选中状态且存在选中集合时,携带选中列表
        selectedTodos: todo.selected && selectedTodos ? selectedTodos : undefined
      };
    },

设计亮点:通过selectedTodos的“存在性”作为模式判断依据,无需额外定义“type”字段,简化数据结构的同时保证语义清晰。

2.2 拖拽状态(isDragging):批量同步状态

默认的isDragging仅判断“当前项是否为拖拽触发项”,无法满足多选场景。我们需要自定义判断逻辑:多选模式下,所有选中项均视为“正在拖拽” ,从而同步视觉效果。

    // TodoItem组件中useDrag的isDragging配置
    isDragging: (monitor) => {
      const item = monitor.getItem();
      // 安全校验:避免拖拽数据为空时的异常
      if (!item) return false;

      // 场景1:单个拖拽(无选中集合或当前项未选中)
      if (!item.selectedTodos || !item.selected) {
        // 仅当拖拽数据ID与当前项ID一致时,视为拖拽中
        return item.id === todo.id;
      }

      // 场景2:多选拖拽(存在选中集合且当前项已选中)
      // 检查当前项是否在选中集合中,是则视为拖拽中
      return item.selectedTodos.some(
        selectedTodo => selectedTodo && selectedTodo.id === todo.id
      );
    },

逻辑拆解:

  • 安全校验:优先判断item是否存在,避免monitor.getItem()返回空值导致的报错。
  • 单个拖拽判断:无selectedTodos时,沿用默认逻辑(ID匹配即视为拖拽中)。
  • 多选拖拽判断:通过some()方法遍历选中集合,只要当前项在集合中,就标记为“拖拽中”,实现批量状态同步。

三、视觉效果联动:选中项透明化

通过isDragging返回的布尔值,直接关联组件样式,实现“拖拽中选中项透明”的视觉反馈,这也是提升用户体验的关键一步。

    // TodoItem组件样式与拖拽状态联动
    import { useDrag } from 'react-dnd';

    export default function TodoItem({ todo, index }) {
      // 省略useDrag其他配置...
      const [{ isDragging }, drag] = useDrag({
        item: /* 上文配置 */,
        isDragging: /* 上文配置 */,
        // 其他属性(如end:拖拽结束后的排序逻辑)
      });

      // 拖拽状态样式:选中项拖拽时透明化
      const todoItemStyle = {
        opacity: isDragging ? 0.5 : 1, // 核心视觉反馈
        transition: 'opacity 0.2s ease', // 平滑过渡提升体验
        // 其他基础样式(如padding、border等)
      };

      return (
        <div className={`todo-item${isDragging ? ' isDragging' : ''}`} ref={divRef}>
            {/* 其他 */}
        </div>
      );
    };

效果说明:当拖拽任意一个选中项时,所有选中项的isDragging均变为trueopacity同步变为0,实现示例中的透明效果;拖拽结束后,isDragging重置为false,样式恢复正常。

四、功能总结

本次实现通过修改useDrag的核心配置,仅用“数据携带标识+状态批量判断”的轻量方案,完成了单个/多选拖拽的无缝切换,同时通过样式联动提升了用户体验。核心亮点:

  • 无侵入式设计:不修改原有单个拖拽逻辑,通过条件判断扩展多选能力。
  • 状态自洽:依赖选中集合动态计算状态,避免局部状态与全局状态的不一致。
  • 视觉反馈清晰:选中项同步透明,让用户明确感知批量拖拽的作用范围。

学习React-DnD:实现多任务项拖动-维护多任务项数组

一、功能承接与需求概述

上一篇文档中,我们已完成单个任务项的拖动排序功能。本次迭代的核心需求是实现多个任务项的批量选中,具体需达成以下目标:

  • 扩展状态管理,支持存储选中的任务项集合
  • 新增选中、取消选中任务项的操作逻辑
  • 在任务项组件中关联单选框与选中状态的交互

二、核心状态扩展(TodoProvider)

要实现多任务选中与拖拽,首先需要在全局状态中新增“选中任务集合”字段,并同步更新上下文供组件调用。

2.1 初始化状态修改

initialState中添加selectedTodos数组,用于存储当前选中的任务项,同时确保任务项模型的完整性:

const initialState = {
  todos: [
    { id: 1, text: 'Learn React', completed: false, selected: false },
    { id: 2, text: 'Build a Todo App', completed: false, selected: false },
    { id: 3, text: 'Build a Demo', completed: true, selected: false },
    { id: 4, text: 'Fix a Bug', completed: false, selected: true },

  ],
  // 当前选中的任务数组
  selectedTodos: [{ id: 4, text: 'Fix a Bug', completed: false, selected: true }],
};

2.2 上下文同步更新

将新增的selectedTodos状态注入上下文,确保子组件能获取到选中任务集合:

// TodoProvider内部的上下文值配置
const contextValue = {
  todos: state.todos,
  selectedTodos: state.selectedTodos,
  ...actions,
};

三、核心操作逻辑实现(Reducer)

基于需求新增两个核心操作类型:ADD_SELECT_TODOS(添加选中任务)、REMOVE_SELECT_TODOS(移除选中任务),以下是完整实现逻辑。

3.1 操作类型常量定义(建议单独维护)

// ActionTypes.js
export const ActionTypes = {
  // 原有操作类型...
  ADD_SELECT_TODOS: 'ADD_SELECT_TODOS',
  REMOVE_SELECT_TODOS: 'REMOVE_SELECT_TODOS',
};

3.2 添加选中任务(ADD_SELECT_TODOS)

通过任务ID查找目标任务,确保任务存在且未被选中后,添加到selectedTodos集合中,避免重复选中:

// ADD_SELECT_TODOS
case ActionTypes.ADD_SELECT_TODOS:
  {
    // 找到要添加的todo对象
    const todoToAdd = state.todos.find(todo => todo.id === action.payload.id);
    // 确保todo存在且尚未被选中(避免重复添加)
    if (todoToAdd && !state.selectedTodos.some(todo => todo.id === todoToAdd.id)) {
      return {
        ...state,
        selectedTodos: [...state.selectedTodos, todoToAdd],
      };
    }
    return state;
  }

3.3 移除选中任务(REMOVE_SELECT_TODOS)

根据任务ID从selectedTodos集合中过滤掉目标任务:

// REMOVE_SELECT_TODOS
case ActionTypes.REMOVE_SELECT_TODOS:
  return {
    ...state,
    selectedTodos: state.selectedTodos.filter(todo => todo.id !== action.payload.id),
  };

四、任务项组件交互(TodoItem)

在TodoItem组件中,通过Ref绑定单选框,并在单选框状态变化时,触发选中/取消选中的操作,实现视图与状态的同步。

4.1 组件核心逻辑

import { useContext, useRef } from 'react';
import useTodoContext from '@/context/TodoContext/useTodoContext';

const TodoItem = ({ todo }) => {
  const { addSelectTodos, removeSelectTodos } = useTodoContext();
  // 用Ref绑定单选框元素,便于后续操作(如主动获取状态)
  const checkboxRef = useRef(null);

  // 单选框状态变化处理函数
  const handleCheckboxChange = (e) => {
    toggleSelected(todo.id);
    if (e.target.checked) {
      // 选中:调用添加选中任务的action
      addSelectTodos(todo.id);
    } else {
      // 取消选中:调用移除选中任务的action
      removeSelectTodos(todo.id);
    }
  };

  return (
    <div className={`todo-item${isDragging ? ' isDragging' : ''}`} ref={divRef}>
      <div className="todo-item-content">
        <input
          type="checkbox"
          id={`todo-${todo.id}`}
          checked={todo.selected}
          onChange={handleCheckboxChange}
          className="todo-checkbox"
          ref={checkboxRef}
        />
      </div>
      {/* 原有单个任务拖拽相关逻辑 */}
    </div>
  );
};

export default TodoItem;

注意:仅保留必需内容,不包含TodoItem组件的所有内容。

4.2 关键交互说明

  • Ref绑定:通过checkboxRef可在需要时主动控制单选框(如全选/取消全选功能),提升组件灵活性。
  • 状态双向绑定:单选框的checked属性直接绑定任务项的selected状态,确保视图与全局状态一致。
  • 操作触发:状态变化时通过上下文获取的addSelectTodosremoveSelectTodos方法更新全局状态,实现跨组件状态同步。

五、配套Action函数实现

为了让组件更便捷地调用上述操作,需在TodoProvider中定义对应的action函数,并注入上下文:

const actions = {
  // 原有action...
  
    // 添加选中任务
    addSelectTodos: (id) => {
      dispatch({
        type: ActionTypes.ADD_SELECT_TODOS,
        payload: { id },
      });
    },

    // 移除选中任务
    removeSelectTodos: (id) => {
      dispatch({
        type: ActionTypes.REMOVE_SELECT_TODOS,
        payload: { id },
      });
    },
    
};

六、测试要点

  1. 单个任务选中/取消:勾选单选框后,selectedTodos应同步增减,任务项selected状态正确。
  2. 多个任务选中:选中多个任务后,selectedTodos应包含所有选中项,无重复数据。

学习React-DnD:实现 TodoList 简单拖拽功能

本文将为您带来基础 TodoList 项目的进阶教程,重点讲解如何使用 React-DnD 库为任务列表添加拖拽排序功能。建议读者先掌握基础版本实现后再继续阅读本文;若尚未了解基础 TodoList 功能,请先查阅相关教程。

EasyTodoList.gif

一、引言

在基础 TodoList 项目中,我们实现了任务的添加、状态切换和删除等核心功能。为了提升用户体验,拖拽排序是一个非常实用的功能,它允许用户通过直观的拖放操作来重新排列任务顺序。React-DnD 是 React 生态中处理拖拽操作的优秀库,它提供了声明式 API,让拖拽功能的实现变得简单而优雅。

与原生拖拽 API 相比,React-DnD 封装了复杂的事件处理逻辑,将拖拽行为拆解为可复用的组件逻辑,同时支持多种拖拽场景扩展,非常适合在 TodoList 这类需要灵活交互的应用中使用。

二、项目结构调整

为了使拖拽功能的代码结构清晰、可维护,我们基于基础 TodoList 项目进行如下结构调整,新增拖拽相关配置目录,并明确各组件的职责边界:

src/
├── App.jsx              # 应用入口组件,添加 DnDProvider 包裹全局
├── App.css              # 全局样式文件,新增拖拽相关视觉样式
├── components/
│   ├── TodoList/
│   │   └── TodoList.jsx # 任务列表容器,渲染 TodoItem 列表
│   └── TodoItem/
│       └── TodoItem.jsx # 核心改造组件,同时作为拖拽源和放置目标
├── context/
│   └── TodoContext/     # 任务状态管理,新增拖拽排序方法
├── dnd/                 # 新增:拖拽相关配置目录,集中管理类型定义
│   └── types.js         # 定义拖拽类型常量,实现类型统一管理
└── main.jsx             # 应用挂载入口,无额外修改

三、完整实现步骤

按以下步骤完成功能落地,逐步完成拖拽排序功能的集成,每一步都包含具体代码和关键说明:

1. 安装 React-DnD 核心依赖

首先需要安装 React-DnD 库及其 HTML5 后端(适用于桌面端浏览器场景),如果是移动端应用,可后续替换为触摸后端:

# 使用 npm 安装
npm install react-dnd react-dnd-html5-backend

# 或使用 yarn 安装
yarn add react-dnd react-dnd-html5-backend

2. 定义拖拽类型常量

dnd/types.js 文件中定义拖拽类型,采用常量形式便于后续维护和复用,避免硬编码导致的错误:

// dnd/types.js
// 定义 Todo 任务的拖拽类型,命名清晰便于区分其他拖拽项
const ItemTypes = {
  TODO_ITEM: 'todo_item',
}

// 导出拖拽类型常量
export default ItemTypes; 

3. 配置全局 DnDProvider

React-DnD 通过 DnDProvider 为整个应用提供拖拽上下文,需要在应用入口组件 App.jsx 中包裹全局组件,并配置 HTML5 后端:

import React from 'react'
import './App.css'

import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'
import TodoProvider from '@/context/TodoContext/TodoProvider'

import TodoList from '@/components/TodoList/TodoList'

function App() {

  return (
    <DndProvider backend={HTML5Backend}>
      <TodoProvider>
          <TodoList />
      </TodoProvider>
    </DndProvider>
  )
}

export default App

4. 实现 TodoItem 拖拽功能

我们需要改造 TodoItem.jsx 组件,使其同时具备拖拽源(DragSource)和放置目标(DropTarget)功能,这是实现拖拽排序的关键:

改造要点:

  1. 拖拽源功能:启用待办事项的拖拽操作,设置拖拽数据
  2. 放置目标功能:接收其他待办事项的拖拽,处理放置操作
  3. 交互优化:确保拖拽过程流畅,实现自然的排序效果
// components/TodoItem/TodoItem.jsx
import React, { useRef } from 'react';
import useTodoContext from '@/context/TodoContext/useTodoContext';

import { useDrag, useDrop } from 'react-dnd';
import ItemTypes from '@/dnd/types';

export default function TodoItem({ todo, index }) {
  const { todos, deleteTodo, toggleComplete, reorderTodos } = useTodoContext();
  const divRef = useRef(null);
  // 确保todo存在
  if (!todo) {
    return null;
  }

  // 拖拽功能实现
  const [{ isDragging }, dragSourceRef] = useDrag(
    {
      // 拖拽类型,用于和目标源的accept属性匹配
      type: ItemTypes.TODO_ITEM,
      // 拖拽时传递的数据,包含当前拖拽项的id和索引
      item: { id: todo.id, index },
      // 自定义判断当前项是否正在被拖拽的逻辑
      // 当拖拽数据存在且id与当前项匹配时,视为正在拖拽
      isDragging: (monitor) => {
        return monitor.getItem() !== null && monitor.getItem().id === todo.id;
      },
      // 收集拖拽状态的回调,返回需要的状态数据
      collect: (monitor) => {
        return {
          // 获取当前是否处于拖拽状态(如果不设置isDragging配置,会使用默认实现)
          // isDragging 如果没有设置 则会调用默认的实现
          isDragging: monitor.isDragging(),
        }
      }

    }
    // 依赖数组:当todos发生变化时,重新创建拖拽源配置
    , [todos])

  const [{ isOver, canDrop }, dropTargetRef] = useDrop(
    {
      // 接受的拖拽类型,与拖拽源的type匹配
      accept: ItemTypes.TODO_ITEM,
      // 放置目标自身的数据(此处为空对象,可根据需要添加)
      item: {},
      hover: (item) => {
        // item 是拖拽源  todo 是接受拖拽
        // console.log(item, todo);

        if (item.id === todo.id) return;

        // 直接使用索引会导致拖拽混乱
        // 在拖拽过程中列表顺序发生变化(比如多次快速拖拽), item.index 仍然会保持 初始拖拽时的旧索引 ,而不是当前列表中的实际索引。
        // console.log(item.index, index);
        // reorderTodos(item.index, index);

        // 通过 todos.findIndex 的方式获取index更可靠
        // console.log(item.id, todo.id);
        const sourceIndex = todos.findIndex(oneTodo => oneTodo.id === item.id);
        const destinationIndex = todos.findIndex(oneTodo => oneTodo.id === todo.id);
        // console.log(sourceIndex, destinationIndex);
        reorderTodos(sourceIndex, destinationIndex);

      },
      // 收集放置状态的回调,返回需要的状态数据
      collect: (monitor) => {
        return {
          // 当前是否有拖拽项悬停在上方
          isOver: monitor.isOver(),
          // 当前目标是否可以接受拖拽项
          canDrop: monitor.canDrop
        }
      },
    }
    // 依赖数组:当todos发生变化时,重新创建放置目标配置
    , [todos])

  // 注意 Drag 和 Drop 必须添加todos依赖 如果不添加 容易拖拽状态不同步 等问题  显示混乱
  dropTargetRef(dragSourceRef(divRef))

  return (
    // 拖拽时  会添加 isDragging 类名  会使当前组件 透明度 降低
    <div className={`todo-item${isDragging ? ' isDragging' : ''}`} ref={divRef}>
      <div className="todo-item-content">
        <input
          type="checkbox"
          id={`todo-${todo.id}`}
          className="todo-checkbox"
        />
        <label
          htmlFor={`todo-${todo.id}`}
          className={`todo-text ${todo.completed ? 'completed' : ''}`}
        >
          {todo.text}
        </label>
      </div>
      <button
        onClick={() => toggleComplete(todo.id)}
        className={`todo-complete-btn ${todo.completed ? 'completed' : ''}`}
        aria-label={todo.completed ? "标记为未完成" : "标记为已完成"}
      >
        {todo.completed ? "已完成" : "未完成"}
      </button>
      <button
        onClick={() => deleteTodo(todo.id)}
        className="todo-delete-btn"
        aria-label="删除任务"
      >
        ×
      </button>
    </div>
  );
}

五、常见问题及解决方案

1. 拖拽一次后样式不生效

问题描述:首次拖拽任务后,再次拖拽时透明度样式不再正确应用。

原因分析:React-DnD 默认的 monitor.isDragging() 方法在组件重新渲染后,无法准确识别当前正在被拖拽的是哪个具体组件,导致状态判断错误。

解决方案:实现基于任务 ID 的自定义拖拽状态判断,在 collect 函数中使用相同的逻辑:

isDragging: (monitor) => {
    return monitor.getItem() !== null && monitor.getItem().id === todo.id;
}

2. 拖拽位置计算不准确

问题描述:拖拽排序后,任务的实际位置与预期不符,出现位置错乱。

原因分析:直接使用组件索引进行位置计算时,没有考虑到列表动态更新导致的索引变化,容易产生闭包陷阱。

解决方案:使用 findIndex 方法基于任务 ID 实时查找最新位置,确保排序操作的准确性:

const sourceIndex = todos.findIndex(oneTodo => oneTodo.id === item.id);
const destinationIndex = todos.findIndex(oneTodo => oneTodo.id === todo.id);

3. 状态不同步问题

问题描述:拖拽操作完成后,组件界面显示与实际数据状态不一致。

原因分析useDraguseDrop 钩子的依赖数组配置不完整,导致组件没有在相关状态变化时重新计算。

解决方案:确保依赖数组包含所有必要的状态和属性:

// 对于 useDrag
}, [todos]);

// 对于 useDrop
}, [todos]);

六、总结

在本教程中,我们成功地在基础 TodoList 应用上集成了 React-DnD 库,实现了任务的拖拽排序功能。通过系统性的实现步骤,我们构建了一个用户体验良好的交互式待办事项应用:

  1. 项目结构优化:创建了专门的拖拽配置目录,使代码组织更加清晰
  2. React-DnD 基础配置:通过 DnDProvider 和 HTML5Backend 配置了拖拽环境
  3. 状态管理扩展:在 TodoContext 中实现了基于 Reducer 的拖拽排序逻辑
  4. 组件功能增强:将 TodoItem 组件改造为同时支持拖拽源和放置目标的复合组件
  5. 用户体验优化:添加了拖拽过程中的视觉反馈样式,提升交互体验
❌