症状
从首页点文章卡片进详情页,自定义光标正常从"展开态"回缩到原点。从归档页点进去,光标卡在展开态不动了。
两个入口,同一个目标页面,同一套代码,一个好一个坏。
现场勘查
光标组件用的是 vue3-bits 的 TargetCursor,挂在 App.vue 里。它的悬停逻辑分两条路径:
- 正常退出:鼠标离开目标元素 →
mouseleave事件 → 清理函数 → 光标回缩 - 兜底退出:页面滚动 →
elementFromPoint检查光标下元素是否还是原来那个 → 不是则清理
路由过渡用 <transition mode="out-in">,离开时 opacity: 0 的 GSAP 动画过后直接卸掉旧 DOM。
问题出在第二条路径——旧 DOM 被卸掉的时候,mouseleave 根本不会触发。
那滚动兜底呢?首页能工作是因为 Hero 占了 100vh,卡片在折叠线以下,scrollBehavior({ top: 0 }) 把页面卷回顶部,卡片滚出视口,elementFromPoint 发现光标下换了人,清理触发。
归档页没有 Hero,卡片就在首屏。卷不动,elementFromPoint 指着同一张卡片的子元素,顺着 DOM 树往上找到的还是原来那个 <a>,判断"没变",跳过清理。
首页能工作纯属巧合——是布局差异带来的副作用,不是设计如此。
走弯路
尝试的顺序大概是:
router.afterEach+rAF派发mousemove→ 没用。mousemove只更新光标位置,不触发悬停检测- 在
onEnter过渡钩子里派发scroll→ 光标回缩了,但页面会跳。因为 GSAP 正在做进入动画,scroll 事件搅进去了 - 绕过 TargetCursor,直接操作它内部的角标 DOM → 角标确实回到了默认位置,但 TargetCursor 内部有个 parallax ticker 在持续跑,下一帧又把角标推回展开态
- 换成
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 想让光标回缩,结果它又触发了一次位置更新。
解法
三条原则:
- 等旧 DOM 卸了再动手——
setTimeout(400ms),比onLeave的 350ms 多 50ms 缓冲 - 只派发 scroll,不派发 mousemove——光标位置由真实鼠标事件跟踪,不需要我们管
- 抑制回声——
suppressScrollDispatch标志让 App.vue 自己的 scroll handler 在合成事件中静默
最终改动不到 20 行:
// 合成 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 状态;给事件派发打标签追踪链条。比任何猜测都快。