
本文详解如何在 React 中使用 react-beautiful-dnd 实现多个独立、动态生成的可折叠列表(如手风琴组件)之间的严格隔离式拖拽——每个列表仅允许内部排序,且所有列表需支持按需渲染与状态同步。
本文详解如何在 react 中使用 react-beautiful-dnd 实现多个独立、动态生成的可折叠列表(如手风琴组件)之间的严格隔离式拖拽——每个列表仅允许内部排序,且所有列表需支持按需渲染与状态同步。
在构建具备多组可折叠区域(如 Accordion)的拖拽界面时,一个常见但极易被忽视的陷阱是:将
✅ 正确架构原则
-
必须包裹稳定 DOM 节点 :其父容器不能随交互(如展开/收起)而销毁或重建; - 每个列表需拥有唯一、静态、语义明确的 droppableId(推荐使用 accordion-${id} 格式);
- 拖拽逻辑必须基于当前 state 数据结构进行精确索引定位,而非直接操作原始 accordionData;
- 避免在 Droppable 内部依赖条件渲染的 UI 组件(如 MUI AccordionDetails 的默认行为)。
? 修复后的核心实现(关键改动)
以下为重构后的精简示例,已移除 MUI Accordion,改用受控的自定义折叠逻辑,并确保 Droppable 始终挂载:
import * as React from "react";
import { DragDropContext, Droppable, Draggable, DropResult } from "react-beautiful-dnd";
import { Paper, Stack, Typography, IconButton } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
// 使用 useState 管理每个 accordion 的展开状态
const BasicButtons = () => {
const [arrayData, setArrayData] = React.useState(accordionData);
const [expandedIds, setExpandedIds] = React.useState<set>>(new Set([1])); // 默认展开第一个
const toggleAccordion = (id: number) => {
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const onDragEnd = (result: DropResult) => {
const { source, destination } = result;
// 未投放到有效区域,直接返回
if (!destination) return;
// 拖拽源与目标必须在同一列表(通过 droppableId 判断)
if (source.droppableId !== destination.droppableId) return;
const accordionId = parseInt(source.droppableId.split('-')[1]);
const accordionIndex = arrayData.findIndex(a => a.id === accordionId);
if (accordionIndex === -1) return;
const items = Array.from(arrayData[accordionIndex].content);
const [movedItem] = items.splice(source.index, 1);
// 插入位置需校准:若目标 index >= source.index,则原位置已空出,index 自动前移一位
const insertIndex = destination.index > source.index
? destination.index - 1
: destination.index;
items.splice(insertIndex, 0, movedItem);
const updatedData = [...arrayData];
updatedData[accordionIndex] = {
...updatedData[accordionIndex],
content: items
};
setArrayData(updatedData);
};
return (
<dragdropcontext ondragend="{onDragEnd}"><stack direction="column" spacing="{2}">
{arrayData.map((data) => {
const isExpanded = expandedIds.has(data.id);
return (
<paper key="{data.id}" sx="{{" overflow: borderradius:>
{/* 自定义头部:稳定挂载,不参与条件渲染 */}
<stack direction="row" justifycontent="space-between" alignitems="center" p="{2}" sx="{{" bgcolor: cursor: onclick="{()"> toggleAccordion(data.id)}
>
<typography variant="h6">{data.title}</typography><iconbutton size="small" sx="{{" transform: isexpanded :><expandmoreicon></expandmoreicon></iconbutton></stack>
{/* Droppable 必须始终挂载 —— 即使内容隐藏,也保留占位 */}
<droppable droppableid="{`accordion-${data.id}`}">
{(provided) => (
<div ref="{provided.innerRef}" style="{{" maxheight: isexpanded : overflow: transition: ease-in-out opacity>
<stack direction="column" spacing="{1}" p="{2}">
{data.content.map((item, index) => (
<draggable key="{item.id}" draggableid="{item.id.toString()}" index="{index}">
{(provided) => (
<paper ref="{provided.innerRef}" sx="{{" p: display: alignitems: cursor: bgcolor: boxshadow:><typography>{item.text}</typography></paper>
)}
</draggable>
))}
{provided.placeholder}
</stack>
</div>
)}
</droppable></paper>
);
})}
</stack></dragdropcontext>
);
};
export default BasicButtons;</set>
⚠️ 关键注意事项
- 禁止在 Droppable 内部使用 display: none 或 visibility: hidden 控制显隐:这会导致 react-beautiful-dnd 无法计算占位符位置。应使用 maxHeight: 0 + overflow: hidden + 过渡动画替代;
- droppableId 必须全局唯一且稳定:不要使用动态计算的 ID(如 Math.random()),也不要复用同一 ID 多次;
- onDragEnd 中的状态更新必须基于当前 arrayData(state),而非原始 accordionData 常量,否则会导致状态不同步;
- 若需服务端持久化,应在 onDragEnd 完成后调用 API 并处理 loading/error 状态,避免乐观更新造成 UI 与后端不一致。
✅ 总结
react-beautiful-dnd 对 DOM 生命周期极为敏感。当面对多动态列表场景时,放弃对第三方 UI 库“开箱即用”组件的盲目依赖,转而采用可控、稳定、语义清晰的自定义结构,是保障拖拽体验健壮性的根本路径。本文方案不仅解决了多 Accordion 内部拖拽失效的问题,更提供了可扩展的模式:未来如需支持跨列表拖拽(需修改 onDragEnd 权限逻辑)、添加拖拽动画、或集成虚拟滚动,均可在此坚实基础上平滑演进。











