浏览器原生的零延迟打开优化——往返缓存
往返缓存(简称bfcache)是一种浏览器自带的优化技术,用于在用户点击后退和前进按钮时实现无延迟打开。它显著改善了用户的浏览体验——尤其是那些网络或设备较慢的用户。
作为Web开发人员,了解如何跨所有浏览器优化您的页面开启 bfcache 至关重要,以便您的用户享受到这个特征带来的好处。
浏览器兼容性
Firefox(2005年)和 Safari(2009年)在很多年前就已经支持 bfcache 了,并且包括桌面和移动端。
从 86 版开始,Chrome 为一小部分安卓用户启用了跨站点浏览时的 bfcache 。在随后的版本中,覆盖的比例逐步增加。从96版(2021年11月16日)开始,bfcache 已默认在所有桌面和移动端设备中启用。
bfcache 简介
bfcache 是一个内存缓存,在用户离开时存储页面的完整快照(包括 JavaScript 堆)。如果用户决定返回,浏览器可以轻松地快速恢复整个页面。
有多少次你访问了一个网站,点击了一个链接去了另一个页面,却发现它不是你想要的,然后点击了后退按钮?在那一刻,bfcache 可以改变前一个页面的加载速度:
不开启 bfcache | 发起一个新的请求去加载上一个页面,页面的加载速度取决于该网站是否为再次访问进行缓存优化 ,浏览器可能需要重新下载,重新渲染,重新运行部分或全部此前已下载的资源。 |
---|---|
开启 bfcache | 加载上一个页面几乎是实时的,因为整个页面是直接从内存中恢复的,根本不需要网络请求。 |
看看下面的这个 bfcache 实验视频,了解它带来的速度提升:
在上面的视频中,使用 bfcache 的示例比没有它的示例快得多。
bfcache 不仅加快了浏览速度,还减少了流量消耗,因为资源不必再次下载。
Chrome 使用数据报告显示,十分之一的桌面流量和五分之一的移动端流量是向后或向前的。启用 bfcache 后,浏览器每天可以消除数十亿网页的数据传输流量和加载时间!
这种“缓存”的原理是什么
bfcache 使用的“缓存”不同于 HTTP缓存(它在加速重复访问方面也很有用)。bfcache 是在内存中保存了整个页面的快照(包括 JavaScript 堆),而 HTTP 缓存仅包含先前请求的响应。由于加载页面所需的所有请求都可以从 HTTP 缓存中得到的情况非常罕见,因此使用 bfcache 恢复的重复请求总是比优化得最好的非 bfcache 请求更快。
然而,在内存中创建页面的快照带来了新的复杂度:怎样才能让正在运行中的代码得到最好的保护。例如,当页面中存在setTimeout()时,如何处理放入快照之后到期的计时器?
答案是浏览器暂停运行任何挂起的计时器或 unresolved 的Promise——基本上是 JavaScript任务队列中所有挂起的任务 —— 并在页面从 bfcache 恢复时继续处理任务队列。
在大多数情况下,这么做都没什么问题(例如,setTimeout或Promise),但在少数情况下,它可能会导致非常混乱或意外的行为。例如,如果浏览器暂停作为IndexedDB事务的一部分所需的任务,它可能会影响其他已打开的同源 Tab 页(因为多个 Tab 页可以同时访问同一个IndexedDB数据库)。因此,如果页面正在处理IndexedDB事务或正在调用可能影响其他页面的API,浏览器通常不会尝试缓存。
关于具体哪些API会影响页面bfcache机制的触发,请参阅下面的针对bfcache做优化部分。
监听 bfcache 的API
虽然 bfcache 是浏览器自动进行的优化,但开发人员知道它何时发生仍然很重要,这样他们就可以为此优化他们的页面并相应地调整相关指标或性能测量。
用于观察 bfcache 的主要事件是页面转换事件——pageshow和pagehide——伴随着 bfcache 特性一起诞生,并且几乎所有现代浏览器中都已支持。
较新的页面生命周期事件——freeze和resume——也会在页面进出 bfcache 时以及在其他一些情况下触发。例如,当后台选项卡被冻结以降低CPU使用率时。需要注意的是,页面生命周期事件目前只有基于 Chromium 的浏览器支持。
监听页面从 bfcache 中恢复
pageshow事件,在页面初次加载时紧跟 load事件触发,从 bfcache 恢复时也会触发。pageshow事件有一个 persisted属性,用来标识是否从 bfcache 中恢复(如果不是从 bfcache 恢复的,则该属性为false)。您可以使用persisted属性来区分常规页面加载和 bfcache 恢复。
例如:
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
console.log('从 bfcache 中恢复');
} else {
console.log('常规加载。');
}
});
在支持页面生命周期API的浏览器中,resume 事件也会在页面从 bfcache 中恢复时触发(紧挨着pageshow事件之前),尽管当用户重新访问冻结的背景选项卡时也会触发。如果您想为从冻结中唤醒的页面恢复最新状态(包括 bfcache 中的页面),您可以使用resume事件,但如果您想测量站点的bfcache 命中率,您需要使用 pageshow 事件。在某些情况下,您可能需要两个一起用。
下面对性能和分析的影响部分会详细提到相关的最佳实践。
监听页面何时进入bfcache
pagehide 事件与pageshow事件对应。当页面正常加载或从bfcache恢复时,pageshow事件触发。当页面正常卸载或浏览器尝试将其放入 bfcache 时,pagehide事件触发。
pagehide 事件也有一个persisted属性,如果它是false,那么您可以确信页面不会进入bfcache 。但是,如果persisted属性为true,它不能保证页面会被缓存。这意味着浏览器打算缓存该页面,但可能有一些因素使其无法缓存。
window.addEventListener('pagehide', (event) => {
if (event.persisted) {
console.log('页面 *可能会* 进入 bfcache。');
} else {
console.log('页面正常退出');
}
});
类似地,freeze 事件将在 pagehide 事件之后立即触发(如果事件的persisted属性为true),但这同样意味着浏览器打算缓存页面。出于下面解释的多种原因,它可能仍然必须丢弃它。
针对 bfcache 做优化
并非所有页面都能从 bfcache 中恢复,即使页面确实存储在那里,它也不会无限期地停留在那里。开发人员了解是什么使页面符合(和不符合)bfcache 策略以最大化他们的缓存命中率至关重要。
以下部分概述了最佳实践,以使浏览器尽可能缓存您的页面。
永远不要使用 unload 事件
优化 bfcache 最重要的一条是永远不要使用unload事件。永远!
对于浏览器来说,来自远古时代的unload 事件是有争议的,因为它远早于 bfcache ,并且依赖这一事件的很多页面假定unload 事件触发后页面将被彻底销毁。这提出了一个挑战,因为这些页面也假设unload 事件会在用户关闭页面时触发,这不再可靠(而且已经有很长一段时间都不可靠了)。
所以浏览器面临着一个两难的选择,他们必须在改善用户体验和可能破坏页面功能之间做出选择。
Chrome 和 Firefox 的选择是监听了unload事件的页面不符合 bfcache 条件,这风险较小,但也会排除掉 *很多 *页面。Safari 会尝试缓存监听了 unload事件的页面,但为了减少可能的破坏,它不会在用户离开时触发unload事件,这使得该事件变得非常不可靠。
不要使用unload事件,而是使用pagehide事件。所有触发unload事件的情况都会触发pagehide事件,更重要的是,当页面被放入 bfcache 时*也会 *触发。
事实上,Lighthouse v6.2.0 添加了一个no-unload-listeners审计,如果页面上的任何JavaScript(包括来自第三方库的)添加了unload事件,它将警告开发人员。
只在必要时使用beforeunload事件
在 Chrome 和 Safari 中,beforeunload事件不会使您的页面不符合 bfcache 的条件,但会使它们在Firefox 中不符合条件,因此除非绝对必要,否则请避免使用它。
然而,与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的浏览器中)从一个带有target=_blank并且没有指定rel="noopener" 的链接打开的,那么新打开的页面将持续保留一个对打开页面的窗口对象的引用。
除了是一个安全风险,具有非空window.opener引用的页面不能安全地放入 bfcache ,因为这可能会破坏任何尝试访问它的页面。
因此,最好尽可能使用 rel="noopener" 属性来避免创建 window.opener 引用。如果您的站点需要新开一个页面并通过 window.postMessage()或直接引用 window 对象来控制它,那么新打开的页面和对应的 opener 都不会触发 bfcache 。
总是在用户退出之前关闭打开的连接
如上所述,当页面放入 bfcache 时,所有队列中的 JavaScript 任务都会暂停,然后在页面从缓存中取出时恢复。
如果这些队列的 JavaScript 任务仅访问 DOM API-或仅影响到当前页面的其他 API -那么在页面对用户不可见时暂停这些任务不会导致任何问题。
但是,如果这些任务连接到也可从相同来源的其他页面(例如:IndexedDB、Web Locks、WebSockets等)访问的API,这可能会出现问题,因为暂停这些任务可能会阻止其他选项卡中的代码运行。
因此,在以下情况下,某些浏览器不会尝试将页面放入 bfcache 中:
-
存在活跃的 IndexedDB 连接的页面
-
正在执行 fetch() 或 XMLHttpRequest 的页面
如果您的页面正在使用这些API中的任何一个,最好通过pagehide或freeze事件关闭连接并删除或断开观察者。这将允许浏览器安全地缓存页面,而不会影响其他打开的选项卡。
然后,如果从 bfcache 恢复了页面,再重新打开或重新连接到这些API(借助pageshow或resume事件)。
以下示例显示了如何借助 pagehide 事件关闭页面中打开着的IndexedDB连接来确保您的页面在使用时符合 bfcache 条件:
let dbPromise;
function openDB() {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open('my-db', 1);
req.onupgradeneeded = () => req.result.createObjectStore('keyval');
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
});
}
return dbPromise;
}
// Close the connection to the database when the user is leaving.
window.addEventListener('pagehide', () => {
if (dbPromise) {
dbPromise.then(db => db.close());
dbPromise = null;
}
});
// Open the connection when the page is loaded or restored from bfcache.
window.addEventListener('pageshow', () => openDB());
从 bfcache 恢复后更新过时或敏感数据
如果您的站点保持用户状态——尤其是任何敏感的用户信息——则需要在从 bfcache 恢复页面后更新或清除该数据。
例如,如果用户打开了结账页面,然后更新他们的购物车,如果从 bfcache 恢复了陈旧的页面,后退时可能会显示过时的信息。
另一个更关键的例子是,如果用户在公共计算机上退出站点,而下一个用户单击后退按钮。这可能会暴露用户在注销时认为已被清除的私人数据。
为了避免这种情况,最好始终在 pageshow 事件的event.persisted 属性为true 时更新页面。
以下代码检查 pageshow 事件中是否存在特定于站点的cookie,如果找不到cookie,则重新加载:
window.addEventListener('pageshow', (event) => {
if (event.persisted && !document.cookie.match(/my-cookie/)) {
// Force a reload if the user has logged out.
location.reload();
}
});
测试以确保您的页面可缓存
ChromeDevTools 可以帮助您测试页面,以确保它们针对 bfcache 进行了优化,并识别可能阻止它们符合条件的任何问题。
要测试特定页面,请在 Chrome 中打开它,然后在开发工具中转到应用程序>往返缓存。接下来单击运行测试按钮,开发工具将尝试离开和返回以确认是否可以从 bfcache 恢复页面。
如果成功,面板将报告“从往返缓存中成功恢复了”:
如果不成功,面板将指示页面未恢复并列出原因。如果原因是您作为开发人员可以解决的问题,也将指示:
在上面的屏幕截图中,使用 unload 事件侦听器会阻止页面符合 bfcache 的条件。您可以通过从unload 切换到使用 pagehide 来解决这个问题:
不要用:
window.addEventListener('unload', ...);
这样做:
window.addEventListener('pagehide', ...);
bfcache 对性能度量和流量分析的影响
如果您使用分析工具跟踪对您网站的访问,您可能会注意到报告的总 PV 有所减少,因为 Chrome 一直在持续为更多用户启用 bfcache 。
事实上,您可能 *已经漏报 *了其他已支持 bfcache 浏览器的页面浏览量,因为大多数流行的分析库不会将从 bfcache 恢复的页面视为一次新的PV。
如果您不希望由于 Chrome 启用 bfcache 而导致页面浏览量减少,您可以通过监听页面 pageshow 事件并在 persisted 属性为 true 时上报一次页面PV(推荐)。
以下示例展示了如何使用Google Analytics 执行此操作;其他分析工具的逻辑应该类似:
// Send a pageview when the page is first loaded.
gtag('event', 'page_view');
window.addEventListener('pageshow', (event) => {
// Send another pageview if the page is restored from bfcache.
if (event.persisted) {
gtag('event', 'page_view');
}
});
测量你的 bfcache 命中率
您可能还希望跟踪是否使用了 bfcache ,以帮助识别未使用 bfcache 的页面。例如:
window.addEventListener('pageshow', (event) => {
// You can measure bfcache hit rate by tracking all bfcache restores and
// other back/forward navigations via a seperate event.
const navigationType = performance.getEntriesByType('navigation')[0].type;
if (event.persisted || navigationType == 'back_forward' ) {
gtag('event', 'back_forward_navigation', {
'isBFCache': event.persisted,
});
}
});
重要的是要认识到,在网站所有者控制之外的许多情况下,后退/前进按钮不会触发 bfcache,包括:
-
当用户退出浏览器并重新启动时
-
当用户复制一个标签时
-
当用户关闭一个选项卡并将其重新恢复时
因此,网站所有者不应该期望所有back_forward类型的访问都有100%的 bfcache 命中率。然而,对于经常后退和前进的页面来说,测量它们的比率对于识别哪些页面由于本身原因阻止了 bfcache 的触发很有用。
Chrome 团队正在开发一个NotRestoredReason API 来帮助揭示 bfcache 未被启用的原因,以帮助开发人员理解缓存未被使用的原因,以及他们是否可以着手改进他们的网站。
性能度量
bfcache 也会对收集在实际情况中的性能指标产生负面影响,特别是测量页面加载时间的指标。
由于 bfcache 会恢复现有页面而不是启动新的页面加载,因此启用 bfcache 时收集的页面加载总数会减少。然而,关键是被 bfcache 恢复替换的页面加载可能是数据集中最快的页面加载。这是因为根据定义,向后和向前请求是重复访问,重复页面加载通常比第一次访问者的页面加载更快(由于HTTP缓存,如前所述)。
结果是数据集中加载快的页面变少了,从数据角度来看好像更慢了——尽管用户体验到的性能可能已经提高了!
有几种方法可以解决这个问题。一种是用它们各自的加载类型注释所有页面负载指标:navigate、 reload、 back_forward或 prerender。这将允许您持续监控这些加载类型的性能——哪怕整体分布为负倾斜。这种方法推荐用于非以用户为中心的页面负载指标,如首字节时间(TTFB)。
对于以用户为中心的指标,如 Core Web Vitals,更好的选择是报告一个更准确的代表用户体验的值。
对核心性能指标(Core Web Vitals)的影响
Core Web Vitals 通过各种维度(加载速度、交互性、视觉稳定性)衡量用户对网页的体验,由于用户体验 bfcache 恢复比传统页面加载更快,因此在 Core Web Vitals 指标上反映这一点非常重要。毕竟,用户不在乎是否启用了 bfcache,他们只关心快不快!
收集和报告核心Web指标的工具(例如:Chrome用户体验报告)将bfcache恢复视为数据集中单独的页面访问。
虽然到目前为止还没有专门的Web性能API来测量 bfcache 恢复相关指标,但可以使用现有的Web API 来获取近似值。
-
对于最大内容绘制(LCP),您可以使用 pageshow 事件的时间戳和下一个绘制帧的时间戳之间的增量(因为帧中的所有元素都将同时绘制)。请注意,在bfcache还原的情况下,LCP和FCP将是相同的。
-
对于First Input Delay(FID),您可以在pageshow 事件中重新添加事件监听器(参考 FID polyfill),并将 bfcache 恢复后第一个输入延迟上报为FID。
-
对于累积布局偏移(CLS),您可以继续使用现有的性能观察器;您所要做的就是将当前CLS值重置为0。
有关bfcache如何影响每个指标的更多详细信息,请参阅各个CoreWeb Vitals 指标指南页面。有关如何在代码中实现这些指标的bfcache版本的具体示例,请参阅PR:将它们添加到web-vitals JS库。
从v1开始,web-vitals JavaScript库默认已经支持在bfcache恢复时上报相关指标。使用v1或更高版本的开发人员不需要更新他们的代码。
更多参考
-
Firefox缓存 (Firefox中的 bfcache )
-
页面缓存 (Safari中的 bfcache )
-
Back-forward cache: web-exposed behaviour ( bfcache 在浏览器之间的差异)
-
bfcache测试器 (测试不同的API和事件如何影响浏览器中的 bfcache )
-
性能游戏破局者:浏览器往返缓存 (一个来自 Smashing Magazine 的案例研究,通过启用 bfcache 给Web核心指标带来巨大提升)
英文原文:web.dev/bfcache/
转载自:https://juejin.cn/post/7153186637365641246