正確使用緩存可以帶來巨大的性能優勢,節省寬帶,並降低服務器成本,但許多網站並不重視緩存,造成競爭條件,導致相互依賴的資源不同步。
絕大多數最佳實踐緩存屬於以下兩種模式之一:
- 模式一:不可變(immutable)內容 + 長
max-age - 模式二:可變(mutable)內容,始終由服務器驗證
模式一:不可變內容 + 長 max-age
Cache-Control:max-age=31536000
適用以下情況:
- 此 URL 上的內容永遠不會改變。
- 瀏覽器/CDN 可以將此資源緩存一年沒有問題。
- 可以使用小於
max-age幾秒的緩存內容,無需諮詢服務器。
在這個模式下,您永遠不會更改特定 URL 的內容,而是更改 URL:
<script src="/script-f93bca2c.js"></script>
<link rel="stylesheet" href="/styles-a837cb1e.css" />
<img src="/cats-0e9a2ef4.jpg" alt="…" />
每個 URL 包含的信息都會隨之改變,它可以是版本號、修改日期或內容的哈希值。
大多數服務器端框架都自帶工具來簡化這一過程(我使用 Django 的 ManifestStaticFilesStorage),還有一些較小的 Node.js 庫也能實現同樣的功能,例如 gulp-rev。
不過,這種模式不適用於文章和博文等內容,它們的 URL 無法版本化,內容也必須能夠更改。説真的,鑑於我經常會犯一些基本的拼寫和語法錯誤,我需要能夠快速、頻繁地更新內容。
模式二:可變內容,始終由服務器驗證
Cache-Control: no-cache
適用以下情況:
- 此 URL 上的內容可能會更改
- 未經服務器許可,任何本地緩存版本都不可信
注意:no-cache 並不意味着 "不緩存",而是指在使用緩存資源前必須與服務器進行檢驗(或稱為 "重新驗證")。此外,must-revalidate 並不意味着 "必須重新驗證",而是説如果本地資源的時效小於所提供的 max-age,就可以使用,否則就必須重新驗證。
在這種模式下,可以在響應中添加 ETag(你選擇的版本 ID)或 Last-Modified 日期標頭。下一次客户端獲取資源時,就會分別通過 If-None-Match 和 If-Modified-Since 回傳已有內容的值,從而允許服務器説 "就用你已有的吧,它是最新的",或者正如它的拼寫那樣 "HTTP 304"。
如果無法發送 ETag/Last-Modified,服務器將始終發送完整內容。
這種模式總是需要通過網絡獲取,因此不如模式一那樣可以完全繞過網絡。
模式一所需的基礎設施讓人望而卻步,而模式二所需的網絡請求又讓人同樣望而卻步,因此,人們往往會選擇介於兩者之間的模式:較小的 max-age 和可變內容,這是一個糟糕的折衷方案。
可變內容的 max-age 通常是錯誤的選擇
遺憾的是,這種情況並不少見,例如在 Github 頁面上就會發生。
想象一下
/article//styles.css/script.js
所有服務:
Cache-Control: must-revalidate, max-age=600
包含以下場景:
- URL 內容更改
- 如果瀏覽器有不到 10 分鐘的緩存版本,則使用該版本,無需詢問服務器
- 否則,進行網絡獲取,如果可用,使用
If-Modified-Since或If-None-Match
這種模式在測試中似乎有效,但在實際場景中卻會造成故障,而且很難追查。在上面的例子中,服務器實際上已經更新了 HTML、CSS 和 JS,但頁面最終使用的是緩存中的舊 HTML 和 JS,以及服務器上更新的 CSS。版本不匹配導致了問題的出現。
通常情況下,當我們對 HTML 進行重大修改時,很可能也會修改 CSS 以反映新的結構,並更新 JS 以適應樣式和內容的變化。這些資源是相互依存的,但緩存標頭無法表達這一點。用户最終可能會使用其中一個/兩個資源的新版本,而使用另一個/多個資源的舊版本。
max-age 是相對於響應時間而言的,因此如果上述所有資源都是作為同一導航的一部分被請求的,那麼它們將被設置為在大致相同的時間過期,但仍然存在競爭的可能性。
如果有些頁面不包含 JS,或包含不同的 CSS,過期日期就會不同步。更糟糕的是,瀏覽器經常會從緩存中刪除一些內容,而它並不知道 HTML、CSS 和 JS 是相互依存的,所以它會很樂意刪除其中一個,而不刪除其他的。將這些因素相乘,最終出現這些資源版本不匹配的情況也就不是不可能了。
對於用户來説,這可能會導致佈局和/或功能被破壞,從細微的故障到完全無法使用的內容。
值得慶幸的是,用户有一個逃生通道...
刷新有時可以解決
如果頁面是作為刷新的一部分加載的,瀏覽器總是會與服務器重新驗證,而忽略 max-age。因此,如果用户遇到的問題是由於 max-age 導致的,點擊刷新就能解決一切問題。當然,強迫用户這樣做會降低信任度,因為這會讓人覺得你的網站很不穩定。
Service Worker 線程可以延長這些錯誤的壽命
假設您有以下 Service Worker:
const version = '2';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(`static-${version}`)
.then((cache) => cache.addAll(['/styles.css', '/script.js']))
)
})
self.addEventListener('activate', (event) => {
// delete old caches...
})
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => response || fetch(event.request))
)
})
這個 Service Worker 線程...
- 預先緩存腳本和樣式
- 如果匹配,則從緩存中提供服務,否則通過網絡提供服務
如果我們更改了 CSS/JS,我們就會提升 version,使 Service Worker 的字節不同,從而觸發更新。不過,由於 addAll 是通過 HTTP 緩存獲取的(幾乎所有的獲取都是這樣),我們可能會遇到 max-age 競爭條件,並緩存到不兼容的 CSS 和 JS 版本。
一旦它們被緩存,在下次更新 Service Worker 之前,我們將一直提供不兼容的 CSS 和 JS。
您可以繞過 Service Worker 中的緩存:
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(`static-${version}`)
.then((catch) => {
cache.addAll([
new Request('/styles.css', { cache: 'no-cache' }),
new Request('/script.js', { cache: 'no-cache' })
])
})
)
})
遺憾的是,Chrome/Opera 尚不支持緩存選項,而 Firefox Nightly 最近才支持緩存選項,不過你也可以自己嘗試一下:
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(`static-${version}`).then((cache) => {
return Promise.all(
['/styles.css', '/script.js'].map(() => {
// cache-bust using a random query string
return fetch(`${url}?${Math.random()}`).then((response) => {
// fail on 404, 500 etc
if(!response.ok) throw Error('Not ok');
return cache.put(url, response);
})
})
)
})
)
})
在上文中,我使用隨機數來破壞緩存,但您可以更進一步,使用構建步驟來添加內容的哈希值(類似於 sw-precache 的做法)。這有點像在 JavaScript 中重新實現模式一(不可變內容),但只是為了 Service Worker 用户的利益,而不是所有瀏覽器和 CDN 的利益。
Service Worker 和 HTTP 緩存可以很好地合作,不要讓它們打架!
正如您所看到的,您可以解決 Service Worker 中的糟糕的緩存問題,但最好還是解決問題的根源。正確設置緩存可以換 Service Worker 領域的工作變得更輕鬆,而且也有利於不支持 Service Worker 的瀏覽器(Safari、IE/Edge)受益,並讓您最大限度地利用 CDN。
正確的緩存標頭意味着您還可以大幅簡化 Service Worker 的更新:
const version = '23';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(`static-${version}`)
.then((cache) => {
cache.addAll([
'/',
'/script-f93bca2c.js',
'/styles-a837cb1e.css',
'/cats-0e9a2ef4.jpg'
])
})
)
})
在這裏,我會使用模式二(服務器重新驗證)緩存根頁面,使用模式一(不可變內容)緩存其他資源。每次 Service Worker 更新都會觸發對根頁面的請求,但其他資源只有在 URL 發生變化時才會被下載。這樣做非常好,因為無論從上一版本還是 10 個版本更新,都能節省帶寬並提高性能。
與本地程序相比,這是一個巨大的優勢,在本地程序中,即使是很小的改動也要下載整個二進制文件,或者涉及複雜的二進制差異,在這裏,我們只需相對較少的下載就能更新一個大型網絡應用程序。
Service Worker 的最佳工作方式是增強而不是變通,因此與其與緩存對抗,不如與它合作!
謹慎使用 max-age 和可變內容可帶來益處
在可變內容上使用 max-age 通常是錯誤的選擇,但並非總是如此。
例如,本頁面的 max-age 為三分鐘,這裏並不存在競爭條件的問題,因為該頁面沒有任何依賴項遵循相同的緩存模式(我的 CSS、JS 和圖片 URL 都遵循模式一 ——不可變內容),而且該頁面的任何依賴項都不遵循相同的模式。
這種模式意味着,如果我有幸寫了一篇受歡迎的文章,我的 CDN(Cloudflare)可以為我的服務器分擔熱量,只要我可以忍受文章更新需要三分鐘才能被用户看到,而我現在就是這樣。
這種模式不能隨便使用,如果我在一篇文章中添加了一個新的部分,並在另一篇文章中進行了鏈接,那麼我就創建了一個可能會發生競爭的依賴關係。用户點擊鏈接後,可能會進入一篇沒有引用部分的文章。如果我想避免這種情況,我會更新第一篇文章,使用 Cloudflare 的用户界面刷新 Cloudflare 的緩存副本,等待三分鐘,然後在另一篇文章中添加鏈接。是的......使用這種模式必須非常小心。
正確使用緩存可以大大提高性能和節省帶寬。對於任何容易改變的 URL,最好使用不可變內容,否則就使用服務器重新驗證。只有當你覺得自己很勇敢,並且確信你的內容沒有依賴關係或可能不同步的依賴關係時,才會混合使用 max-age 和可變內容。
推薦閲讀:徹底理解瀏覽器緩存機制
在 GitHub 上查看此頁面