前端靜態資源緩存最優解以及max-age的陷阱


合理的使用緩存可以極大地提高網站資源的利用率,還可以節約帶寬從而降低服務器成本。但是很多站點針對緩存的策略並不合理,甚至是完全無作為,如果是這樣,就完全沒有發揮出緩存的優勢,而不合理的策略反而很大程度上會導致網站在訪問時會發生由於靜態資源的競爭關系而導致依賴的靜態資源不同步的問題(簡單地說,就是頁面發生了崩壞)。

以下為兩個最佳靜態資源緩存實踐的例子:

資源內容長時間內穩定不變

 設置長時間max-age

// 設置緩存時間為1年
Cache-Control: max-age=31536000

資源的內容非常穩定,長時間內都不會發生變更,那么我們就可以聲明瀏覽器/CDN可以長時間緩存該資源(31536000秒,即一年),只要用戶不手動清理瀏覽器緩存,一年內源服務器都不再會收到(當前瀏覽器/CDN)對該資源的請求。

接下來看一看實際的應用場景:

第一天

瀏覽器請求了/index-v1.js、/base-v1.css以及/dog-v1.png這三個資源,時序圖如下:

 

 

第二天

這次瀏覽器請求了/index-v2.js、/base-v2.css以及/dog-v1.png這三個資源,時序圖如下:

此處注意:index.js和base.css與第一天請求的版本號不同。

 

 

一年后

在一年的時間里,瀏覽器再也沒有請求過/index-v1.js、/base-v1.css以及/dog-v1.png這三個資源,瀏覽器緩存就會把它們給刪掉,時序圖如下:

 

 所以在這個例子中,為了讓緩存發揮最大效率,你要做的並不是更改文件的內容,而是應該更改資源的URL:

<script src="/index-v3.js"></script>
<link rel="stylesheet" href="/base-v3.css">
<img src="/dog-v3.jpg" alt="…">

每一個靜態資源URL都應該跟隨其內容的修改而改變。例如示例index-v1.js中的v1,你對它的命名不需要有任何限制。它可以是一個版本號,最后修改的日期,或者根據內容計算出來的散列值。

絕大多數服務器端的框架都提供了工具來實現這一點,同樣的在nodejs中有很多優秀的庫來實現這個功能,比如gulp-rev、webpack、fis3。

資源經常發生變更

Cache-Control: no-cache

資源的內容經常發生變化,沒有服務器的確認,任何本地緩存的資源都是不可信的,那么我們就可以聲明不讀取該資源的緩存,需要調用該資源時每次都嘗試向源服務器獲取。

第一天

 

 第二天

 

 

注意:
no-cache並不意味着不緩存。它的意思是在使用緩存資源之前,它必須經過服務器的檢查(revalidate也可以實現這個功能)。
no-store才是告訴瀏覽器不要緩存它。此外,must-revalidate並不意味着必須重新認證,它的前提是資源還在max-age的緩存期內,否則必須重新認證。

在此模式下 ,你也可以將ETag(你選擇的版本ID)或者Last-modified日期添加到響應首部中。客戶端下次獲取資源時,他會分別通過If-None-Match(與ETage對應)和If-Modified-Since(與Last-Mofied對應)兩個請求首部將值發送給服務器。如果服務器發現兩次值都是對等的,就是返回一個HTTP 304。

如果沒有發送ETag和Last-Modified,那么服務器將始終返回完整的資源內容。

但是這種方法有個缺點,就是它每次都會去服務器做一次驗證,涉及到了網絡提取,所以它不如第一個例子那樣可以完全繞過網絡。

下面來看一個頁面崩壞的例子:

在經常修改內容的靜態資源上使用max-age

當前頁面包含文件/article/、/styles.css和/script.js,他們的緩存策略如下:

// 十分鍾內不需要重新認證,超過十分鍾就需要重新認證 Cache-Control: must-revalidate, max-age=600

當頁面文件發生變更時,文件路徑會發生變化(如文件名會包含文件算出的哈希),在十分鍾內,瀏覽器將會一直使用緩存住的內容,而不會去服務器請求最新的資源 ;超過十分鍾,在可用的前提下使用If-Modified-Since和If-None-Match重新進行服務器認證。

這個描述看起來沒毛病,那么我們來看一下實際使用中會發生什么:

第一次請求

 

 幾分鍾后

 

 

 

 

