查看原文
其他

从 0 到 1 上手 bfcache 往返缓存

fuxy 大前端技术之路 2022-06-29

bfcache(back/forword cache),可称为“往返缓存”,是一种可以实现即时前进、后退导航的浏览器(优化)特性。它能够极大提升用户体验,尤其是针对网络环境或设备速度较慢的用户。

作为开发者,了解如何在各个浏览器中优化页面的 bfcache,对于提升网站的体验非常有帮助。

浏览器兼容性

Firefox 和 Safari 浏览器在桌面和移动设备上均已支持 bfcache 多年。

Chrome 86 针对少数安卓用户开启了跨站导航的 bfcache 功能。在 Chrome 87 中,跨站导航的 bfcache 支持将对所有安卓用户开放,同站导航的 bfcache 功能也将尽快支持。

bfcache 基础知识

bfcache 是一种内存型缓存,在用户导航离开页面时,将当前页面的完整快照(包括 javascript 堆)存储下来。由于整个页面都是存储在内存之中,一旦用户返回,浏览器便可以快速恢复页面。

你一定遇到过这种情况,当访问一个网站时,点击链接跳转到另一个页面,却发现这个页面并不是你想要的,于是点击了浏览器的“后退”按钮。在这种情况下,bfcache 可以给回退页面的加载速度带来巨大的提升。

未启用 bfcache

加载之前页面的时候会发送新的请求,根据页面对重复请求的优化情况不同,浏览器可能会重新下载、解析、执行之前下载过的部分或全部资源。

启用 bfcache

由于整个页面都存储在内存之中,加载之前页面的过程会在瞬间完成,不会有任何网络请求。

以下视频展示了在实际使用中 bfcache 给导航速度带来的巨大提升。


上面视频中启用 bfcache 比不启用 bfcache 的案例要快很多。

bfcahe 不仅可以加速导航,还减少了数据使用量,因为资源无需重复下载。

Chrome 使用数据显示,桌面端 10% 的导航操作、移动端 20% 的导航操作属于前进和后退。随着 bfcache 功能开启,浏览器每天为数十亿网页减少了数据传输和加载时间。

缓存的工作方式

bfcache 的缓存机制与 HTTP 缓存不同(HTTP 缓存在加速重复导航中也很有用)。bfcache 会将完整页面的快照(包括 javascript 堆)放在内存中,而 HTTP 缓存的对象只包含发送的请求。由于加载一个网页的所有请求全部来自 HTTP 缓存的可能性极小,重复访问使用了 bfcache 技术的页面往往比使用非 bfache 技术极致优化过的页面时的导航速度要快得多。

然而,在内存中创建并存储网页快照时,如何保存执行中的代码会有一定复杂性。例如,当页面已经被 bfcache 缓存时,应该如何处理setTimeout()的回调。

答案是,浏览器会暂停所有执行中和等待中的定时器和未被 resolve 的 promise,以及所有 Javascript 任务队列[1]中的所有待执行任务,等到页面从 bfcache 中恢复时再继续执行中的任务。

在一些场景下这种情况风险较低,比如定时器和 promises,但其他场景中可能会出现令人迷惑的行为和非预期行为。例如,当浏览器暂停了一个含有IndexedDB 事务的任务,他可能会影响到同一来源的其他选项卡(同一个 IndexedDB 数据库可能被多个选项卡同时访问)。因此,在 IndexedDB 事务中间,或者使用可能影响其他页面的 API 时,浏览器通常不会缓存页面。

更多有关各个 API 如何影响页面的 bfcache 使用,请阅读后面“优化页面的 bfcache”的部分。

观察 bfache 的 API

尽管 bfcahce 缓存机制是由浏览器的自动处理,对于开发者来说,了解它的原理仍然非常重要,只有知道缓存在何时发生,才能针对它优化自己的网页以及调整衡量指标。

页面过渡事件[2]pageshowpagehide是用于监听 bfcache 的基础事件,它是与 bfcahce 缓存同时存在,并且目前已被大多数浏览器[3]支持。

全新的网页生命周期[4]中的freezeresume事件,在页面从 bfcache 中存储和取出时会被触发。该事件在其他情况下也会被触发, 例如,当背景选项卡被冻结来降低 CPU 功耗时。值得注意的是,这两个生命周期事件只在 Chromium 系列浏览器中得到支持。

