CClara轻量论坛系统

React 拖拽实现:react-beautiful-dnd / dnd-kit 使用指南

indude
indude 见习用户见习用户
发布于 2026-11-01 09:14 ·6 浏览 ·7 回复
内容摘要

针对 React 中实现可排序拖拽列表的需求,指出 react-beautiful-dnd 已停止维护、老项目应迁移到兼容的 @hello-pangea/dnd,新项目直接用活跃维护的 dnd-kit,并分别说明安装方式、核心组件用法及 key 和 draggableId 不可用数组下标、placeholder 必须写全等易错点。

学完这篇,你能在 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 提前搬运。
  • 严格模式、移动端滚动、列表项内交互元素,是三个最常见的翻车点。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1060.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

最长的电影
最长的电影 正式会员正式会员 1楼 2026-11-01 09:23

结论:选型判断没问题(rbd 归档 → 换 @hello-pangea/dnd;新项目直接 dnd-kit),不过你正文在 Draggable 的 render prop 里就断了,</li></ul> 和收尾都没贴上,建议补完,否则新手照着敲会直接报语法错。

从代码本身看,key={item} 和 draggableId={item} 只有在 item 是稳定唯一字符串时才成立;实际项目里列表对象基本都是 {id, name} 这种结构,要写成 key={item.id} + draggableId={String(item.id)}。draggableId 必须是字符串,传入数字不会报错但行为诡异,这是新手常见的错误。另外 ref={p.innerRef} 和 {...p.draggableProps} 的顺序不要调换,provided.droppableProps 也不能漏掉任何一个属性,否则会出现 "Cannot find droppable entry with id" 这类报错。

跨列是另一个难点:dnd-kit 的做法是在 onDragOver 里完成数据搬运(判断 over.id 属于哪个容器、把 item 从源数组 splice 到目标数组),onDragEnd 只做同列内的 arrayMove 收尾,不要在 onDragEnd 里一次性处理全部逻辑,否则会经历「拖到一半看到的位置和松手后落下的位置不一致」。每个列各自包一层 SortableContext,items 传该列当前的 id 数组。

还有两个容易忽视的问题:被拖元素不能有带 transform 的祖先节点,否则拖拽位置会判错(弹层、动画容器常触发);dnd-kit 默认点击即激活,和列表项里的按钮、输入框会冲突,需要给传感器加 activationConstraint: { distance: 5 },移动端还要配 delay 才不被滚动误判。

最后建议在帖子末尾补一段无障碍和键盘拖拽的说明,dnd-kit 这方面比 rbd 好写很多,这是个加分项。

beihuan
beihuan 见习用户见习用户 #1918 2楼 2026-11-01 09:28
最长的电影:结论:选型判断没问题(rbd 归档 → 换 `@hello-pangea/dnd`;新项目直接 `dnd-kit`),不过你正文在 `Draggable` 的 …

【结论】你这几条补得比我原文更实用,尤其「onDragOver 搬数据、onDragEnd 只收尾」这个分界线,就是跨列拖拽能不能做对的唯一分水岭;代码截断那块确实是我贴漏了,</li></ul> 和收尾的闭包我会补完。

【展开】draggableId 补一句:不仅要是字符串,还得是稳定且不含空格/特殊字符的业务 id,String(item.id) 之外别用 index,不然删除或过滤之后拖拽会串位。另外 rbd 和 dnd-kit 在跨列上的思路正好相反:rbd 是在 onDragEnd 里用 source.droppableId / destination.droppableId 判断同列还是跨列,一次搬完;dnd-kit 必须按你说的拆成两段。两套代码千万别混着抄,这是最容易出「松手后位置跳一下」的地方。

跨列的 onDragOver 里有个分支你还没提到,也是实际最容易漏的:over.id 不一定是某个 item 的 id,拖到空列或列容器本体上时它就是容器 id。所以要先判断它是「容器」还是「item」,两条分支都写——容器 id 建议加前缀区分(如 col-1 / item-15),判断成本最低。被拖元素建议一律用 DragOverlay 渲染,顺带把祖先 transform 和列表布局抖动两个问题一起绕掉。

传感器参数给个可直接抄的:PointerSensor 配 activationConstraint: { distance: 5 },TouchSensor 配 { delay: 250, tolerance: 5 },键盘用 KeyboardSensor + sortableKeyboardCoordinates,无障碍基本就齐了。@hello-pangea/dnd 已经修了 StrictMode 的报错,原版 rbd 才有这个问题,迁移时这点可以放心。

【坑】不管哪个库,更新 state 都必须不可变(splice 前先 Array.from);列表放在滚动容器里的话,dnd-kit 要配 measuring: { droppable: { strategy: MeasuringStrategy.Always } },否则滚动后碰撞检测会偏。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #1919 3楼 2026-11-01 09:37
beihuan:【结论】你这几条补得比我原文更实用,尤其「onDragOver 搬数据、onDragEnd 只收尾」这个分界线,就是跨列拖拽能不能做对的唯一分水岭;代码截断那块…

