# 抬起去哪了：一次点击丢失排查

轻点失灵、用力按一下又恢复。沿事件链逐层检查后，我们发现问题发生在网页收到事件之前，恢复策略却决定了后续体验。

杨永安 + Claude Code · 2026-11-05 · 原始记录: 2026-10-02

https://flydogai.com/bruce/blog/the-missing-release?lang=zh

<a id="an-incomplete-click"></a>

## 一次点击，少了一半

反馈很像一个前端问题：设置侧边栏轻点时有些栏目没有反应，用力按一下触摸板，后面的轻点又正常了。早先我们曾把类似现象归因于 React 的点击兼容性，并把一些操作提前到按下时执行。但那个解释没有经过完整测量。

这次直接在实际窗口记录事件。失败时有 pointerdown 和 mousedown，随后没有抬起。下一次操作又少了新的 pointerdown。WebKit 仍以为上一轮按键没有松开，后续判断也跟着错了。问题从“点击不灵”变成了“释放事件在哪里丢了”。

```text
pointerdown → mousedown → …
下一次：mousedown → pointerup → mouseup → click
```

<a id="follow-the-event"></a>

## 沿着事件链，一层一层问

我们先确认命中的确实是按钮，没有遮罩或拖拽区域挡住它。然后检查 WebView、窗口、应用，以及系统投递给进程的事件。那个丢掉的 mouseUp 在这些层里都没有出现，连系统查询仍把左键记作按下。继续调整页面处理函数，已经无法解释这一段事实。

当时测试机上的窗口管理工具 Magnet 有一个监听鼠标按下、抬起的主动事件拦截器。退出它再复测，结果发生了变化：记录中的两组八次点击分别丢了两到三次抬起；退出后，只使用有效时长的十二次点击没有再丢失。这是当时设备与工具状态下的对照，不能外推为所有安装环境的结论。

合成事件也必须有对照。按下和抬起同一瞬间发出的 0ms 测试，在最小原生窗口中也会丢失，不能拿它代表真实轻点。仪器自己的异常，差点成了我们的产品结论。

<a id="recovery"></a>

## 同样的缺失，也可能有不同的体感

接下来，我们并排比较 Chromium 与 WKWebView 收到的同一类事件。两边都可能收到不完整的序列，差别在于丢失之后如何恢复。测量中，Chromium 会在下一次按下前先结束旧的按下状态；WebKit 则可能继续沿用错误状态。

标题栏归谁处理也重要：原生标题栏上的点击不一定进入网页，而 Bruce 的网页拖拽区域会让 WebView 参与。同样一句“Electron 手感好”，背后可能是恢复策略与事件边界的差异，不能直接简化成某个框架的点击函数更可靠。

<a id="repair-with-boundaries"></a>

## 修复状态，不能制造新的意图

Bruce 无法替别的程序修复事件拦截器，但可以在自己的 WebView 入口维护按下与抬起的配对。实现会结合系统按键变化、事件队列和下一次真实按下，判断何时需要修复。

补发的位置和时间一样重要。早期且目标仍可信时，可以落回按下处完成轻点；太迟的补发只复位，不执行用户已经放弃的操作。把抬起放到指针当前所在的新目标上，可能让共同祖先收到一次幽灵点击。

后续实现还区分了指针拖动与原生拖拽会话，并跟踪迟到的真实释放，避免一次动作结算两次。修复一个丢事件的问题，不应该靠向所有目标无差别地合成点击。

<a id="make-the-evidence-useful"></a>

## 把排查方法留给下一位开发者

这些记录能帮助使用 WebView、原生窗口或混合界面的开发者，也能给代为排查的 Agent 一条更稳妥的路线。具体修复会随平台与版本变化，但可观测性和对照的方法可以复用。

- 把“没响应”拆成命中、按下、抬起、派发与状态恢复。
- 逐层确认事件是否到达，越过已排除的边界后就停止反复猜测。
- 保留失败与成功两组对照，并注明合成事件的条件。
- 仪器先确认目标窗口，避免合成输入落到别的应用。
- 恢复过期状态时，不补做用户已经放弃的意图。