观察网页从 bfcache 中恢复

页面最初加载时以及从 bfcache 恢复页面时,pageshow事件都会在会在load事件之后立即触发。如果页面是从 bfcache 中恢复,pageshow事件中的persisted属性的值会是true,反之为false。你可以根据persisted属性的值来判断页面是否是从 bfcache 中恢复。以下为示例代码:

window.addEventListener('pageshow'function(event) {
  if (event.persisted) {
    console.log('This page was restored from the bfcache.');
  } else {
    console.log('This page was loaded normally.');
  }
});

在支持页面生命周期 API 的浏览器中,resume事件会在页面从 bfcache 中恢复时(pageshow事件之前)被触发,尽管用户通过被冻结的背景标签页再次访问时事件也会被触发。如果你想要从冻结状态恢复到页面的激活状态(包括在 bfcache 中的页面),可以使用resume事件,但如果你想要统计网站加载的 bfcache 命中率,需要使用pageshow事件。有些情况下,可能二者都需要。

在“对性能和分析的影响”部分查看更多 bfcache 测量的最佳实践。

观察页面进入 bfcache

pagehide事件与pageshow事件相对。pageshow事件在页面正常加载时以及从 bfcache 中恢复时被触发。pagehide事件则在页面被卸载时已近浏览器将页面存入 bfcache 是被触发。

pagehide事件同样有persisted属性,当属性值为false时可以确定页面并不会进入 bfcache 缓存。而当persisted属性的值为true时,并不能保证页面一定对被缓存。这意味着浏览器试图将页面缓存,但可能会由于一些因素导致无法进行缓存。

window.addEventListener('pagehide'function(event) {
  if (event.persisted === true) {
   console.log('This page *might* be entering the bfcache.');
  } else {
    console.log('This page will unload normally and be discarded.');
  }
});

类似的,freeze事件会在pagehide事件之后立即触发(persisted属性为true时),但同样这只意味着浏览器试图缓存页面。他仍有可能因为一些原因(下文将介绍)而不进行缓存。

针对 bfcache 优化你的页面

并不是所有页面都会存储在 bfcache 中,甚至当页面已被存储在其中, 也并不是永久性的。开发者有必要了解 bfcache 存储页面的条件,从而提高缓存命中率。

以下的内容列出了让浏览器尽可能缓存网页的最佳实践。

避免使用unload事件

在任何浏览器中,优化 bfcache 最重要的方式就是:永远不要使用unload事件!

unload事件对于浏览器来说是有问题的,因为它早于 bfcache,并且互联网上许多页面都是在“触发unload事件后页面将不继续存在”的(合理)假设下运行的。这就带来了一个挑战,因为很多页面也是在假设“unload事件将在用户导航离开的时候触发”的情况下构建的,而这种情况已经(在很长一段时间[5])不再成立。

因此,浏览器面临着两难境地,他们得从中做出选择,它们能够改善用户体验,但也有破坏页面的风险。

Firefox 选择将添加了unload事件监听的网页认定为不符合 bfcache 条件,这样做风险较小,但也会使很多页面失去缓存资格。Safari 会尝试缓存带unload事件监听的网页,但为了减少破坏页面的风险,当用户离开时,它不会运行unload事件。

由于 Chome 中65% 的网页[6]都注册了unload事件监听,为了能够尽可能多的缓存网页,Chrome 选择与 Safari 的实施方案保持一致。

pagehide事件来代替unload事件。pagehide会在每次unload事件触发时被触发,并且在页面缓存到 bfcache 时也会触发。

事实上,Lighthouse v6.2.0[7]添加了no-unload-listenersaudit,它会在页面的 Javascript(包括第三方库)添加了unload事件监听时给开发者发出警告。

警告:切勿添加 unload 事件监听器!请改用pagehide事件。添加 unload 事件侦听器将使您的网站在Firefox中加载变慢,并且该代码甚至大部分时间都不会在Chrome和Safari中运行。

条件性添加beforeunload事件监听

beforeunload事件不会让你的页面在 Chrome 和 Safari 浏览器中不符合 bfcache 条件,但是会让页面在 Firefox 中不符合 bfcache 条件,所以请避免使用该事件,除非必须使用时。