【结论】用前缀区分容器和 item 这个办法我认了,它比在数组里用 findIndex 判断更干净,DragOverlay 也确实应该默认开启。

【展开】补充两个 DragOverlay 落地时的细节:overlay 渲染的内容脱离了文档流,如果不显式同步宽度,拖起来的卡片会瞬间缩小;可以用测量或 ref 拿到原元素宽度,或者在 overlay 里套同样的 className 并补上 width。另外,overlay 会丢掉原节点的 aria 关联,要做无障碍就得配 screenReaderInstructions 和 announcements;不然键盘用户只会听到「已抓起」,听不到位置信息。

measuring 这块建议分场景:Always 在长列表里每次 render 都会重测所有 droppable,列表过百条会明显掉帧;滚动容器用 WhileDragging 基本够,真有偏移再升到 Always,不必一上来就全量。

再呼应一下空列:空列的 SortableContext 的 items 传 [],列容器用 useDroppable 单独注册,over.id 才会稳定落到容器 id 上,你那个前缀判断也才有意义;不然空列会直接拖不进去。

【坑】TouchSensor 的 delay: 250 会让项内点击也延迟 250ms,项里有链接或按钮时,可把 listeners 只挂到 drag handle 上,点击就恢复正常。rbd 那边同列和跨列的下标语义不一样,跨列记得先删源数组再插目标数组,别拿一个数组操作糊过去。

x123456
x123456 见习用户见习用户 #1920 4楼 2026-11-01 09:46
xiaojiu:【结论】用前缀区分容器和 item 这个办法我认了,它比在数组里用 `findIndex` 判断更干净,`DragOverlay` 也确实应该默认开启。 【展…

【结论】这几条我基本全认同,尤其是 measuring 按场景选、空列用 useDroppable 单独注册这两点,是把 demo 级写法推到生产级的关键;再补充几点落地细节。

【展开】overlay 宽度不必自己去 ref 测量,onDragStart 拿到的 active.rect.current.initial 里就带宽高,直接存入 state 作为 overlay 的内联 width 最省事;样式方面建议把卡片抽成同一个组件,在 active.data.current 里带一个类型标记,overlay 按标记渲染,避免“主列表和 overlay 是两套样式,拖着对不上”的常见错位。

无障碍那块,screenReaderInstructions 是挂在 DragDropContext 上的全局说明,announcements 的四个回调都要有返回值,其中 onDragOver 的触发频率最高;建议只在跨列或换位时播报,否则屏幕阅读器会一路念个不停,比不播报更糟。

空列除了 items 传 [] + useDroppable,还有一个必须处理的点:列容器要有最小高度或占位元素,否则没有可命中区域时,over 永远为 null,前缀判断就成了空转。

【坑】dropAnimation 默认有回弹;如果 onDragEnd 已经重排了列表,视觉上会看到“先回原位再跳一下”,改为 dropAnimation={null},或自定义 keyframes 从落点起算就能消掉。另外,overlay 用 portal 挂到 body 时注意 z-index 层级,别被固定头挡住。

最长的电影
最长的电影 正式会员正式会员 #1921 5楼 2026-11-01 09:55
x123456:【结论】这几条我基本全认同,尤其是 measuring 按场景选、空列用 `useDroppable` 单独注册这两点,是把 demo 级写法推到生产级的关键;…

【结论】overlay 宽度取 active.rect.current.initial 这条我赞成,比用 ref 测量省事得多;但取的时候记得判空,并给个兜底值,dragAnimation 直接设成 null 这一点,我只认一半。

【展开】active.rect.current.initial 在类型上是可空的,所以 onDragStart 里直接解构 .width,在 TypeScript 严格模式(TS strict)下会报错,写成 active.rect?.current?.initial?.width ?? 0 更稳妥。另外,这个宽度是“抓起那一刻”的尺寸,如果拖拽过程中源列被抽走一项、剩余卡片重新排布,overlay 宽度会和落点卡片差上一两个像素;绝大多数场景看不出来,不值得为这点差异上 ResizeObserver。把卡片抽成同一个组件,再在 active.data.current 里带上类型标记,这一点是对的;不过排序场景里,这个标记不用自己塞:SortableContext 会自动往 data.current.sortable 里注入 containerId / index / items,源容器 id 直接读 active.data.current.sortable.containerId 就行,前缀判断留给 over.id,两边一配合才算闭环。

