React DnD 嵌套拖拽源(Nested Drag Sources)实战:canDrag 逐层回退机制与源码解析
【免费下载链接】react-dndDrag and Drop for React项目地址: https://gitcode.com/gh_mirrors/re/react-dnd
在 React DnD 中,拖拽源(Drag Source)可以像 DOM 元素一样相互嵌套。本文以官方文档 Drag Sources 示例文档 为核心,深入讲解嵌套拖拽源的核心行为:当一个嵌套的拖拽源通过canDrag返回false拒绝被拖拽时,React DnD 会逐层询问其父级拖拽源,直到找到可用的源并激活;且只有被激活的那个拖拽源会收到beginDrag()与endDrag()回调。读完本文,你将掌握嵌套拖拽源的工作机制、正确的写法,并理解 dnd-core 底层beginDrag动作中的源选择算法,同时对比了解嵌套 drop target(放置目标)的"多点响应"差异。
一、什么是嵌套拖拽源
官方文档 drag-sources.md 对该示例给出了精炼的说明:
你可以将一个拖拽源嵌套进另一个拖拽源之中。如果嵌套的拖拽源从
canDrag返回false,它的父级会被询问,直到找到一个可拖拽的源并被激活。只有被激活的拖拽源,其beginDrag()和endDrag()才会被调用。
这描述的是拖拽源(而非放置目标)特有的"单一激活"语义:
- 嵌套结构中,一次拖拽操作最终只会激活一个拖拽源;
- 激活判定从最深层(最内层)的源开始,逐层向外回退;
canDrag是判定"能否被拖拽"的唯一开关,它既可以是布尔值,也可以是接收 monitor 的函数。
在仓库中,该示例位于packages/examples/src/03-nesting/drag-sources/目录,包含五个文件:
- Container.tsx:组装嵌套结构的入口组件;
- SourceBox.tsx:可递归嵌套的核心拖拽源组件;
- TargetBox.tsx:接收拖拽的放置目标;
- Colors.ts:定义拖拽类型常量;
- interfaces.ts:共享类型定义。
二、核心机制:canDrag 的"由内向外"回退
2.1 从源码看源选择算法
嵌套拖拽源的激活逻辑实现在 dnd-core 的beginDrag动作中,核心是getDraggableSource函数(beginDrag.ts):
function getDraggableSource(sourceIds: Identifier[], monitor: DragDropMonitor) { let sourceId = null for (let i = sourceIds.length - 1; i >= 0; i--) { if (monitor.canDragSource(sourceIds[i])) { sourceId = sourceIds[i] break } } return sourceId }这段代码揭示了两个关键事实:
- 遍历方向是逆序的(
i = sourceIds.length - 1递减到0)。sourceIds数组中越靠后的元素越是深层/内层的拖拽源,因此 React DnD 总是优先询问最内层的源。 - 命中即停。一旦某个源通过
monitor.canDragSource(...)判定为可拖拽,循环立即break,其父级不会再被询问——这就是"逐层回退直到找到可用源"的准确含义。
随后,只有这个被选中的sourceId才会真正参与拖拽初始化(beginDrag.ts):
const source = registry.getSource(sourceId) const item = source.beginDrag(monitor, sourceId) // If source.beginDrag returns null, this is an indicator to cancel the drag if (item == null) { return undefined } verifyItemIsObject(item) registry.pinSource(sourceId)beginDrag只对激活的那个源调用,registry.pinSource(sourceId)也只钉住它;可以推断,endDrag阶段同样只针对该被钉住的源回调。这正对应文档中"只有被激活的拖拽源的beginDrag()与endDrag()会被调用"的描述。
2.2 canDrag 的三种取值形态
canDrag的求值逻辑在 DragSourceImpl.ts 中:
public canDrag() { const spec = this.spec const monitor = this.monitor if (typeof spec.canDrag === 'boolean') { return spec.canDrag } else if (typeof spec.canDrag === 'function') { return spec.canDrag(monitor) } else { return true } }三种形态一目了然:
| 形态 | 行为 | 适用场景 |
|---|---|---|
boolean | 直接返回该值 | 静态开关,如"禁用拖拽"复选框 |
function | 以monitor为参调用并返回结果 | 依据拖拽状态(monitor.getItem()等)动态判定 |
| 未提供 | 默认返回true | 永远允许拖拽 |
该行为有配套单测覆盖(DragSourceImpl.spec.ts):分别验证了未定义时返回true、布尔值原样返回、函数形态会被调用并返回其结果。
三、实战:递归嵌套的 SourceBox
3.1 核心组件:SourceBox
SourceBox.tsx 是理解本示例的关键。它本身就是一个useDrag拖拽源,同时通过children支持任意层级的自我嵌套:
export const SourceBox: FC<SourceBoxProps> = memo(function SourceBox({ color, children, }) { const [forbidDrag, setForbidDrag] = useState(false) const [{ isDragging }, drag] = useDrag( () => ({ type: color, canDrag: !forbidDrag, collect: (monitor: DragSourceMonitor) => ({ isDragging: monitor.isDragging(), }), }), [forbidDrag, color], ) // ... return ( <div ref={drag} style={containerStyle} role="SourceBox"><SourceBox color={Colors.BLUE}> <SourceBox color={Colors.YELLOW}> <SourceBox color={Colors.YELLOW} /> <SourceBox color={Colors.BLUE} /> </SourceBox> <SourceBox color={Colors.BLUE}> <SourceBox color={Colors.YELLOW} /> </SourceBox> </SourceBox>最外层蓝色源包裹了两棵子树:左子树第二层是黄色源,黄色源内又含黄色、蓝色两个叶子;右子树第二层是蓝色源,内含一个黄色叶子。当用户拖拽某个叶子盒子时:
- 若该叶子
canDrag === true(未勾选 Forbid drag),只有它被激活; - 若叶子勾选了 Forbid drag,React DnD 向上询问其直接父级;
- 父级若也可拖拽则父级被激活;若父级同样被禁用,则继续向上,直至最外层。
由于useDrag的依赖数组是[forbidDrag, color],勾选复选框会触发canDrag重新求值,行为始终与最新状态一致。
3.3 放置目标:TargetBox
同目录下的 TargetBox.tsx 用于接收嵌套源的拖放,它同时接受YELLOW与BLUE两种类型:
const [{ isOver, draggingColor, canDrop }, drop] = useDrop( () => ({ accept: [Colors.YELLOW, Colors.BLUE], drop(_item: DragItem, monitor) { onDrop(monitor.getItemType()) return undefined }, collect: (monitor: DropTargetMonitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop(), draggingColor: monitor.getItemType() as string, }), }), [onDrop], )monitor.getItemType()返回被激活源的type,因此TargetBox能显示"最近放下的是什么颜色",从 UI 上直接验证拖入的究竟是哪个嵌套层的源。
四、测试用例如何验证嵌套回退
示例自带集成测试 dragSources.spec.tsx,用react-dnd-test-utils的wrapWithBackend与fireDragDrop模拟真实拖放,从两个角度验证了本文的核心机制:
- 正常拖放:找到第 3 个
SourceBox(boxes[3],即叶子节点),读取其data-color,执行fireDragDrop,断言TargetBox的data-color变成了该颜色——说明叶子源被成功激活并完成拖放。
const box3 = boxes[3]! const box3Color = box3.attributes['data-color'].value await fireDragDrop(box3, target) expect(target.attributes['data-color'].value).toEqual(box3Color)- 禁止拖放:先点击
box3的子元素(即复选框)触发forbidDrag,再执行相同的拖放,断言TargetBox的颜色没有变成box3Color——说明该叶子被禁用后,拖放没有发生(其父级因类型不匹配等原因未能把该颜色送到目标)。
act(() => { fireEvent.click(box3.children[0]!) }) const box3Color = box3.attributes['data-color'].value fireDragDrop(box3, target) expect(target.attributes['data-color'].value).not.toEqual(box3Color)测试路径中的data-color属性与 SourceBox.tsx 渲染的data-color={color}一一对应,是验证"哪个源被激活"的可靠锚点。
五、对照:嵌套 Drop Target 的"多点响应"差异
同样是 nesting 主题,姊妹文档 drop-targets.md 明确对比了放置目标与拖拽源的本质区别:
放置目标同样可以互相嵌套。但与拖拽源不同,多个放置目标可以同时对同一个被拖拽的项作出反应。
React DnD 设计上不提供阻止事件传播的手段。放置目标应当通过比较
monitor.isOver()与monitor.isOver({ shallow: false })来判断是自己被悬停还是其嵌套目标被悬停;也可以检查monitor.didDrop()与monitor.getDropResult()来了解嵌套目标是否已处理本次放置,并返回不同的 drop result。
5.1 源码印证:drop 会遍历所有可放置目标
在 drop.ts 中,drop动作会收集所有可放置的目标并按倒序逐个调用其drop:
const targetIds = getDroppableTargets(monitor) // Multiple actions are dispatched here, which is why this doesn't return an action targetIds.forEach((targetId, index) => { const dropResult = determineDropResult(targetId, index, registry, monitor) // ... manager.dispatch(action) })其中getDroppableTargets(drop.ts)先过滤出所有canDropOnTarget的目标再reverse(),保证最内层目标最先执行drop。这与拖拽源"只激活一个"形成鲜明对比。
5.2 didDrop / getDropResult:判断嵌套目标是否已处理
drop.ts 中determineDropResult展示了 drop result 的合并规则:
const target = registry.getTarget(targetId) let dropResult = target ? target.drop(monitor, targetId) : undefined verifyDropResultType(dropResult) if (typeof dropResult === 'undefined') { dropResult = index === 0 ? {} : monitor.getDropResult() }也就是说:若某个目标未返回结果,则会继承之前(更内层)目标的 drop result;最内层目标默认得到{}。这使嵌套目标可以通过monitor.getDropResult()拿到子目标的返回值。
5.3 isOver 与 shallow 选项的精确语义
monitor.isOver({ shallow: true })与默认的isOver()(即{ shallow: false })的区别在 DragDropMonitorImpl.ts 的isOverTarget中一目了然:
const index = targetIds.indexOf(targetId) if (shallow) { return index === targetIds.length - 1 } else { return index > -1 }shallow: true:仅当自己是targetIds数组最后一个(最内层、当前被直接悬停)时返回true;shallow: false(默认):只要自己出现在悬停目标链中即返回true。
有趣的是,hover.ts 中的removeNonMatchingTargetIds会先剔除类型不匹配的目标,注释说明这正是为了保证isOver({ shallow: true })不被不相关的中间目标干扰。
5.4 示例代码:greedy 开关
drop-targets 示例 用greedy属性演示了上述 API 的组合用法:
const [{ isOver, isOverCurrent }, drop] = useDrop( () => ({ accept: ItemTypes.BOX, drop(_item: unknown, monitor) { const didDrop = monitor.didDrop() if (didDrop && !greedy) { return } setHasDropped(true) setHasDroppedOnChild(didDrop) }, collect: (monitor) => ({ isOver: monitor.isOver(), isOverCurrent: monitor.isOver({ shallow: true }), }), }), [greedy, setHasDropped, setHasDroppedOnChild], )isOver与isOverCurrent分别对应"子孙被悬停也算"与"仅自己被悬停"两种状态,用于控制背景高亮(源码中isOverCurrent || (isOver && greedy)才变绿);monitor.didDrop()为true说明更内层的子目标已经处理了放置,非 greedy 的父目标选择放弃,greedy 的父目标则继续处理并记录hasDroppedOnChild。
对应的拖拽源 Box.tsx 非常简单,仅声明useDrag(() => ({ type: ItemTypes.BOX })),类型常量定义在 ItemTypes.ts。
六、实践要点与常见误区
结合文档与源码,总结如下要点:
- 拖拽源是"单点激活",放置目标是"多点响应"。这是嵌套场景下最容易混淆的一点:
canDrag回退只发生在拖拽源一侧;放置目标一侧则没有"阻止传播"的概念,所有命中目标都会收到回调。 canDrag优先使用函数形态做动态判定。示例中的布尔形态适合静态开关;若需根据monitor状态(如是否已有拖拽进行中、item 内容是否合法)判断,应使用函数形态(见 DragSourceImpl.ts)。- 依赖数组要与
canDrag用到的状态保持一致。useDrag的 spec 工厂函数依赖[forbidDrag, color],状态变化才会触发重新求值(useDrag.ts)。 - 利用
monitor.didDrop()/getDropResult()实现嵌套目标的协作,利用isOver({ shallow: true })区分"自己被悬停"与"子孙被悬停"(DropTargetMonitorImpl.ts)。 - 嵌套层级不宜过深。
getDraggableSource与getDroppableTargets均按层级线性遍历(beginDrag.ts、drop.ts),深层嵌套会增加每次拖拽事件的处理开销,示例中的 2~3 层是合理的复杂度上限。
如需动手验证,可进入仓库packages/examples目录按 package.json 中的脚本运行测试(yarn test使用 Jest 执行dragSources.spec.tsx等用例),或在 docsite 中通过view-source标签跳转查看示例的实时运行效果(组件加载逻辑见 exampleTabs.tsx)。
【免费下载链接】react-dndDrag and Drop for React项目地址: https://gitcode.com/gh_mirrors/re/react-dnd
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考