从 0 到 1 上手 bfcache 往返缓存
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]pageshow
和pagehide
是用于监听 bfcache 的基础事件,它是与 bfcahce 缓存同时存在,并且目前已被大多数浏览器[3]支持。
全新的网页生命周期[4]中的freeze
和resume
事件,在页面从 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 中的其中一个,最好总是在页面pagehide
或freeze
事件期间关闭连接并删除或断开观察者的连接。这样浏览器就可以安全地缓存页面,而不会影响其他打开的选项卡。
如果从 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]:navigate
,reload
,back_forward
或prerender
。这将使你能够继续在这些导航类型中监视性能 - 即使总体分布偏慢。建议将这种方法用于不以用户为中心的页面加载指标,例如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的影响)
参考资料
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 -
觉得本文对你有帮助?请分享给更多人
关注「大前端技术之路」加星标,提升前端技能
点赞和在看就是最大的支持❤️