然而,与unload事件不同,beforeunload事件的使用是完全合理的。例如,当你想提醒用户离开页面会丢失未保存的更改。因此,在用户有未保存的改动时,添加beforeunload事件监听并在保存以后立刻移除,这种做法是值得推荐的。

尽量避免:

window.addEventListener('beforeunload', (event) => {
  if (pageHasUnsavedChanges()) {
    event.preventDefault();
    return event.returnValue = 'Are you sure you want to exit?';
  }
});

以上代码无条件添加了beforeunload事件监听。推荐做法:

function beforeUnloadListener(event) {
  event.preventDefault();
  return event.returnValue = 'Are you sure you want to exit?';
};
// A function that invokes a callback when the page has unsaved changes.
onPageHasUnsavedChanges(() => {
  window.addEventListener('beforeunload', beforeUnloadListener);
});
// A function that invokes a callback when the page's unsaved changes are resolved.
onAllChangesSaved(() => {
  window.removeEventListener('beforeunload', beforeUnloadListener);
});

以上代码在需要时添加beforeunload事件监听,并在不需要时移除。

避免使用 window.opener 引用

在某些浏览器(包括基于 Chromium 的浏览器)中,如果使用window.open()或(基于88版本之前的 Chromium[8])从带有target = _blank的链接中打开了页面,而未指定rel =" noopener",则打开的页面将具有对打开页面窗口对象的引用。

除了存在安全风险[9]之外,不能将带有非 nullwindow.opener引用的页面安全地放入 bfcache 中,因为这可能会给试图访问它的页面带来破坏。

因此,最好避免使用rel="noopener"来创建window.opener引用。如果你的站点需要打开一个窗口并通过window.postMessage()或直接引用该窗口对象进行控制,则打开的窗口和opener 均不符合 bfcache 的条件。

用户离开之前,关闭建立的连接

如上所述,将页面放入bfcache后,所有 JavaScript 的计划任务都将暂停,然后在从缓存取出页面时恢复执行。

如果这些 JavaScript 的计划任务只使用了 DOM API 和当前页面独立的 API,在用户看不见页面时暂停这些任务并不会造成问题。

但是,如果这些任务使用了可以被同源的其他页面访问的 API(例如:IndexedDB,Web Locks,WebSockets 等)就可能会出现问题,因为暂停这些任务可能会阻止其他选项卡中的代码运行 。

因此,在以下情况下,大多数浏览器将不会尝试将页面放入bfcache:

  • 含有未完成IndexedDB 事务[10]的页面
  • 含有正在进行fetch()[11]XMLHttpRequest[12]的页面
  • 含有WebSocket[13]链接或WebRTC[14]连接的页面

如果你的页面正在使用这些 API 中的其中一个,最好总是在页面pagehidefreeze事件期间关闭连接并删除或断开观察者的连接。这样浏览器就可以安全地缓存页面,而不会影响其他打开的选项卡。

如果从 bfcache 还原了页面,你可以(在pageshow或者resume事件中) 重新打开或重新使用这些 API。

使用以上列出的 API 不会使页面失去存储在 bfcache 的资格,只要它们在用户离开之前没有在活动状态。但是,目前使用某些API(嵌入式插件,Workers,广播频道和一些其他API[15])确实会使页面无法缓存。尽管 Chrome 最初在 bfcache 的初始版本中有意保守一些,但长期目标是使 bfcache 兼容尽可能多的 API。

测试以确保你的页面可缓存

尽管无法确定页面在卸载时是否已放入缓存中,但是可以确定后退和前进导航时从缓存中恢复了页面。

目前在 Chrome 中,一个页面最多可以在 bfcache 中保留三分钟,这让我们有足够的时间(使用Puppeteer[16]WebDriver[17]之类的工具)来运行测试以确保导航离开页面再点击“后退”按钮之后pageshow事件的persisted属性为true

值得注意的是,正常情况下,页面应在缓存中保留足够长的时间以运行测试,但可以随时将其静默释放(例如当系统内存不足时)。测试失败并不一定意味着你的页面不可缓存,因此你需要配置测试或相应地建立失败标准。

在 Chrome 中,bfcache 当前仅在移动设备上启用。要在桌面上测试 bfcache,你需要启用 #back-forward-cache[18]

关闭 bfcache 的方法

如果你不希望将页面存储在 bfcache 中,可以通过将顶级页面响应中的Cache-Control标头设置为no-store来确保不缓存该页面:

