点了三年返回键,今天才发现快的原因是缓存而不是网络——这件事今天被一个浏览器 API 说清楚了

你的返回体验「感觉」快了,但到底是浏览器从缓存捞的,还是网络重新拉的——这件事今天浏览器自己会告诉你了。

bfcache(back-forward cache)这个能力,Chrome 92 桌面版默认开启之后,前进后退的体验确实快了。10% 到 20% 的导航直接从内存恢复,连网络请求都省了。但快归快,快的原因是黑盒——你只知道「好像快了」,不知道快在哪、有多少比例是真的走了缓存、有多少比例其实是网络还行而不是命中了 bfcache。

这个问题在生产环境里更难办。bfcache 命中率和页面结构、第三方脚本、fetch 取消行为、unload 事件、Cache-Control no-store 头都有关,但没有任何原生手段能告诉你「你的页面为什么没有走 bfcache」。

直到现在。

NotRestoredReasons API:bfcache 的诊断接口

Chrome 125 给 Performance API 加了一个新属性:PerformanceNavigationTiming.notRestoredReasons。这个接口返回的是一个树形结构,记录当前页面在进入 bfcache 之前被拦下来的原因——而且不只记录顶级框架,还递归记录所有同源 iframe 的拦截原因。

用 PerformanceObserver 就能拿到数据:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.notRestoredReasons) {
      console.log("bfcache 拦截原因:", JSON.stringify(entry.notRestoredReasons, null, 2));
    }
  }
});

observer.observe({ type: "navigation", buffered: true });

返回的结构长这样:

{
  "children": [
    {
      "id": "comments-iframe",
      "name": "comments",
      "src": "/comments.html",
      "url": "https://example.com/comments.html",
      "reasons": ["unload-listener", "masked"],
      "children": []
    }
  ],
  "id": "article-page",
  "name": "main",
  "reasons": ["response-cache-control-no-store"],
  "src": null,
  "url": "https://example.com/article/123"
}

顶级框架的 reasons 数组里,每个字符串都是一个拦截原因。规范定义的原因包括:

  • fetch — 页面卸载时有未完成的 fetch 请求被取消了,页面状态不稳定
  • lock — 持有 Web Lock API 的锁,卸载时锁被强制终止
  • websocket — 有活跃的 WebSocket 连接
  • parser-aborted — HTML 解析还没完成
  • navigation-failure — 原始导航本身失败了
  • masked — 跨域 iframe 存在,但具体原因被浏览器隐藏(保护隐私)

浏览器自己还可能加额外的原因,最常见的是 unload-listener(页面注册了 unload 事件处理器)和 response-cache-control-no-store(服务器在响应头里带了 Cache-Control: no-store)。

三个最常见的拦截场景

在实际项目中,bfcache 失效的原因集中在三类:

1. unload 事件监听器

这是最常见的拦路虎。大量老项目还在用 window.addEventListener("unload", ...) 做清理工作,但 unload 事件本身已经被 Chrome 标记为 deprecated,因为它会阻止 bfcache。更关键的是,许多第三方脚本(统计、广告、监控 SDK)会在背后偷偷注册 unload 处理器,开发者在不知不觉中就把自己的页面拦在了 bfcache 外面。

解决方案是换成 pagehide 事件,它是专门为这个场景设计的,浏览器会正确处理 bfcache 的保存和恢复。

2. Cache-Control: no-store 头

这是后端层面的拦路虎。如果页面响应带了 Cache-Control: no-store,浏览器不会把页面放进 bfcache,哪怕页面本身完全符合 bfcache 条件。很多 CDN 配置或者后端安全头会默认加上 no-store,导致这个性能优化悄悄失效。

用 Chrome DevTools 的 Network 面板看一下页面主文档的响应头,检查有没有 no-store。

3. 卸载时有未完成的 fetch 请求

现代 SPA 大量使用 fetch 做数据请求。如果用户在页面即将进入 bfcache 时恰好有一个请求在飞,浏览器会认为页面状态不稳定,取消这个 fetch,并因此拒绝将页面放入 bfcache。这类问题比较隐蔽,因为请求本身可能是业务逻辑无关的预加载或者心跳。

怎么用这个 API 做生产监控

拿到数据只是第一步,关键是怎么用在真实监控里。

一个典型的 RUM 集成思路:

// 每次导航完成之后,检测 bfcache 命中情况
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.notRestoredReasons) {
      // bfcache 未命中,上报到你的监控系统
      const reasons = flattenReasons(entry.notRestoredReasons);
      navigator.sendBeacon("/analytics", JSON.stringify({
        type: "bfcache_miss",
        url: entry.name,
        reasons: reasons,
        timestamp: Date.now()
      }));
    } else {
      // bfcache 命中
      navigator.sendBeacon("/analytics", JSON.stringify({
        type: "bfcache_hit",
        url: entry.name,
        timestamp: Date.now()
      }));
    }
  }
});

observer.observe({ type: "navigation", buffered: true });

// 递归展平拦截原因,方便统计
function flattenReasons(node, results = []) {
  if (node.reasons) {
    results.push(...node.reasons.map(r => `${node.url || node.src}: ${r}`));
  }
  if (node.children) {
    node.children.forEach(child => flattenReasons(child, results));
  }
  return results;
}

用这个数据,你可以做出类似这样的生产仪表盘:

  • bfcache 命中率:过去 7 天,前进后退操作中有多少比例走了 bfcache
  • 拦截原因分布:unload-listener / no-store / fetch / websocket 各占多少百分比
  • 页面级别的 bfcache 评分:给每个页面打健康分,低于 60 分的优先优化

不只是性能指标,是产品指标

bfcache 命中率其实是一个被严重低估的产品指标。用户的前进后退操作占了所有导航的 10% 到 20%,这部分体验好不好,直接影响用户对「你的网站快不快」的整体感知。

一个电商详情页,如果用户点返回等了两秒,转化率损失可能是 5%。但这个两秒的感知,和「网络慢」是两回事——用户其实是在为 bfcache 失效付出代价,而你的 Lighthouse 分数可能还是绿的。

今天,NotRestoredReasons API 把这件事从黑盒变成了白盒。免费,原生,Chrome 125 就支持。下次做性能优化的时候,先把这个诊断接口接上去,看看你的页面到底是被什么东西拦在了 bfcache 外面。

评论区

0 条评论

登录后可评论。

阿速·性能优化 14 阅读