想象一下,在線上環境你永遠不知道瀏覽器前面坐着的是什么樣的人,他很有可能無意中胡亂地用鼠標點點點(比如刪掉了style.css的本地緩存),就打亂了瀏覽器的靜態資源緩存機制,導致頁面發生了錯亂,而且真的很難追蹤(刪除行為無法記錄)。

在上面的例子中,服務器實際上已經更新了HTML、CSS和JS,但是頁面最后使用的是緩存中舊的HTML和JS,以及剛從服務器下載的最新的CSS,多個靜態資源版本之間不匹配的問題隨之出現,進而導致了頁面的崩壞。

通常,當需要對HTML進行重大修改時,我們會更改CSS文件來適配新的DOM結構,並且更新JS來配置樣式和DOM的修改。這些資源都是相互依賴的,但攜帶緩存信息的HTTP首部可不管你這些有的沒的。最終,用戶很有可能會得到一個/兩個靜態資源新版本,而其他資源都是舊版本。

max-age是相對於服務器響應時間的,所以如果所有上述資源都在同一時間請求,即便它們都被設置為了相同的max-age時長,它們仍然存在很小的競爭可能性(畢竟有的資源先返回有的資源后返回)。如果你的某些頁面不包含JS,或者包含不同的CSS,它們的緩存失效時間就有可能會不同步。更惡心的是,瀏覽器始終會從緩存中刪除和獲取資源,它並不知道這些資源中哪個是相互依賴的,只要過了緩存時間它就會毫不猶豫地刪掉一個,並不會刪掉這個過期文件所依賴的其他資源。把上面的種種可能性加在一起,就會大概率出現靜態資源版本不匹配的問題。

不過還好,我們還有法子來解決這個問題:

強制刷新瀏覽器或者清除緩存

在強制刷新瀏覽器或者清除緩存后,請求的頁面以及頁面內的所有資源會忽略之前的max-age,去服務器做重新認證。因此,如果用戶由於max-age出現問題之后,只需要強制刷新或者清緩存就可以修復問題。當然,強迫用戶這樣做只會讓它們降低對你網站的信任度,認為你的網站不靠譜。

原文在這里寫了使用serviceWorker來解決上面的頁面崩壞問題,按筆者的理解,serviceWorker就是對有依賴關系的資源進行了捆綁,一旦其中一個過期,則所有的資源都要重新獲取;但問題是serviceWorker並不是所有瀏覽器都支持,即使chrome和firefox也僅在最近的版本才開始支持,所以在這里就不貼出來了,有興趣的同學可以去原貼看一下。

在內容經常修改但是URL不變的靜態資源上使用max-age在通常意義上來說不是一個好點子,但事實卻不總是如此。

假如一個頁面的max-age為三分鍾,並且在這個頁面上不需要考慮靜態資源的競爭關系,即在這個頁面上不存在任何的靜態資源依賴,那么在這種情況下就可以盡情使用max-age,當然,代價是網站的修改要在三分鍾之后才可以被看到。

不過要是頁面存在靜態資源競爭關系的話,這種法子不好用了,比如我現在有兩個文章A和B,我現在文章A中添加一個新的章節,然后在文章B中增加了一個指向文章A新增章節的超鏈接。然后我從文章B中訪問這個鏈接,假如文章A的max-age沒有過期,那么我訪問到的文章A里將會發現文章並沒有那個新增的章節。此時只能等max-age過期或者強制刷新瀏覽器,再或者清除緩存了。所以,一定要謹慎使用這種方法。

正確使用緩存可以代理巨大的性能收益並且有效節省服務器帶寬。既支持版本號類型的靜態資源緩存方式也支持服務器重新認證(no-cache、304)的方式。如果你覺得自己很勇敢,那么大可混合使用max-age,但是前提你得確定自己的HTML中沒有靜態資源競爭關系。

最后簡單匯總一下合理的緩存策略:HTML使用每次服務端驗證的方式來保證資源是最新的,CSS和JS則可以使用設置max-age,但發生變更后更新資源路徑(如重新計算文件的哈希,並把哈希值加入文件名中)的方式來保證資源是最新的,當然,這樣做需要在HTML中同步更新依賴CSS和JS的資源路徑(雖然之前的CSS和JS仍在緩存期內,但實際頁面已經正確使用了更新后的資源)。

使用serviceWorker減少這種錯誤的出現幾率

service Worker的執行時機:

 

 注冊serviceWorker:

