dnd-kit 可拖拽卡片内链接/按钮点击被吞——在组件层统一拦截而非逐处打补丁
做 Kanban 看板时,把整张卡片设为可拖拽元素是很常见的交互。但只要卡片里放了 <Link>、<button> 或 <input>,就会踩一个让人摸不着头脑的坑:点链接开始拖卡片,点按钮没反应。调试半天,最后发现是 dnd-kit 的 PointerSensor 在 pointerdown 阶段接管了整个容器,把交互子元素的事件全吃掉了。
现象
- 拖拽区域铺满整张卡片(
useSortable的listeners直接展开在容器<li>上)。 - 卡片内有
<a>跳转链接或操作<button>。 - 点击链接 → 没有跳转,卡片开始进入拖拽模式;点击按钮 → 事件被吞,
onClick不触发。 - 临时绕法:在每个交互子元素上加
onPointerDown={(e) => e.stopPropagation()},能用,但丑陋——每个新增的按钮都要记得加。
根因
dnd-kit 的 PointerSensor 通过监听容器元素的 pointerdown 事件来启动拖拽状态机。当 listeners 展开在整个容器上,任何落在该容器上的 pointerdown(包括子元素冒泡上来的)都会触发传感器。传感器一旦激活,后续 pointermove 就开始移动卡片,click / focus 等事件链被打断。
activationConstraint: { distance: N } 可以让"纯点击不移动"的操作穿透,但它无法阻止一个从链接上发起的 press-and-drag 抢走整张卡片——而且也解决不了"每个消费者都要手动打补丁"的代码气味。
每次往卡片里塞交互元素就要补一个 stopPropagation,这是组件封装的泄漏——说明组件没有正确处理自己内部的边界条件。随着项目迭代,这类补丁极易遗漏,导致某个新增按钮静默失效。
正解
在可拖拽容器组件内部包装 onPointerDown,检测事件的起源目标是否落在交互元素上,是则跳过拖拽传感器。
const INTERACTIVE_SELECTOR =
"a,button,input,textarea,select,label,[role='button'],[role='link'],[data-no-drag]";
export function isInteractiveDragTarget(target: EventTarget | null): boolean {
return target instanceof Element && target.closest(INTERACTIVE_SELECTOR) != null;
}
function SortableCard({ id, children }) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
const guardedListeners = listeners && {
...listeners,
onPointerDown: (e: React.PointerEvent) => {
if (isInteractiveDragTarget(e.target)) return; // 交互子元素,放行
listeners.onPointerDown?.(e); // 其余区域,进入拖拽
},
};
return (
<li ref={setNodeRef} {...attributes} {...guardedListeners}>
{children}
</li>
);
}几个关键点:
- 只守卫
onPointerDown,其余listeners(含键盘拖拽的onKeyDown)原样保留,无障碍功能不受影响。 Element.closest()向上查找祖先,<a>里面的<span>也能被正确识别为"交互区域"。[data-no-drag]是逃生舱——给自定义元素加这个 attribute 就能阻断拖拽,不需要是原生交互标签。- 消费者无需任何改动,卡片里随便放链接按钮,开箱即用。
把 isInteractiveDragTarget 守卫封进可拖拽容器组件,所有消费者的交互子元素自动穿透,不再需要逐处 stopPropagation。
备选:拖拽把手模式
如果业务上不需要"整张卡片都能拖",可以改用 dnd-kit 官方推荐的 handle 模式——只在一个小拖拽图标上展开 listeners,其余区域完全不参与拖拽逻辑。两种模式的取舍:
| 场景 | 推荐方案 |
|------|----------|
| 整卡可拖 + 内部有交互元素 | closest() 守卫 |
| 只允许从特定把手拖 | handle 模式 |
一句话外卖
消费者需要打补丁才能用得通,这不是消费者的问题——是组件没封好;把守卫下沉到组件层,让调用方零负担。