现象
站点前台的栏目切换用 View Transitions:只捕获正文 main,页头保持实时渲染,当前栏目的胶囊在实时页头里滑动。录屏回看时总觉得点击那一下页头“闪了一下”,但又说不清是哪里。
逐帧量化
录屏是 60 fps、436 帧。把顶部 48 px 的页头裁出来转成灰度,按区域逐帧算平均亮度:
| 区域 | 436 帧亮度范围 | 结论 |
|---|---|---|
| Logo | 42.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 ms | startViewTransition | a 关于 | 高亮 |
| +9 ms | update 回调开始(已截图) | html | 灰 |
| +68 ms | ready | html | 高亮(已是当前项) |
| +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 让带标记的导航项沿用悬停色:
}标记必须在 startViewTransition 之前同步挂上:截图发生在下一帧,晚一步就截到灰色了。
验证
同一份本地构建,用 MutationObserver 立即删掉标记来模拟修复前,对比截图时刻链接的计算颜色(亮色主题):
| 截图时颜色 | :hover 命中 | |
|---|---|---|
| 修复前 | rgb(82, 82, 82),灰 | html |
| 修复后 | rgb(21, 21, 21),高亮 | html |
命中测试仍然落在 <html>,指针变箭头也照旧,这部分属于浏览器行为,无法消除;但文字不再暗下去。无头浏览器在暂停绘制期间不出帧,所以这里用截图时刻的计算颜色作为代理,没有逐帧拍到修复后的画面。
方法小结
- 判断“有没有闪”,先把区域拆开量化:静态区域方差约为 0 就能排除整体闪烁,再只看有变化的区域。
- 录屏里的鼠标指针是现成的命中测试探针,形状突变往往比像素变化更早说明问题。
- View Transitions 的截图帧会冻结到
ready,任何依赖指针状态的样式(:hover、:active)在这一帧都可能失真,需要用显式标记兜住。