Cache-Control: no-store

所有其他缓存指令(包括子帧上的no-cache甚至no-store)都不会影响页面使用 bfcache 的资格。

尽管此方法有效且可在浏览器中使用,但它还是具有其他缓存和性能隐患。为了解决这个问题,有人建议添加一个更明确的关闭机制[19],包括在需要时清除 bfcache 的机制(例如,当用户注销共享设备上的网站时)。

另外,在 Chrome 中,目前可以通过#back-forward-cache以及基于企业策略的关闭[20]来进行用户级的关闭。

注意:鉴于 bfcache 提供了明显更好的用户体验,不建议你关闭,除非出于隐私原因绝对必要,比如当用户从共享设备上注销了网站。

bfcache 如何影响分析和性能衡量

如果使用分析工具跟踪对你网站的访问,你可能会发现报告的浏览量总数有所下降,因为 Chrome 持续为更多用户启用了 bfcache。

实际上,你可能已经低估了其他实现了 bfcache 的浏览器中的浏览量,因为大多数流行的分析库都不会将 bfcache 还原作为新的浏览量进行跟踪。

如果你不希望由于 Chrome 启用 bfcache 而导致浏览量下降,则可以通过监听pageshow事件并检查persisted属性来将 bfcache 恢复报告为浏览量(推荐)。

以下示例显示了如何使用 Google Analytics 进行此操作,其他分析工具的逻辑应类似:

// Send a pageview when the page is first loaded.
gtag('event''page_view')
window.addEventListener('pageshow'function(event) {
  if (event.persisted === true) {
    // Send another pageview if the page is restored from bfcache.
    gtag('event''page_view')
  }
});

性能测试

bfcache 也可能对真实场景中收集到的性能指标产生负面影响,特别是衡量页面加载时间的指标。

由于 bfcache 导航会还原现有页面而不是启动新页面加载,因此启用 bfcache 时,收集的页面加载总数将减少。尽管如此,(将页面重新加载改为)从 bfcache 加载的时间可能是数据集中最快的。因为根据定义,来回导航属于重复访问,并且(由于HTTP 缓存)重复页面的加载通常比第一次访问者的页面加载要快。

带来的结果是你的数据集中的快速加载完成的页面变少了,整体页面加载速度变慢了,尽管用户体验的性能可能得到了改善。

有几种方法可以解决此问题。一种是在所有页面加载指标中标记各自的导航类型[21]navigatereloadback_forwardprerender。这将使你能够继续在这些导航类型中监视性能 - 即使总体分布偏慢。建议将这种方法用于不以用户为中心的页面加载指标,例如TTFB(Time to First Byte)。

对于像 Core Web Vitals 指标中以用户为中心的指标,更好的选择是报告一个更准确地表示真正用户体验的值。

注意:不要将 Navigation Timing API 中的 back_forward 导航类型与 bfcache 还原混淆。Navigation Timing API 仅能标记页面的加载,而从 bfcache 还原是属于之前导航页面的重复使用。

对 Core Web Vitals 指标的影响

Core Web Vitals指标用于衡量用户多维度的网页体验(加载速度,交互性,视觉稳定性),而且由于用户体验 bfcache 还原的速度比传统页面加载更快, Core Web Vitals 指标能反映这一点非常重要 。毕竟,用户不在乎是否启用了 bfcache,他们只是在乎导航的速度!

诸如Chrome 用户体验报告[22]之类的工具,它可以收集和报告 Core Web Vitals 指标,将很快进行更新,届时会将 bfcache 还原视为单独的页面访问统计到数据集中。

尽管 bfcache 恢复后还没有专用的 Web 性能 API 来衡量这些指标,但是可以使用现有的 Web API 来估算它们的值。

  • 对于LCP(Largest Contentful Paint),由于页面结构中的所有元素都将同时绘制,你可以使用pageshow事件的时间戳与下一个绘制的框架的时间戳的差值。请注意,bfcache 还原时,LCP 和 FCP 的值是相同的。
  • 对于FID(First Input Delay),你可以在pageshow事件中重新添加(与FID polyfill[23]所使用的监听相同的)事件监听,并将 bfcache 恢复后第一次交互的延迟时间上报为 FID。
  • 对于CLS(Cumulative Layout Shift),你可以继续使用现有的 Performance Observer;你要做的就是将当前的 CLS 值重置为 0。

