返回博客

拖拽手柄在真实鼠标下正常、合成事件下死掉——setPointerCapture 抛异常吞掉了整条 handler

做了一个可调宽的分隔条(resizable splitter)。真实鼠标拖动完全正常,但写 Playwright 测试时,用 dispatchEvent 派发合成 PointerEvent,拖拽完全没有位移响应——就像根本没触发一样。线上也偶尔收到触控笔用户反馈"拖不动"。

两种场景看上去毫不相关,根因却是同一行代码。

现象

  • 真实鼠标拖动:完全正常,指针移出手柄范围仍能跟踪。
  • Playwright / dispatchEvent 合成事件:onPointerDown 触发了,但后续 onPointerMovedragRef.current 永远是初始值 null,early-return,零位移。
  • 某些触控笔或特殊输入设备:拖拽根本不启动。

根因

element.setPointerCapture(pointerId) 要求 pointerId 对应一个当前活跃的指针(active pointer)。合成 PointerEvent 的 pointerId 不绑定任何真实 active pointer,调用时浏览器直接抛 DOMException: InvalidPointerId

问题出在调用顺序:

// ❌ 错:capture 在前,抛错后下面整段代码不执行
const onPointerDown = (e: React.PointerEvent) => {
  e.currentTarget.setPointerCapture(e.pointerId); // 合成事件在这里抛 → handler 中断
  dragRef.current = { startPos: e.clientX, base, avail };
  setDragging(true);
};

setPointerCapture 抛出 → JavaScript 异常向上冒泡 → dragRef.current 赋值那行永远不会执行。结果:dragRef.current 仍为 nullonPointerMove 里的守卫判断直接 return,整个拖拽"死"。

坑:setPointerCapture 放在状态设置之前,合成事件必然中断整条 handler

setPointerCapture 是浏览器原生 API,合成/边缘 PointerEvent 会抛 InvalidPointerId。 把它放在 dragRef.current = ... 之前,意味着一旦抛错,拖拽状态永远不会被初始化——后续所有 onPointerMove 全部 early-return,用户无感知地"拖不动"。

正解

先把拖拽状态落地,再 best-effort 捕获指针

// ✅ 对:状态先落,capture 后做,且 try/catch 隔离异常
const onPointerDown = (e: React.PointerEvent) => {
  // 1. 先写状态——无论 capture 成不成,拖拽逻辑都能跑
  dragRef.current = { startPos: e.clientX, base, avail };
  setDragging(true);
 
  // 2. best-effort capture——真实指针下生效(移出范围仍跟踪)
  //    合成/边缘态拿不到也无妨,退化为普通拖拽
  try {
    e.currentTarget.setPointerCapture(e.pointerId);
  } catch {
    /* noop */
  }
};

真实指针下 setPointerCapture 正常生效,拖出元素边界仍能收到 pointermove;合成事件下 capture 失败但状态已经写好,拖拽照常工作(只是指针移出元素边界会中断,对测试场景完全够用)。

正解:状态先于 capture,capture 包 try/catch

把核心状态(dragRef.current = ...setDragging(true))放在 setPointerCapture 之前, 再把 setPointerCapture 包在 try/catch 里。 这样无论 capture 成功与否,拖拽主流程都不受影响。

通用原则

setPointerCapture 只是拖拽的增强(让鼠标移出元素仍能跟踪),不是核心控制流。同类 API 还有 requestFullscreenreleasePointerCaptureHTMLVideoElement.play() ——它们都可能因权限、状态、合成事件等原因抛异常。共同原则:把可能抛异常的浏览器 API 置于核心状态变更之后,并用 try/catch 隔离,别让副作用的失败连累主流程。

附:合成事件下拖拽还是看不到状态变化?

如果已经修了调用顺序,合成事件测试仍然看不到 dragRef 变化,可能是 React 批处理问题: setDragging(true) 是异步合并的,同步读 dragRef.current 拿到的是旧值。 这属于另一个坑:React 批处理下合成事件后同步读 state 不可靠。

一句话外卖

浏览器 API 中"可能抛异常的副作用"必须放在核心状态写入之后,并 try/catch 隔离——它是增强,不是主流程的守门人。