從 0 到 1 上手 bfcache 往返緩存

bfcache(back/forword cache),可稱爲 “往返緩存”,是一種可以實現即時前進、後退導航的瀏覽器(優化)特性。它能夠極大提升用戶體驗,尤其是針對網絡環境或設備速度較慢的用戶。

作爲開發者,瞭解如何在各個瀏覽器中優化頁面的 bfcache,對於提升網站的體驗非常有幫助。

瀏覽器兼容性

Firefox 和 Safari 瀏覽器在桌面和移動設備上均已支持 bfcache 多年。

Chrome 86 針對少數安卓用戶開啓了跨站導航的 bfcache 功能。在 Chrome 87 中,跨站導航的 bfcache 支持將對所有安卓用戶開放,同站導航的 bfcache 功能也將盡快支持。

bfcache 基礎知識

bfcache 是一種內存型緩存,在用戶導航離開頁面時,將當前頁面的完整快照(包括 javascript 堆)存儲下來。由於整個頁面都是存儲在內存之中,一旦用戶返回,瀏覽器便可以快速恢復頁面。

你一定遇到過這種情況,當訪問一個網站時,點擊鏈接跳轉到另一個頁面,卻發現這個頁面並不是你想要的,於是點擊了瀏覽器的 “後退” 按鈕。在這種情況下,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:

如果你的頁面正在使用這些 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 來估算它們的值。

有關 bfcache 如何影響每個指標的更多詳細信息,請參閱各個 Core Web Vitals 指南頁面。有關如何在代碼中實現這些指標 bfcache 版本的特定示例,請參閱 PR - adding them to the web-vitals JS library[24]。

從 v1 開始,web-vitals[25]JavaScript 庫在其報告的指標中支持 bfcache 恢復 [26]。使用 v1 或更高版本的開發人員無需更新代碼。

其他資料

參考資料

[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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/eAxooCtufmua4nDnBCpshQ