有关 bfcache 如何影响每个指标的更多详细信息,请参阅各个 Core Web Vitals指南页面。有关如何在代码中实现这些指标 bfcache 版本的特定示例,请参阅PR - adding them to the web-vitals JS library[24]

从 v1 开始,web-vitals[25]JavaScript 库在其报告的指标中支持 bfcache 恢复[26]。使用 v1 或更高版本的开发人员无需更新代码。

其他资料

  • Firefox Caching[27](Firefox 浏览器的 bfcache)
  • Page Cache[28](Safari 浏览器的 bfcache)
  • Back/forward cache: web exposed behavior[29](不同浏览器中 bfcache 的差异)
  • bfcache tester[30](测试不同的API和事件对浏览器bfcache的影响)

参考资料

[1]

javascript 任务队列: https://html.spec.whatwg.org/multipage/webappapis.html#task-queue

[2]

页面过渡事件: https://developer.mozilla.org/en-US/docs/Web/API/PageTransitionEvent

[3]

大多数浏览器: https://caniuse.com/page-transition-events

[4]

网页生命周期: https://developers.google.com/web/updates/2018/07/page-lifecycle-api

[5]

在很长一段时间: https://developers.google.com/web/updates/2018/07/page-lifecycle-api#the-unload-event

[6]

65% 的网页: https://www.chromestatus.com/metrics/feature/popularity#DocumentUnloadRegistered

[7]

Lighthouse v6.2.0: https://github.com/GoogleChrome/lighthouse/releases/tag/v6.2.0

[8]

基于88版本之前的 Chromium: https://crbug.com/898942

[9]

存在安全风险: https://mathiasbynens.github.io/rel-noopener/

[10]

IndexedDB 事务: https://developer.mozilla.org/en-US/docs/Web/API/IDBTransaction

[11]

fetch(): https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API

[12]

XMLHttpRequest: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest

[13]

WebSocket: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket

[14]

WebRTC: https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API

[15]

一些其他API: https://source.chromium.org/chromium/chromium/src/+/master:content/browser/frame_host/back_forward_cache_impl.cc;l=124;drc=e790fb2272990696f1d16a465832692f25506925?originalUrl=https:%2F%2Fcs.chromium.org%2F

[16]

Puppeteer: https://github.com/puppeteer/puppeteer

[17]

WebDriver: https://www.w3.org/TR/webdriver/

[18]

启用 #back-forward-cache: https://www.chromium.org/developers/how-tos/run-chromium-with-flags

[19]

添加一个更明确的关闭机制: https://github.com/whatwg/html/issues/5744

[20]

基于企业策略的关闭: https://cloud.google.com/docs/chrome-enterprise/policies

[21]

导航类型: https://www.w3.org/TR/navigation-timing-2/#sec-performance-navigation-types

[22]

Chrome 用户体验报告: https://developers.google.com/web/tools/chrome-user-experience-report

[23]

FID polyfill: https://github.com/GoogleChromeLabs/first-input-delay

[24]

PR - adding them to the web-vitals JS library: https://github.com/GoogleChrome/web-vitals/pull/87

[25]

web-vitals: https://github.com/GoogleChrome/web-vitals

[26]

支持 bfcache 恢复: https://github.com/GoogleChrome/web-vitals/pull/87

[27]

Firefox Caching: https://developer.mozilla.org/en-US/Firefox/Releases/1.5/Using_Firefox_1.5_caching

[28]

Page Cache: https://webkit.org/blog/427/webkit-page-cache-i-the-basics/

[29]

Back/forward cache: web exposed behavior: https://docs.google.com/document/d/1JtDCN9A_1UBlDuwkjn1HWxdhQ1H2un9K4kyPLgBqJUc/edit?usp=sharing

[30]

bfcache tester: https://back-forward-cache-tester.glitch.me/?persistent_logs=1


- EOF -

推荐阅读  点击标题可跳转

1、Streams 权威指南

2、TypeScript 4.2 有哪些新特性?

3、图文并茂讲清楚 JavaScript 内存管理


觉得本文对你有帮助?请分享给更多人

关注「大前端技术之路」加星标,提升前端技能

点赞和在看就是最大的支持❤️

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存