页面回退没刷新状态,被狠狠提了个bug,到底是... —— 看bfcache的前世今生

1,497 阅读10分钟

在开发C端前端页面的时候,测试反馈当从某个子页面返回主页面时,页面并未刷新,导致用户状态没有及时更新,直接被提出一个bug。明明在PC端开发验证的时候,回退会刷新,并更新状态,为什么到了移动端就不刷新了?难道浏览器原生就有bug吗?

随着探索深入,发现这并不是浏览器的bug,而是浏览器优化用户体验的特性 —— bfcache。在这种加载模式下,页面响应速度非常快,甚至没有调用任何后端接口。同时,调整代码让 bfcache 失效后,页面回退后重新加载一遍,从回退到能看到页面的速度也慢了很多。有没有一种方法,既能吃到这种速度加成,又能更新需要更新状态的值呢?这一切的背后,究竟隐藏着怎样的机制?

bfcache的前世今生

bfcache,全称Backward/Forward Cache,是一种浏览器优化机制,早在2005年就被Firefox引入,Safari在2009年跟进,而Chrome则是在2021年第96版才正式将bfcache设为默认功能,覆盖了所有桌面和移动端设备。bfcache的初衷是为了改善用户体验,它能够在用户点击后退或前进按钮时,几乎瞬间加载页面,而无需重新下载资源或执行冗长的渲染过程。这在网速不佳或设备性能受限的环境下,尤为明显,让浏览变得丝滑流畅。

bfcache的性能奥秘

bfcache的能力在于它能够在内存中存储页面的完整快照,包括JavaScript堆在内的所有元素都被完好保存。当用户选择返回时,浏览器不是从零开始加载,而是直接从内存中恢复页面,这一过程省去了网络请求的时间,极大地提高了加载速度。

据Chrome的数据显示,十分之一的桌面流量和五分之一的移动端流量涉及后退或前进操作。bfcache的启用,每天能为全球互联网用户节省数十亿次的数据传输流量和加载时间。

大家可以对比一下京东m站以及京东超市页面的加载区别~

m站:m.jd.com/

京东超市:pro.m.jd.com/mall/active…

进入上方链接后,随便点击一个商品进入商详页,再用浏览器默认的回退返回,来对比下哪个页面返回的时候加载更快更丝滑呢?

注意要复制链接到浏览器新tab页打开哦(此处埋一个伏笔)。从上方链接直接点击跳转过去,点进商详返回时,还是会重新加载滴。

未启用 bfcache系统会发起新的请求以加载上一页,并且根据该网页针对重复访问的优化程度,浏览器可能需要重新下载、重新解析和重新执行刚刚下载的部分(或所有)资源。
启用 bfcache上一页的加载操作实质上是即时的,因为整个网页可以从内存中恢复,而无需连接至网络。

如何感知bfcache的存在?

bfcache虽然是浏览器自动进行的优化,但作为开发者,我们可以通过特定的事件来感知它的工作。bfcache的主要事件有两个:pageshowpagehidepageshow 事件会在页面首次加载或从bfcache中恢复时触发,而 pagehide 则是在页面即将被缓存或正常卸载前触发。persisted属性则告诉我们页面是否是从bfcache中恢复的。

监听pageshow事件

你可以使用 pageshow 事件的 persisted 属性来判断页面是否是从bfcache中恢复的:

window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    console.log('页面从bfcache中恢复');
  } else {
    console.log('页面正常加载');
  }
});

监听pagehide事件

pagehide 事件也可以提供页面可能被缓存的信息,persisted 属性如果为 false,则可以确信相应网页不会进入 bfcache。不过,将 persisted 设为 true 并不能保证网页会被缓存。这意味着浏览器打算缓存网页,但也可能有其他因素导致无法缓存。

Javascript

window.addEventListener('pagehide', (event) => {
  if (event.persisted) {
    console.log('页面可能会进入bfcache');
  } else {
    console.log('页面正常退出');
  }
});

页面生命周期状态和事件概览

pagelifecycle.png

无法触发bfcache的情况

尽管bfcache带来了一系列优势,但它也有自己的局限性,并非所有的后退/前进操作都能触发bfcache。

若页面存在以下情况,bfcache将不会被触发:

  1. 监听unload或beforeunload事件:这是最常见的阻止bfcache的条件。建议有条件地监听beforeunload事件,并在事件触发后立即移除监听器。
  2. 包含Cache-Control: no-cache头的资源:适用于页面含有关键数据的情形,例如银行账户余额。
  3. 打开WebSocket连接:任何未关闭的WebSocket连接将阻止页面进入bfcache。
  4. 未完成的IndexedDB事务:如果页面有正在进行的IndexedDB操作,bfcache将不会启动。
  5. 未完成的请求:页面在用户前进后退的时候还没有完全加载完或者它有正在进行的网络请求,比如 Fetch 或 XMLHttpRequest。
  6. 非空window.opener:当页面的window带有opener属性时,可能会破坏访问来源或者任何尝试访问它的页面,不能够安全进入bfcache。
  7. 包含不符合条件的frame:顶层的主页面包含嵌入式iframe,这些iframe自身是不存在bfcache的。如果这些iframe不满足上述的任何条件,也会导致主页面无法进入bfcache。

针对 bfcache 优化网页

在开发时,应当遵循以下推荐做法:

切勿使用 unload 事件:

页面生命周期事件:如果使用unload事件监听器,应改为监听pagehide事件。unload事件的触发条件与bfcache相冲突,因为它基于页面确定将终止的假设。pagehidepageshow事件则在页面缓存和终止之间提供了更灵活的处理方式。

