重构 DraggableEntry 、修正卡片高度对齐 by cyfung1031 · Pull Request #1245 · scriptscat/scriptcat

重新再看了一次
把名字改成 DraggableInjector
避免你误以为是 forwardRef
这次重构用的是 CloneElement


我们来重新梳理一下 React.cloneElement 和 forwardRef 在 dnd-kit 拖拽场景里的区别,以及为什么你的代码选择 cloneElement 是正确的。

一、React.cloneElement 的核心作用

React.cloneElement 的作用非常明确:
它拿一个已存在的 React 元素,复制一份,然后用你提供的新 props 去覆盖原来的 props,最后返回这个新的元素。
原来的元素完全不受影响。

关键点在于:

  • 它可以覆盖任何 props,包括 ref
  • 这是普通方式(比如直接 {...props} 展开)做不到的,因为 ref 不是普通属性

代码示例:

const original = <div className="old-class">内容</div>;

const cloned = React.cloneElement(original, {
  className: "new-class",
  ref: someRef,
});

结果就是新的 div,className 被覆盖,ref 也被设置,原对象不变。

二、在 dnd-kit 场景中为什么必须用它

你的组件本质上是一个“行为增强器”:

  • 给 children 注入拖拽能力(attributes、style、ref)
  • 不能增加额外的 DOM 层(否则布局、flex/grid、CSS 选择器都会出问题)
  • dnd-kit 要求 ref 必须直接挂在可拖拽的真实 DOM 元素上

所以你写了:

const mergedRef = composeRefs(setNodeRef, children.ref);

return React.cloneElement(children, {
  ...attributes,
  ref: mergedRef,
  style: { ...children.props.style, ...style },
});

这三步做到了:

  1. 保留 children 原来的 ref(如果有的话)
  2. 合并 dnd-kit 的 setNodeRef
  3. 注入拖拽所需的 attributes 和 style
  4. 没有多一层 DOM,布局和结构保持原样

三、如果用

{children}

会怎样

很多人第一反应是包一层 div:

<div ref={setNodeRef} {...attributes} style={style}>
  {children}
</div>

问题立刻出现:

  • 多了一层 DOM,flex/grid 布局可能错位(子元素变成孙元素)
  • CSS 选择器(比如 :first-child、nth-child)失效
  • dnd-kit 有时会因为 ref 没挂在最外层真实元素而出问题
  • 性能和可维护性都变差

cloneElement 完美绕过了这些问题。

四、forwardRef 为什么不适合这里

forwardRef 的设计目的是让父组件能把 ref 传给子组件内部的某个 DOM

典型用法:

const MyInput = forwardRef((props, ref) => (
  <input ref={ref} {...props} />
));

它是“从外向内传递 ref”的工具。

但你的需求正好相反:
你需要从 children 身上读取它已有的 ref(如果使用者传了 ref),然后和 dnd-kit 的 ref 合并,再写回去。

forwardRef 完全读不到 children 的 ref。它只能处理从父组件传进来的 ref。

如果你把组件改成 forwardRef 包一层:

  • 你拿到的 ref 是父组件传给你的
  • children 原有的 ref 被忽略
  • dnd 的 setNodeRef 还是挂不到正确的元素上

方向完全反了。

五、ref 合并的必要性

如果使用者这样用你的组件:

<Draggable>
  <Card ref={myCardRef} />
</Draggable>

children 已经有 ref 了。
你再直接 ref={setNodeRef} 会覆盖掉 myCardRef,导致外部拿不到 Card 的实例。

所以必须用 composeRefs(或 useMergedRef)把两个 ref 合并:

const mergedRef = composeRefs(setNodeRef, children.ref);

cloneElement 能把这个 mergedRef 正确覆盖回去,这是 forwardRef 做不到的。

六、总结对比

需求 forwardRef 是否能解决 cloneElement + composeRefs 是否能解决
父组件传 ref 给内部 DOM
读取 children 已有的 ref
合并两个 ref 并覆盖回去
不增加额外 DOM 层 通常会增加
适合 dnd-kit 这种场景 不适合 非常适合

七、结论

你的场景是典型的“无 DOM 行为注入”:

  • 不产出额外节点
  • 增强 children 的能力
  • 保留使用者原有的 ref 和 props

React 生态里这种模式非常常见(Radix UI、Headless UI、很多拖拽库的 wrapper 都这么做)。
cloneElement + ref 合并 是目前最干净、最符合需求的解法。

forwardRef 在这里不但帮不上忙,反而会让 ref 指向错误的方向。