拖拽手柄在真实鼠标下正常、合成事件下死掉——setPointerCapture 抛异常吞掉了整条 handler
做了一个可调宽的分隔条(resizable splitter)。真实鼠标拖动完全正常,但写 Playwright 测试时,用 dispatchEvent 派发合成 PointerEvent,拖拽完全没有位移响应——就像根本没触发一样。线上也偶尔收到触控笔用户反馈"拖不动"。
两种场景看上去毫不相关,根因却是同一行代码。
现象
- 真实鼠标拖动:完全正常,指针移出手柄范围仍能跟踪。
- Playwright /
dispatchEvent合成事件:onPointerDown触发了,但后续onPointerMove里dragRef.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 仍为 null,onPointerMove 里的守卫判断直接 return,整个拖拽"死"。
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 失败但状态已经写好,拖拽照常工作(只是指针移出元素边界会中断,对测试场景完全够用)。
把核心状态(dragRef.current = ...、setDragging(true))放在 setPointerCapture 之前,
再把 setPointerCapture 包在 try/catch 里。
这样无论 capture 成功与否,拖拽主流程都不受影响。
通用原则
setPointerCapture 只是拖拽的增强(让鼠标移出元素仍能跟踪),不是核心控制流。同类 API 还有 requestFullscreen、releasePointerCapture、HTMLVideoElement.play() ——它们都可能因权限、状态、合成事件等原因抛异常。共同原则:把可能抛异常的浏览器 API 置于核心状态变更之后,并用 try/catch 隔离,别让副作用的失败连累主流程。
如果已经修了调用顺序,合成事件测试仍然看不到 dragRef 变化,可能是 React 批处理问题:
setDragging(true) 是异步合并的,同步读 dragRef.current 拿到的是旧值。
这属于另一个坑:React 批处理下合成事件后同步读 state 不可靠。
一句话外卖
浏览器 API 中"可能抛异常的副作用"必须放在核心状态写入之后,并 try/catch 隔离——它是增强,不是主流程的守门人。