菜单

停不下来的 spinner:back/forward cache 与一个回不去的等待状态

2026-10-16MCP Checkup

本文译自 2026-09-23 的英文原文。

首页的检查有一个等待状态。你按下按钮,按钮把自己禁用,标签变成「检查中」并带一个按秒走的计时,探测完成后浏览器跳转到结果页。很简单,但它有三个 bug,只有在你做人们实际会做的事时才会冒出来:改主意,按返回。

用户看到了什么

从结果页按返回,首页会原样回来:按钮禁用,标签定格在最后一次计数,一直如此。没有哪一次点击能把它救回来。唯一的出路是刷新,而没人会去刷新一个看起来正忙的页面;他们会等,然后离开。

原因

现代浏览器会把最近离开的页面保存在 back/forward cache 里。按返回不会重新加载页面,而是把整个冻结的文档原样恢复,连 JavaScript 状态一起。我们的页面被冻结在等待状态,按钮永远不会有人重新启用,因为根本没有任何代码路径会重新启用它。计时器本身在页面隐藏时已经停了;标签只是一直保留着那一刻显示的数字。

还有两个小缺陷和它缠在一起。中途取消跳转(按 Esc,或点了某个链接)会让计时器继续走,因为导航被取消时浏览器不会触发任何事件。另外,页面在 pagehide 之外还注册了一个 beforeunload 监听器,作为停止计时的双保险。在 Firefox 里,带 beforeunload 监听器的页面没有资格进入 back/forward cache,于是 Firefox 用户得到的是一次缓慢的完整重载,而不是瞬间恢复;Chromium 用户得到的则是冻住的 spinner。两边都糟糕,原因恰好相反。

修法

等待状态被重写成一个纯粹的控制器,时钟由外部注入:提交、每秒一次的 tick、复位、隐藏时停止。纯粹,所以测试不用浏览器就能走遍每一个状态转换;注入时钟,所以「过了二十秒」是一次函数调用,而不是一次 sleep。

三个事件驱动它:

  • pageshow 且 event.persisted === true,表示浏览器是从缓存恢复了页面。控制器复位:按钮启用,标签复原,计时停止。冻住的 spinner 就是这样修好的。
  • pagehide 停止计时,这样待在缓存里的页面不会自己在那儿数数。
  • 一个上限。 如果过了二十秒仍没有跳转,控制器复位并重新启用按钮。探测器自己的兜底时限是十二秒,所以二十秒严格晚于探测器的兜底;有一条测试钉住这个不等式,谁也不能只调低其中一个常量而不察觉另一个。对被取消的跳转,这也是唯一可用的修法,因为没有事件可以监听。

beforeunload 监听器被直接删掉了。一条源码文本守卫断言两个首页都不含这个字符串,所以它不会通过复制粘贴回来。两个语言版本的首页原先各自带着一份旧的内联脚本;现在它们导入同一个模块。

在真实浏览器里检验

单元测试证明的是控制器,证明不了浏览器会把页面放进缓存。修复前,Firefox 因为 beforeunload 监听器把这个页面视为不符合条件。修复后,Chrome DevTools 中 Application 下的 back/forward cache 测试,在预先填好 endpoint 的生产首页上报告「Successfully served from back/forward cache」;恢复时的复位本身,则由上面的单元测试钉住。一句关于浏览器行为却没有测量的声称,正是我们不再写的那种注释,所以这一节准确写明了这两件事我们各自有的是哪一种依据。

通用的教训

任何带「忙碌」状态的页面,都需要一条回到「空闲」的路,而这条路不能依赖那个忙碌的操作完成;它还必须经得起被冻结再解冻。pageshow 加上 persisted,就是浏览器为解冻提供的钩子。如果你不处理它,你的页面迟早会被恢复到一个离不开的状态,而看着它的人会以为网站坏了,而不会想到是自己按了返回。