if (navigator.serviceWorker) {
 navigator.serviceWorker.register('/serviceworker.js', {
   scope: '/'
 });
}

執行serviceworker.js:

const version = '2';

self.addEventListener('install', event => {
 // 由於系統會隨時睡眠SW,所以,為了防止執行中斷,就需要使用 event.waitUntil 進行捕獲
 event.waitUntil(
   caches.open(`static-${version}`)
   .then(cache => cache.addAll(
       // 不穩定文件或者大文件加載
       //...
     ), 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))
 );
});

 

  • 將script和styles緩存起來。

  • 如果有匹配到的緩存就從緩存中獲取,如果沒有就從服務器獲取。

如果我們修改了JS/CSS,只需修改version就可以讓service worker觸發更新。

你也可以在service worker中跳過緩存:

self.addEventListener('install', event => {
   event.waitUntil(
       caches.open(`static-${version}`)
       .then(cache => cache.addAll([
           new Request('/styles.css', {
               cache: 'no-cache'
           }),
           new Request('/script.js', {
               cache: 'no-cache'
           })
       ]))
   );
});

不過很不巧的是,cache選項在和safari和opera中都不支持 ,只有firefox和chrome最近才開始支持。但是你可以這樣做:

self.addEventListener('install', event => {
   event.waitUntil(
       caches.open(`static-${version}`)
       .then(cache => Promise.all(
           [
               '/styles.css',
               '/script.js'
           ].map(url => {
               // 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);
               })
           })
       ))
   );
});

 

你可以使用上面代碼中的隨機字符串,也可以使用散列值。這有點像在javascript中實現文章剛開始第一小節的方法,不過僅僅是在server worker中使用。

service worker和HTTP cache也可以很好的共存

通過上個的例子,你可以看到service worker可以很好的處理一些糟糕的緩存情況。但是僅僅是做一些hack處理而已,最重要的是再根源上解決問題。正確的使用緩存不僅可以更好地使用service worker,還可以很好地在那些不支持service worker的瀏覽器(IE/Safari/Opera)上提高網站的性能。除此之外,對你的CDN也是大有益處。

正確的使用緩存,可以大量簡化service worker的代碼:

const version = '23';

self.addEventListener('install', event => {
   event.waitUntil(
       caches.open(`static-${version}`)
       .then(cache => cache.addAll([
           '/',
           '/script-v3.js',
           '/styles-v3.css',
           '/dog-v3.jpg'
       ]))
   );
});

所以,我們可以使用第二小節的方法(服務器重新認證)來緩存根HTML頁面。並使用第一小節的方法(不同的內容使用不同的URL)來緩存其他資源。每次service worker更新世都會去請求網站的根HTML頁面,其他資源只有在更改URL時才會去下載,從而提高網站的性能。

雖然service worker擅長提高網站的性能,但它並不是一個完整的解決方案。因此要和HTTP cache配合使用才可以顯著地提高性能。

max-age和『內容經常修改但是URL不變的靜態資源』搭配使用

在內容經常修改但是URL不變的靜態資源上使用max-age在通常意義上來說不是一個好點子,但事實卻不總是如此。

假如一個頁面的max-age為三分鍾,並且在這個頁面上不需要考慮靜態資源的競爭關系(靜態資源之間存在相互依賴,見第三小節),所以在這個頁面上不存在任何的靜態資源依賴。在這種情況下就可以盡情使用max-age。不過這也意味着網站的修改要再三分鍾之后才可以被看到。

不過要是頁面存在靜態資源競爭關系的話,這種法子不好用了,比如我現在有兩個文章A和B,我現在文章A中添加一個新的章節,然后在文章B中增加了一個指向文章A新增章節的超鏈接。然后我從文章B中訪問這個鏈接,假如文章A的max-age沒有過期,那么我訪問到的文章A里將會發現文章並沒有那個新增的章節。此時只能等max-age過期或者強制刷新瀏覽器,再或者清除緩存了。所以,一定要謹慎使用這種方法。

正確使用緩存可以代理巨大的性能收益並且有效節省服務器帶寬。既支持版本號類型的靜態資源緩存方式也支持服務器重新認證(no-cache、304)的方式。如果你覺得自己很勇敢,那么大可混合使用max-age和『內容經常修改但是URL不變的靜態資源』,但是前提你得確定自己的HTML中沒有靜態資源競爭關系。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM