返回博客

dnd-kit 可拖拽卡片内链接/按钮点击被吞——在组件层统一拦截而非逐处打补丁

做 Kanban 看板时,把整张卡片设为可拖拽元素是很常见的交互。但只要卡片里放了 <Link><button><input>,就会踩一个让人摸不着头脑的坑:点链接开始拖卡片,点按钮没反应。调试半天,最后发现是 dnd-kit 的 PointerSensorpointerdown 阶段接管了整个容器,把交互子元素的事件全吃掉了。

现象

  • 拖拽区域铺满整张卡片(useSortablelisteners 直接展开在容器 <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 模式 |

一句话外卖

消费者需要打补丁才能用得通,这不是消费者的问题——是组件没封好;把守卫下沉到组件层,让调用方零负担。