现象

站点前台的栏目切换用 View Transitions:只捕获正文 main,页头保持实时渲染,当前栏目的胶囊在实时页头里滑动。录屏回看时总觉得点击那一下页头“闪了一下”,但又说不清是哪里。

逐帧量化

录屏是 60 fps、436 帧。把顶部 48 px 的页头裁出来转成灰度,按区域逐帧算平均亮度:

区域436 帧亮度范围结论
Logo42.04–42.06不变
右侧搜索与主题按钮33.55–33.58不变
页头背景空白13.0–13.0不变
各导航项27.8–30.8有变化

整条页头没有闪。导航项的变化有两种:一种是胶囊滑动,属于预期;另一种是被点的那一项每次点击都会从悬停的高亮色退回灰色,持续 2–3 帧(33–50 ms)后恢复。把这几帧拼成条带图再看,同一时刻鼠标指针也从手形变成了箭头。

指针变箭头是关键线索:浏览器认为指针下面已经不是那个链接了。

确认原因

在页面里包装 document.startViewTransition,每 2 ms 采样一次 :hover 链最深的元素和被点链接的计算颜色,鼠标停在「关于」上点击:

时间事件:hover 命中链接颜色
+0 msstartViewTransitiona 关于高亮
+9 msupdate 回调开始(已截图)html灰
+68 msreadyhtml高亮(已是当前项)
+88 ms悬停恢复a 关于高亮

这是规范行为。CSS View Transitions 规定,在 rendering suppression 期间(截取旧状态之后、update 回调完成之前):

all pointer hit testing must target its document element, ignoring all other elements.

所以链接失去 :hover,指针也回到默认箭头。

为什么偏偏能看到

命中测试落到 <html> 本身看不见,问题出在截图那一帧:

sequenceDiagram
    participant U as 用户
    participant B as 浏览器
    participant P as 页面
    U->>P: 点击「关于」
    P->>B: startViewTransition
    B->>B: 进入 rendering suppression,命中测试交给 html
    B->>B: 截图帧:链接已失去 hover,画成灰色
    Note over B: 暂停绘制,屏幕冻结在截图帧
    B->>P: update 回调:提交新路由
    P-->>B: 新页面渲染完成
    B->>U: ready:开始动画,页头实时绘制
    B->>B: 下一次命中测试,hover 恢复

录屏也对得上:灰掉的那 2–3 帧里,旧栏目的文字仍是高亮,胶囊也还在旧位置,说明屏幕显示的是提交新路由之前的截图帧,只是被点链接已经按“未悬停”绘制。

项目里还有两处设定把它放大了:

  • 栏目过渡时页头不进快照,ready 之后的页头是实时画面,悬停色的丢失和恢复都会如实显示。
  • 为了让当前项立即切换,过渡期间页头链接设了 transition: none,颜色是直接跳变,没有 150 ms 渐变来缓冲。

所以它不是逻辑 bug,路由、命中测试找回点击、胶囊动画都正常;它是一个由规范行为加实时页头造成的视觉瑕疵。

修复

页面无法改变浏览器的命中测试,但可以不依赖 :hover 来决定颜色。点击时记下被点的链接,在开始截图之前挂一个标记:

document.documentElement.classList.add('is-route-transitioning');
if (pressed) {
  pressed.dataset.vtPressed = '';
  nav.pressed = pressed;
}

标记在过渡结束、被取消或被下一次点击接力时撤掉:接力复用正在播放的过渡,不会再截图,也就不需要保持。CSS 让带标记的导航项沿用悬停色:

.nav-item:hover,
.nav-item[data-vt-pressed] {
  color: var(--color-fg);
}

标记必须在 startViewTransition 之前同步挂上:截图发生在下一帧,晚一步就截到灰色了。

验证

同一份本地构建,用 MutationObserver 立即删掉标记来模拟修复前,对比截图时刻链接的计算颜色(亮色主题):

截图时颜色:hover 命中
修复前rgb(82, 82, 82),灰html
修复后rgb(21, 21, 21),高亮html

命中测试仍然落在 <html>,指针变箭头也照旧,这部分属于浏览器行为,无法消除;但文字不再暗下去。无头浏览器在暂停绘制期间不出帧,所以这里用截图时刻的计算颜色作为代理,没有逐帧拍到修复后的画面。

方法小结

  • 判断“有没有闪”,先把区域拆开量化:静态区域方差约为 0 就能排除整体闪烁,再只看有变化的区域。
  • 录屏里的鼠标指针是现成的命中测试探针,形状突变往往比像素变化更早说明问题。
  • View Transitions 的截图帧会冻结到 ready,任何依赖指针状态的样式(:hover、:active)在这一帧都可能失真,需要用显式标记兜住。