实战:canDrag 逐层回退机制与源码解析)
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.tsfunction 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.tsconst 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、布尔值原样返回、函数形态会被调用并返回其结果。三、实战递归嵌套的 SourceBox3.1 核心组件SourceBoxSourceBox.tsx 是理解本示例的关键。它本身就是一个useDrag拖拽源同时通过children支持任意层级的自我嵌套export const SourceBox: FCSourceBoxProps 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} roleSourceBox>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 dragReact 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 个SourceBoxboxes[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动作会收集所有可放置的目标并按倒序逐个调用其dropconst targetIds getDroppableTargets(monitor) // Multiple actions are dispatched here, which is why this doesnt return an action targetIds.forEach((targetId, index) { const dropResult determineDropResult(targetId, index, registry, monitor) // ... manager.dispatch(action) })其中getDroppableTargetsdrop.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数组最后一个最内层、当前被直接悬停时返回trueshallow: 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深层嵌套会增加每次拖拽事件的处理开销示例中的 23 层是合理的复杂度上限。如需动手验证可进入仓库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),仅供参考