Skip to main content

BFCache 与页面恢复

BFCache 让「后退」变成瞬时恢复——整个页面状态被冻结在内存里。截至 2026 年 10 月,它已在 Chrome、Firefox、Safari 等主流浏览器中默认开启。它也顺便让很多 SPA 的初始化逻辑「失效」,因为从缓存恢复时脚本不会重新执行。

一、整页冻在内存里​

BFCache 是浏览器把整个页面(含 JS 堆与 DOM 状态)冻结保存在内存里的机制,用户点后退时直接解冻恢复,不重新发请求、不重新执行脚本、不重新渲染。所以它是瞬时的,且滚动位置、表单内容、已展开的面板全都还在——这些靠自己在 beforeunload 里塞状态缓存很难做到一致,因为原页面根本没被卸载。

和 HTTP 缓存的区别要分清:HTTP 缓存存的是资源字节,页面还是要重新解析、重新执行脚本;BFCache 存的是「页面活着的那一刻」,恢复时连 JS 变量都还在。它的代价是内存——浏览器会在内存压力下回收这些冻结页。

它是瞬时的,且状态全在,这是自己做状态缓存做不到的

正常页面离开导航冻结,内存保留后退被回收(内存压力下)恢复后 JS 堆与 DOM 都还在一票否决项:unload / beforeunload 监听、长连接、no-store、打开的 IndexedDB 连接、持有的 Web Locks。
图:BFCache 的生命周期——冻结而非销毁,所以恢复是瞬时的

二、让它失效的几种写法​

最常见的几条失效因素:

  • 注册了 unload 监听。浏览器为了安全无法冻结一个挂了 unload 的页面,应当改用 pagehide 或 visibilitychange,它们不阻止进入 BFCache。
  • 存在未关闭的连接,比如还连着的 WebSocket、未完成的 fetch、长轮询。
  • 响应头带了 Cache-Control: no-store,浏览器据此认为页面不应被缓存。
  • 部分第三方脚本的行为(比如某些分析脚本会注册 unload)也会拖累。

前两条是前端自己造成的,排查时先从自身代码查起——搜一遍项目里有没有 addEventListener('unload', ...),以及有没有在页面停留期间一直开着的连接。

// 不要这样:unload 会让页面失去 BFCache 资格
window.addEventListener('unload', () => reportLeave())
// 改成:pagehide 不阻止冻结,且能覆盖大多数「离开」场景
window.addEventListener('pagehide', (e) => {
if (!e.persisted) reportLeave() // persisted 为 false 才是真正卸载
})

排查失效先从自身代码查起,unload 监听和长连接是最高频的元凶

三、恢复时脚本不会重跑​

SPA 的路由通常是 history.pushState,这意味着点后退时浏览器恢复的是上一个历史记录——但应用不会重新执行初始化逻辑。如果你的入口脚本在加载时拉了用户信息和列表,从缓存恢复时这段逻辑不会重跑,状态还是冻结时的旧值。

要在恢复时区分两种情况,监听 pageshow 事件并看 event.persisted:为 true 说明是从 BFCache 恢复。

window.addEventListener('pageshow', (e) => {
if (e.persisted) {
// 从 BFCache 恢复:状态还在,但时效性数据要重新校验
refreshPriceAndStock()
revalidateSession()
}
})

价格、库存、登录态这类时效性数据必须重新校验,否则用户看到的是几分钟前的值。登录态可能已过期,该跳登录就跳登录。

本地就能验,不必等线上数据:

# DevTools → Application → Background services → Back/forward cache
# 点「Test back/forward cache」,会明确告诉你命中还是被什么东西挡住了

# 命令行侧可以用 CDP 的 Page.navigateToHistoryEntry 复现后退行为

四、怎么确认真的命中了​

别靠猜,要看到命中没有。开发期可以用 DevTools 里的前进/后退缓存检测项,它会直接列出「本页是否能进 BFCache」以及卡在哪条失效因素上。线上可以埋点统计 pageshow 事件中 persisted 为 true 的比例,作为真实环境的命中率。

// 统计 BFCache 命中率:在 pageshow 里上报 persisted
window.addEventListener('pageshow', (e) => {
reportMetric('bfcache_hit', e.persisted) // true 即命中
})

命中率低的页面,优先排查 unload 监听与停留期间的连接。如果命中率低,按第二节的清单逐条排除:先去掉 unload、再关掉停留期间的连接、再检查响应头。

统计时还要注意维度:命中是按页面算的,同一路由下不同参数(比如不同的商品详情页)最好分开看。长期偏低时,先确认页面停留期间有没有开着长连接——WebSocket、SSE 和没停掉的轮询都会让浏览器放弃缓存这一页。

恢复时要补的事可以收在一个函数里,别散落在各处:

function onRestore() {
// 1. 时效性数据重新校验
refreshPrice()
// 2. 登录态可能已过期
if (!isSessionValid()) redirectToLogin()
// 3. 暂停的动画、轮询、播放器恢复
resumePolling()
}

window.addEventListener('pageshow', (e) => {
if (e.persisted) onRestore() // 从 BFCache 恢复
})

五、它和 HTTP 缓存不是一回事​

BFCache 保存的是整页的运行状态——JS 堆、DOM、定时器都还在;HTTP 缓存保存的是资源字节。两者可以同时命中,也可能只有一个命中,所以「静态资源命中了强缓存」推不出「后退是瞬时的」。

一个容易混的推论:Cache-Control: no-store 会连带让页面失去 BFCache 资格,但反过来,给页面配好强缓存并不会让它获得这个资格。

什么情况会让页面失去 BFCache 资格,MDN:BFCache 列了主要原因,排查时按清单过一遍。