一文讀懂前端緩存







當把服務器響應設置為 Cache-Control: no-cache 時,我們發現打開頁面之後,三種資源都只被請求 1 次。

一文讀懂前端緩存

一文讀懂前端緩存


這說明兩個問題:

  • 同步請求方面,瀏覽器會自動把當次 HTML 中的資源存入到緩存 (memory cache),這樣碰到相同 src 的圖片就會自動讀取緩存(但不會在 Network 中顯示出來)
  • 異步請求方面,瀏覽器同樣是不發請求而直接讀取緩存返回。但同樣不會在 Network 中顯示。


總體來說,如上面原理所述,no-cache 從語義上表示下次請求不要直接使用緩存而需要比對,並不對本次請求進行限制。因此瀏覽器在處理當前頁面時,可以放心使用緩存。

當把服務器響應設置為 Cache-Control: no-store 時,情況發生了變化,三種資源都被請求了 2 次。而圖片因為還多一次異步請求,總計 3 次。(紅框中的都是那一次異步請求)

一文讀懂前端緩存

一文讀懂前端緩存


這同樣說明:

  • 如之前原理所述,雖然 memory cache 是無視 HTTP 頭信息的,但是 no-store 是特別的。在這個設置下,memory cache 也不得不每次都請求資源。
  • 異步請求和同步遵循相同的規則,在 no-store 情況下,依然是每次都發送請求,不進行任何緩存。


3、Service Worker & memory (disk) cache

我們嘗試把 Service Worker 也加入進去。我們編寫一個 serviceWorker.js,並編寫如下內容:(主要是預緩存 3 個資源,並在實際請求時匹配緩存並返回)

// serviceWorker.js
self.addEventListener('install', e => {
// 當確定要訪問某些資源時,提前請求並添加到緩存中。
// 這個模式叫做“預緩存”
e.waitUntil(
caches.open('service-worker-test-precache').then(cache => {
return cache.addAll(['/static/index.js', '/static/index.css', '/static/mashroom.jpg'])
})
)
})
self.addEventListener('fetch', e => {
// 緩存中能找到就返回,找不到就網絡請求,之後再寫入緩存並返回。
// 這個稱為 CacheFirst 的緩存策略。
return e.respondWith(
caches.open('service-worker-test-precache').then(cache => {
return cache.match(e.request).then(matchedResponse => {
return matchedResponse || fetch(e.request).then(fetchedResponse => {
cache.put(e.request, fetchedResponse.clone())
return fetchedResponse
})
})
})
)
})


註冊 SW 的代碼這裡就不贅述了。此外我們還給服務器設置 Cache-Control: max-age=86400 來開啟 disk cache。我們的目的是看看兩者的優先級。

當我們首次訪問時,會看到常規請求之外,瀏覽器(確切地說是 Service Worker)額外發出了 3 個請求。這來自預緩存的代碼。

一文讀懂前端緩存


第二次訪問(無論關閉 TAB 重新打開,還是直接按 F5 刷新)都能看到所有的請求標記為 from SerciceWorker。

一文讀懂前端緩存


from ServiceWorker 只表示請求通過了 Service Worker,至於到底是命中了緩存,還是繼續 fetch() 方法光看這一條記錄其實無從知曉。因此我們還得配合後續的

Network 記錄來看。因為之後沒有額外的請求了,因此判定是命中了緩存。

一文讀懂前端緩存


從服務器的日誌也能很明顯地看到,3 個資源都沒有被重新請求,即命中了 Service Worker 內部的緩存。

如果修改 serviceWorker.js 的 fetch 事件監聽代碼,改為如下:

// 這個也叫做 NetworkOnly 的緩存策略。
self.addEventListener('fetch', e => {
return e.respondWith(fetch(e.request))
})


可以發現在後續訪問時的效果和修改前是 完全一致的。(即 Network 僅有標記為 from ServiceWorker 的幾個請求,而服務器也不打印 3 個資源的訪問日誌)

很明顯 Service Worker 這層並沒有去讀取自己的緩存,而是直接使用 fetch() 進行請求。所以此時其實是 Cache-Control: max-age=86400 的設置起了作用,也就是 memory/disk cache。但具體是 memory 還是 disk 這個只有瀏覽器自己知道了,因為它並沒有顯式的告訴我們。(個人猜測是 memory,因為不論從耗時 0ms 還是從不關閉 TAB 來看,都更像是 memory cache)

瀏覽器的行為

