阅读视图
学习React-DnD:实现多任务项拖拽-useDrop处理
在上一篇技术分享中,我们聚焦于useDrag钩子实现Todo任务项的拖动触发逻辑,完成了任务的选中与拖拽启动功能。而拖拽交互的闭环,必然离不开放置接收环节——当用户将任务拖拽到目标位置时,如何精准判断拖动类型(单个/多个)并执行对应的排序逻辑,才是确保交互流畅性的核心。本文将详细拆解这一环节的实现思路,重点解析批量排序操作的设计与hover事件的逻辑处理。
核心问题:区分拖动类型,匹配差异化逻辑
任务拖拽的放置接收环节,首要解决的问题是“识别当前拖动场景”。当用户拖拽任务时,存在两种典型场景:单个任务独立拖动、多个已选中任务批量拖动。这两种场景的排序逻辑存在本质差异:单个任务只需处理“源位置”与“目标位置”的双向交换;而批量任务则需要先提取所有选中项,再整体插入目标位置,同时保持选中项内部的相对顺序。
针对这一差异,我们的技术方案分为两步:一是新增批量排序的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 关键技术点解析
上述代码中,有两个极易踩坑的技术点需要重点关注:
-
索引获取方式:放弃“依赖初始索引”的方式,改用
findIndex通过任务ID获取实时索引。这是因为快速拖拽过程中,任务数组顺序会动态变化,初始索引会失效,而ID作为唯一标识能确保索引精准。 - 批量拖动的位置判断:通过“鼠标垂直偏移量+元素半高”的组合,实现“hover上半部分插上方,hover下半部分插下方”的自然交互。这种设计符合用户直觉,避免了“拖拽到任务边缘时位置判断模糊”的问题。
功能闭环:从拖动到放置的交互优化
通过新增批量排序操作和优化hover事件逻辑,我们完成了Todo任务拖拽的完整功能闭环。实际使用中,用户可通过以下流程完成拖拽操作:
- 单个任务拖拽:直接拖动目标任务,悬停到目标位置即可完成排序;
- 批量任务拖拽:先选中多个任务(可通过Ctrl/Shift键辅助),拖动任意选中任务,整体悬停到目标位置,根据鼠标位置完成批量插入。
这种实现方式既保证了操作的灵活性,又通过边界校验(如越界判断、自我交换排除)确保了功能的稳定性。下图为批量拖拽的实际效果演示:
总结
本文通过“批量排序操作封装+hover事件类型区分”的技术方案,解决了Todo任务拖拽中“放置接收”的核心问题。核心亮点在于:
- 用ID作为索引匹配的唯一标识,避免了动态排序中的索引混乱问题;
- 批量排序时保持选中项的原始顺序,符合用户操作预期;
- 基于鼠标位置的精细判断,提升了拖拽交互的流畅性。
学习React-DnD:实现多任务项拖拽-useDrag处理
在完成任务项的选中与取消选中功能后,核心需求升级为“选中状态下拖拽触发批量移动,未选中时保持单个拖拽”,同时需实现“所有选中项同步透明”的视觉反馈。本文将基于React DnD的useDrag钩子,完整拆解这一功能的实现逻辑。
一、核心设计思路
多选拖拽的核心是“状态识别”与“数据联动”,需解决两个关键问题:
-
拖拽模式判断:通过任务项的选中状态(
todo.selected)和选中集合(selectedTodos),区分“单个拖拽”和“多选拖拽”。 - 批量状态同步:拖拽任意一个选中项时,让所有选中项同步响应拖拽状态(如透明效果),而非仅拖拽触发项。
最终实现效果如示例所示:勾选多个任务项后,拖拽其中任意一项,所有选中项均变为透明并同步移动。
二、关键技术点: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均变为true,opacity同步变为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状态,确保视图与全局状态一致。 -
操作触发:状态变化时通过上下文获取的
addSelectTodos和removeSelectTodos方法更新全局状态,实现跨组件状态同步。
五、配套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 },
});
},
};
六、测试要点
- 单个任务选中/取消:勾选单选框后,
selectedTodos应同步增减,任务项selected状态正确。 - 多个任务选中:选中多个任务后,
selectedTodos应包含所有选中项,无重复数据。
学习React-DnD:实现 TodoList 简单拖拽功能
本文将为您带来基础 TodoList 项目的进阶教程,重点讲解如何使用 React-DnD 库为任务列表添加拖拽排序功能。建议读者先掌握基础版本实现后再继续阅读本文;若尚未了解基础 TodoList 功能,请先查阅相关教程。
一、引言
在基础 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)功能,这是实现拖拽排序的关键:
改造要点:
- 拖拽源功能:启用待办事项的拖拽操作,设置拖拽数据
- 放置目标功能:接收其他待办事项的拖拽,处理放置操作
- 交互优化:确保拖拽过程流畅,实现自然的排序效果
// 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. 状态不同步问题
问题描述:拖拽操作完成后,组件界面显示与实际数据状态不一致。
原因分析:useDrag 和 useDrop 钩子的依赖数组配置不完整,导致组件没有在相关状态变化时重新计算。
解决方案:确保依赖数组包含所有必要的状态和属性:
// 对于 useDrag
}, [todos]);
// 对于 useDrop
}, [todos]);
六、总结
在本教程中,我们成功地在基础 TodoList 应用上集成了 React-DnD 库,实现了任务的拖拽排序功能。通过系统性的实现步骤,我们构建了一个用户体验良好的交互式待办事项应用:
- 项目结构优化:创建了专门的拖拽配置目录,使代码组织更加清晰
- React-DnD 基础配置:通过 DnDProvider 和 HTML5Backend 配置了拖拽环境
- 状态管理扩展:在 TodoContext 中实现了基于 Reducer 的拖拽排序逻辑
- 组件功能增强:将 TodoItem 组件改造为同时支持拖拽源和放置目标的复合组件
- 用户体验优化:添加了拖拽过程中的视觉反馈样式,提升交互体验