從 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]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 的影響)
參考資料
[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