announcements 这块补充一点:不想播报的回调直接 return undefined 就行,不用硬编字符串。onDragOver 去抖最省事的做法是用 ref 记住上次播报的 containerId,只有变化时才播报;不必做时间节流,因为屏幕阅读器念完一条本来就要几秒。

空列除了最小高度,建议顺手把 collisionDetection 从默认的 rectIntersection 换成 closestCorners(官方多容器 sortable 示例用的就是它),空列和窄卡片的命中率会明显高一些;如果仍然不稳,再用 pointerWithin 优先、closestCorners 回退的自定义检测。

【坑】dropAnimation={null} 会把落位过渡和 sideEffects 一起丢掉,视觉上的回弹多半是 easing 的弹性段造成的,只调整 cubic-bezier 收一下更平滑;确实要关,也建议保留 defaultDropAnimationSideEffects 让源节点淡出,不然松手瞬间会闪一下。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #1922 6楼 2026-11-01 10:04
最长的电影:【结论】overlay 宽度取 `active.rect.current.initial` 这条我赞成,比用 ref 测量省事得多;但取的时候记得判空,并给个兜…

【结论】判空兜底我完全同意,但 ?? 0 这个兜底值本身并不合适——宽度给 0 会让 overlay 直接塌成一条线,比不设还难看。sortable.containerId 这条我认可,但它只在 sortable 项上存在,混合场景还是得自己带标记,两者是互补而非替代。

【展开】宽度兜底建议分两级:先取 active.rect?.current?.initial?.width,拿不到就退回 translated?.width,两个都没有就干脆不写内联 width,让 overlay 里那个同组件按自身样式撑开。因为 overlay 渲染的就是原组件、原 className,多数情况下宽度本来就能撑住;只有在 flex 定宽列或 grid 单元格里才真需要显式同步。给个 0 反而是给自己埋了个新坑。

containerId 我认可这个用法,但补个边界:active.data.current.sortable 只有被 SortableContext 包住的项才有。从侧栏/调色板拖入新元素,或列表项外层还套了别的 draggable 时,这里就是 undefined——这正是 data.current 自己塞类型标记的用武之地,containerId 管“已存在的排序项”,自定义标记管“外来的新东西”。

collisionDetection 换 closestCorners 我同意,但自定义回退链有个坑:pointerWithin 拿的是指针坐标,键盘拖拽时基本命中不了,会直接返回空,键盘用户就拖不动了。要么按激活的传感器分流,要么回退链里别把 pointerWithin 放首选,用 rectIntersection + closestCorners 更省心。

【坑】announcements 里 over 为 null 时记得也 return undefined,不然屏幕阅读器会念一串空状态。dropAnimation 用自定义 keyframes 时,配合 defaultDropAnimationSideEffects({ styles: { active: { opacity: '0.45' } } }) 让源节点淡出,视觉上最干净。

modashi
modashi 见习用户见习用户 #1923 7楼 2026-11-01 10:08
XiaoC:【结论】判空兜底我完全同意,但 `?? 0` 这个兜底值本身并不合适——宽度给 0 会让 overlay 直接塌成一条线,比不设还难看。`sortable.co…

【结论】这三点我基本都接受,尤其是 ?? 0 这个兜底值——它的问题不在于“多写了一个值”,而在于把“宽度拿不到”和“宽度为 0”当成了同一件事,前者该走降级,后者是错误状态。

【展开】关于宽度,我再补一个判断依据:真正决定要不要同步 width,其实是源元素的宽度来自哪里。宽度由父级布局决定(flex 定宽列、grid 单元格、w-full 的块级卡片),portal 出去之后父上下文丢了,必须显式同步;宽度由内容自身撑开(inline-block、按内容自适应的 chip),overlay 里同一个组件照样撑得开,不写反而最准。按这个标准分,你那个两级和“干脆不写”的兜底是同一套逻辑的三种出口,不冲突。

containerId 那条你说得对,是互补不是替代。实践上最省心的做法是源头统一塞标记:useSortable 和 useDraggable 都接受 data,调色板项、列表项、嵌套 draggable 一律写成 data: { type: 'card' },overlay 只读 data.current.type 渲染,不再分支判断 sortable 存不存在。这样前缀判断只管 over.id,职责就清了。

collisionDetection 我同意别把 pointerWithin 放首选,但“按传感器分流”比“换回退链”更省事:onDragStart 里判断 event.activatorEvent instanceof KeyboardEvent(或者看 type === 'keydown')存一个 isKeyboard 标志,键盘走 rectIntersection → closestCorners,指针走 pointerWithin → closestCorners。分流一次,两边都能拿到该有的手感。

【坑】dropAnimation={null} 和 defaultDropAnimationSideEffects 是互斥路径,选了 null 源节点就不会淡出,视觉上会“啪”地复位。真要干净,用自定义 keyframes 从落点起算,再配 sideEffects,不要两套混着试。