不同浏览器的缓存策略:虽然所有浏览器都提供了检查页面是否被缓存的API(通过pagehidepageshow事件中的persisted参数),但它们的缓存策略并不相同。例如,Safari即使页面监听了unload事件仍会缓存页面,而Chromium浏览器和Firefox则不会。

unload事件的不确定性:在某些浏览器中,unload事件的触发率低于50%,这意味着页面进程可能在unload事件触发前就被杀掉。在Safari中,pagehide事件的触发率是unload事件的两倍,因为苹果决定缓存监听了unload事件的页面,但不保证该事件会被触发。

在移动设备上,应监听visibilitychange事件来检测页面卸载,因为关闭浏览器应用时页面进程可能过早停止,导致pagehide事件无法被触发。

尽可能减少使用 Cache-Control: no-store

尽管bfcache(Back-Forward Cache)不是传统意义上的HTTP缓存,而是一种浏览器特有的缓存机制,用于存储和快速恢复用户前进或后退时的页面状态,但它也受Cache-Control: no-store的影响。在过往的实现中,如果页面的响应头部设置了Cache-Control: no-store,浏览器就会将其排除在bfcache之外,不对其进行缓存。

【伏笔1揭秘】而对于请求资源返回头的设置,也就导致了文章开头提到的京东m站及京东超市页,前者能命中 bfcache,而后者无法命中的原因。大家可以从浏览器检查器的 Network 中看到,m站的html资源请求头是Cache-Control: max-age=0,而京东超市使用的是Cache-Control: no-store

考虑到Cache-Control: no-store对bfcache资格的限制,建议只在那些确实包含敏感信息、不宜任何形式缓存的网页上使用这一标头。对于那些需要实时更新内容、但内容本身不包含敏感信息的网页,可以采用Cache-Control: no-cacheCache-Control: max-age=0。这两种控制指令告诉浏览器在提供内容前必须先进行重新验证,同时不会影响页面被bfcache缓存的资格。

避免 window.opener 引用

在旧版浏览器中,如果页面是使用 window.open()通过包含 target=_blank的链接打开的,而不指定 rel="noopener",则打开的页面会引用所打开页面的窗口对象,即window.opener。如果 window.opener 被设置(不为 null),那么该页面可以无视安全源(security origin)限制,在其 opener(即打开它的原始窗口)中触发导航操作。

因此,包含非 null 的 window.opener 会导致页面无法安全放入 bfcache。

【伏笔2揭秘】这也就是文章开头提到的,为什么从文章点击跳转后的页面,无法命中bfcache的原因。由于从文章点击跳转默认使用的应该是window.open(),于是新页面存在window.opener,从而无法进入 bfcache。

为了防止页面滥用 window.opener,可以使用 rel="noopener" 属性。

<a href="http://xxx.com" target="_blank" rel="noopener">打开新链接</a>

当然,对于目前所有现代浏览器中,这也是 a 标签的默认设置。也就是通过 a 标签跳转的链接会更优于 window.open() 方式的跳转。

对于更古早的浏览器,可以使用 rel="noreferrer",这同时也会禁用 HTTP 头中的 Referer 信息;或者,可以采用以下 JavaScript 方法作为变通方案,但这种方法有可能会弹出窗口拦截器:

// 使用 JavaScript 打开新窗口并设置 opener 为 null 
var otherWindow = window.open();
otherWindow.opener = null; // 明确设置 opener 为空,增强安全性
otherWindow.location = url; // 将新窗口定位到目标 URL

在用户离开之前关闭打开的连接

在以下情况下,某些浏览器不会尝试将网页放入 bfcache:

如果网页使用了其中任何 API,最好在 pagehide 或 freeze 事件期间关闭连接,并移除或断开连接监听器。这样浏览器就可以安全地缓存网页,而不会影响其他打开的标签页。

接下来,如果该网页从 bfcache 恢复,可以在 pageshow 或 resume 事件期间重新打开或重新连接到这些 API。

在 bfcache 恢复后更新过时或敏感数据

当页面从bfcache恢复时,它将保持之前的状态。但并非所有场景默认进行的 bfcache 行为都符合预期,比如文章开头提到的更新状态的场景,也比如如果用户导航到结账页,然后更新了购物车,那么当从 bfcache 恢复过时页面时,返回的页面信息可能是过时的。

为避免这种情况,如果 event.persisted 为 true,最好始终在 pageshow 事件后更新网页,优雅地处理bfcache:

window.addEventListener('pageshow', (event) =>if (event.persisted) {    
		// 更新页面数据
	    fetch('/api/data').then(response => {
	      // 更新数据
	    });
	    // 或直接刷新页面
	    location.reload();  
	}
});

测试网页是否可以缓存

若要测试网页,请按以下步骤操作:

  1. 在 Chrome 中前往相应网页。
  2. 在开发者工具中,前往 Application -> Back-forward Cache
  3. 点击 Run Test 按钮。然后,开发者工具会尝试离开和返回页面,以确定是否可以从 bfcache 恢复该页面。

bfcache调试.png

结语

bfcache,虽然在不同浏览器中有着各异的表现,但无疑为我们提供了更流畅、更高效的浏览体验。快看看你的项目能否应用上这个特性吧~

参考文章

对 bfcache 特性最全的讲解: web.dev/articles/bf…

bfcache广告提效: web.dev/case-studie…

被忽略的缓存: cloud.tencent.com/developer/a…

webkit源码及大佬分析: github.com/LeuisKen/le…

github.com/WebKit/WebK…