所謂瀏覽器的行為,指的就是用戶在瀏覽器如何操作時,會觸發怎樣的緩存策略。主要有 3 種:

  • 打開網頁,地址欄輸入地址: 查找 disk cache 中是否有匹配。如有則使用;如沒有則發送網絡請求。
  • 普通刷新 (F5):因為 TAB 並沒有關閉,因此 memory cache 是可用的,會被優先使用(如果匹配的話)。其次才是 disk cache。
  • 強制刷新 (Ctrl + F5):瀏覽器不使用緩存,因此發送的請求頭部均帶有 Cache-control: no-cache(為了兼容,還帶了 Pragma: no-cache)。服務器直接返回 200 和最新內容。

緩存的應用模式

瞭解了緩存的原理,我們可能更加關心如何在實際項目中使用它們,才能更好的讓用戶縮短加載時間,節約流量等。這裡有幾個常用的模式,供大家參考

模式 1:不常變化的資源

Cache-Control: max-age=31536000


通常在處理這類資源資源時,給它們的 Cache-Control 配置一個很大的 max-age=31536000 (一年),這樣瀏覽器之後請求相同的 URL 會命中強制緩存。而為了解決更新的問題,就需要在文件名(或者路徑)中添加 hash, 版本號等動態字符,之後更改動態字符,達到更改引用 URL 的目的,從而讓之前的強制緩存失效 (其實並未立即失效,只是不再使用了而已)。

在線提供的類庫 (如 jquery-3.3.1.min.js, lodash.min.js 等) 均採用這個模式。如果配置中還增加 public 的話,CDN 也可以緩存起來,效果拔群。

這個模式的一個變體是在引用 URL 後面添加參數 (例如 ?v=xxx 或者 ?_=xxx),這樣就不必在文件名或者路徑中包含動態參數,滿足某些完美主義者的喜好。在項目每次構建時,更新額外的參數 (例如設置為構建時的當前時間),則能保證每次構建後總能讓瀏覽器請求最新的內容。

特別注意: 在處理 Service Worker 時,對待 sw-register.js(註冊 Service Worker) 和 serviceWorker.js (Service Worker 本身) 需要格外的謹慎。如果這兩個文件也使用這種模式,你必須多多考慮日後可能的更新及對策。

模式 2:經常變化的資源

Cache-Control: no-cache


這裡的資源不單單指靜態資源,也可能是網頁資源,例如博客文章。這類資源的特點是:URL 不能變化,但內容可以(且經常)變化。我們可以設置 Cache-Control: no-cache 來迫使瀏覽器每次請求都必須找服務器驗證資源是否有效。

既然提到了驗證,就必須 ETag 或者 Last-Modified 出場。這些字段都會由專門處理靜態資源的常用類庫(例如 koa-static)自動添加,無需開發者過多關心。

也正如上文中提到協商緩存那樣,這種模式下,節省的並不是請求數,而是請求體的大小。所以它的優化效果不如模式 1 來的顯著。

模式 3:非常危險的模式 1 和 2 的結合 (反例)

Cache-Control: max-age=600, must-revalidate


不知道是否有開發者從模式 1 和 2 獲得一些啟發:模式 2 中,設置了 no-cache,相當於 max-age=0, must-revalidate。我的應用時效性沒有那麼強,但又不想做過於長久的強制緩存,我能不能配置例如 max-age=600, must-revalidate 這樣折中的設置呢?

表面上看這很美好:資源可以緩存 10 分鐘,10 分鐘內讀取緩存,10 分鐘後和服務器進行一次驗證,集兩種模式之大成,但實際線上暗存風險。因為上面提過,瀏覽器的緩存有自動清理機制,開發者並不能控制。

舉個例子:當我們有 3 種資源: index.html, index.js, index.css。我們對這 3 者進行上述配置之後,假設在某次訪問時,index.js 已經被緩存清理而不存在,但 index.html, index.css 仍然存在於緩存中。這時候瀏覽器會向服務器請求新的 index.js,然後配上老的 index.html, index.css 展現給用戶。這其中的風險顯而易見:不同版本的資源組合在一起,報錯是極有可能的結局。

除了自動清理引發問題,不同資源的請求時間不同也能導致問題。例如 A 頁面請求的是 A.js 和 all.css,而 B 頁面是 B.js 和 all.css。如果我們以 A -> B 的順序訪問頁面,勢必導致 all.css 的緩存時間早於 B.js。那麼以後訪問 B 頁面就同樣存在資源版本失配的隱患。

後記

這篇文章真心有點長,但已經囊括了前端緩存的絕大部分,包括 HTTP 協議中的緩存,Service Worker,以及 Chrome 瀏覽器的一些優化 (Memory Cache)。希望開發者們善用緩存,因為它往往是最容易想到,提升也最大的性能優化策略。

參考文章

A Tale of Four Caches(但這篇文章把 Service Worker 的優先級排在 memory cache 和 disk cache 之間,跟我實驗效果並不相符。懷疑可能是 2 年來 chrome 策略的修改?)

Caching best practices & max-age gotchas


分享到:


相關文章: