一个光标不回缩的 Bug 是怎么挖出来的

症状

从首页点文章卡片进详情页,自定义光标正常从"展开态"回缩到原点。从归档页点进去,光标卡在展开态不动了。

两个入口,同一个目标页面,同一套代码,一个好一个坏。

现场勘查

光标组件用的是 vue3-bitsTargetCursor,挂在 App.vue 里。它的悬停逻辑分两条路径:

  • 正常退出:鼠标离开目标元素 → mouseleave 事件 → 清理函数 → 光标回缩
  • 兜底退出:页面滚动 → elementFromPoint 检查光标下元素是否还是原来那个 → 不是则清理

路由过渡用 <transition mode="out-in">,离开时 opacity: 0 的 GSAP 动画过后直接卸掉旧 DOM。

问题出在第二条路径——旧 DOM 被卸掉的时候,mouseleave 根本不会触发。

那滚动兜底呢?首页能工作是因为 Hero 占了 100vh,卡片在折叠线以下,scrollBehavior({ top: 0 }) 把页面卷回顶部,卡片滚出视口,elementFromPoint 发现光标下换了人,清理触发。

归档页没有 Hero,卡片就在首屏。卷不动,elementFromPoint 指着同一张卡片的子元素,顺着 DOM 树往上找到的还是原来那个 <a>,判断"没变",跳过清理。

首页能工作纯属巧合——是布局差异带来的副作用,不是设计如此。

走弯路

尝试的顺序大概是:

  1. router.afterEach + rAF 派发 mousemove → 没用。mousemove 只更新光标位置,不触发悬停检测
  2. onEnter 过渡钩子里派发 scroll → 光标回缩了,但页面会跳。因为 GSAP 正在做进入动画,scroll 事件搅进去了
  3. 绕过 TargetCursor,直接操作它内部的角标 DOM → 角标确实回到了默认位置,但 TargetCursor 内部有个 parallax ticker 在持续跑,下一帧又把角标推回展开态
  4. 换成 nextTick 派发 scroll → 日志里 elementFromPoint 清清楚楚指着旧页面的 PostCard。这时才意识到 mode="out-in" 先做离开动画才卸 DOM,nextTick 太早了

此处得到一个教训:加日志时直接打印 document.elementFromPoint(x, y) 返回的是什么元素,比任何猜测都管用。

定位

给每步派发打上标签——[scroll] 是真实滚动的、[nt①]nextTick 的、[reset] 是兜底的——日志立刻暴露了问题:

  • 离开过渡最长 350ms(onLeave 里的 setTimeout(safeDone, 350)
  • 400ms 后旧 DOM 一定卸掉了
  • 但在这 400ms 内,dispatchMouseMove 也在不停派发,光标被反复拽回旧坐标,看起来"乱窜"

另外还发现 App.vue 自身的 scroll handler 会在合成 scroll 事件时额外派发一次 mousemove,这是一个"回声"——我们去派发 scroll 想让光标回缩,结果它又触发了一次位置更新。

解法

三条原则:

  1. 等旧 DOM 卸了再动手——setTimeout(400ms),比 onLeave 的 350ms 多 50ms 缓冲
  2. 只派发 scroll,不派发 mousemove——光标位置由真实鼠标事件跟踪,不需要我们管
  3. 抑制回声——suppressScrollDispatch 标志让 App.vue 自己的 scroll handler 在合成事件中静默

最终改动不到 20 行:

javascript
// 合成 scroll 时不派发 mousemove,避免光标位置被干扰let suppressScrollDispatch = falsefunction onScroll() {  if (suppressScrollDispatch) return  dispatchMouseMove()}// 等旧 DOM 卸载后触发 TargetCursor 的 scroll 兜底检测router.afterEach(() => {  setTimeout(() => {    suppressScrollDispatch = true    window.dispatchEvent(new Event('scroll'))    suppressScrollDispatch = false  }, 400)})

回顾

这个问题本质上是一个时序漏洞:TargetCursor 的清理依赖 mouseleave(不适用于 DOM 卸载)和 scroll 兜底(只在滚动量足够时生效),而这两个假设在 SPA 路由切换场景下都不成立。

首页能工作只是巧合——布局差异带来了"足够的滚动量",触发了兜底逻辑。这种"看起来 OK 但原理不对"的情况是最危险的,因为它会让你忽略真正的设计缺陷。

调试这类问题的两个实用技巧:用 document.elementFromPoint 打印光标下的真实 DOM 状态;给事件派发打标签追踪链条。比任何猜测都快。