学完这篇,你能在 React 项目里用两种主流方案做出可排序的拖拽列表,并且知道跨列拖拽该怎么写、哪些坑必须提前避开。
第一步:先决定用哪个库
两个库的定位完全不同,别混着选:
- react-beautiful-dnd:Atlassian 出品,API 简洁,动画细腻,曾经是事实标准。但它已经停止维护并归档,官方推荐新项目改用它社区化的继任版
@hello-pangea/dnd,API 100% 兼容,改个包名即可迁移。
- dnd-kit:活跃维护,模块化设计,体积小,支持自定义传感器和碰撞检测,现在新项目的默认选择。
结论:老项目还在用 rbd 就换成 @hello-pangea/dnd;新项目直接上 dnd-kit。
第二步:安装
npm i @hello-pangea/dnd
# 方案 B:dnd-kit(分三个包,按需装)
npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
注意:如果你搜到的教程写的是 npm i react-beautiful-dnd,那个包在 npm 上还在,但源码已经冻结,配 React 18 严格模式会出问题,不要用。
第三步:用 rbd 写一个最简可拖列表
核心是三个组件:DragDropContext 管全局,Droppable 划出可放区域,Draggable 包每一项。
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
function List() {
const [items, setItems] = useState(['A', 'B', 'C']);
const onDragEnd = (result) => {
if (!result.destination) return; // 拖到区域外,直接放弃
const next = Array.from(items);
const [moved] = next.splice(result.source.index, 1);
next.splice(result.destination.index, 0, moved);
setItems(next);
};
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="list">
{(provided) => (
<ul ref={provided.innerRef} {...provided.droppableProps}>
{items.map((item, index) => (
<Draggable key={item} draggableId={item} index={index}>
{(p) => (
<li ref={p.innerRef} {...p.draggableProps} {...p.dragHandleProps}>
{item}
</li>
)}
</Draggable>
))}
{provided.placeholder}
</ul>
)}
</Droppable>
</DragDropContext>
);
}
三个必须写全的地方:provided.innerRef、provided.droppableProps、{provided.placeholder}。少写 placeholder,拖拽时列表会跳动。
注意:key 和 draggableId 都要用业务 id,绝对不要用数组下标。用下标的话,删除一项后剩下的项 id 全变,React 会重建 DOM,动画直接错乱。
第四步:用 dnd-kit 写同样的列表
dnd-kit 的思路是「传感器 + 上下文 + 排序策略」,代码稍微碎一点,但可控性强。
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, arrayMove, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
function SortableItem({ id }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<li ref={setNodeRef} style={style} {...attributes} {...listeners}>
{id}
</li>
);
}
function List() {
const [items, setItems] = useState(['A', 'B', 'C']);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (over && active.id !== over.id) {
setItems((prev) =>
arrayMove(prev, prev.indexOf(active.id), prev.indexOf(over.id))
);
}
}}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<ul>{items.map((id) => <SortableItem key={id} id={id} />)}</ul>
</SortableContext>
</DndContext>
);
}
arrayMove 是 dnd-kit 自带的工具函数,比手写 splice 少出错。
第五步:跨列拖拽(看板)的关键差异
- rbd:一个
Droppable 就是一列,onDragEnd 里要分别处理「同列内换位」和「跨列搬运」,判断 result.source.droppableId !== result.destination.droppableId。跨列时先从源数组删、再插入目标数组,两次 setState 最好合并成一次,否则会闪。
- dnd-kit:用
useDroppable 定义列容器,onDragOver 处理「拖到另一列上方时先挪过去」,onDragEnd 只做最终落位。这一步不做的话,跨列拖拽体验会很生硬。
注意:dnd-kit 里如果列表项内部有按钮、输入框,拖拽事件会把它劫持掉。解决方法是给这些元素加 data-no-dnd 或用 onPointerDown={(e) => e.stopPropagation()};更好的做法是把 listeners 只绑在一个拖拽手柄图标上,而不是整个卡片。
第六步:几个必踩的坑
注意:React 18 的 <StrictMode> 会让 rbd 报 Unable to find draggable with id。原因是严格模式二次挂载打乱了它内部的注册顺序。要么换 @hello-pangea/dnd(已修复),要么在开发环境临时去掉 StrictMode。
注意:移动端拖拽和页面滚动会打架。dnd-kit 加 useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),长按 200ms 才进入拖拽,短滑仍然滚动页面。
注意:列表很长时不要一次渲染几千项。两个库的拖拽计算都是按元素测量的,节点过多样例会明显掉帧,配合虚拟滚动(如 react-window)时需要额外处理测量,或直接做分页。
小结
- 新项目用 dnd-kit,老项目把
react-beautiful-dnd 换成 @hello-pangea/dnd,包名一改就能跑。
- rbd 记住三件套:
innerRef、droppableProps、placeholder;key 和 draggableId 用业务 id。
- dnd-kit 记住三层:
DndContext 配传感器和碰撞检测、SortableContext 配策略、useSortable 拿 setNodeRef 和 transform。
- 跨列拖拽 rbd 靠
onDragEnd 判断 droppableId,dnd-kit 靠 onDragOver 提前搬运。
- 严格模式、移动端滚动、列表项内交互元素,是三个最常见的翻车点。