瀏覽器緩存機制介紹與緩存策略剖析

緩存可以減少網絡IO消耗,提高訪問速度。瀏覽器緩存是一種操作簡單,效果顯著的前端性能優化手段。對於這個操作的必要性,Chrome官方給出的解釋更具有說服力

通過網絡獲取內容即速度緩慢,又開銷巨大。較大的響應需要在客戶端與服務器之間進行多次往返通信, 這會延遲瀏覽器獲得和處理內容的時間, 還會增加訪問者的流量費用。因此,緩存並重複利用之前獲取的資源的能力成爲性能優化的一個關鍵方面

很多時候,大家傾向於將瀏覽器緩存簡單的理解爲HTTP緩存。但事實上,瀏覽器緩存機制有四個方面, 他們按照獲取資源時請求的優先級一次排列如下:

  1. Memory Cache
  2. Service Worker Cache
  3. HTTP Cache
  4. Push Cache

大家對HTTP Cache(即Cache-Control expires等字段控制的緩存)應該比較熟悉,如果對其他幾種緩存可能沒什麼概念, 先看一張線上網站Network面板截圖:

給size這一欄一個特寫:

注意一下非數字——即形如from xxx 這樣的描述對應的資源, 這些資源是通過緩存獲取到的。其中,from memory cache 對標到Memory Cache類型 from ServiceWorker對標到Service Worker Cache類型。至於push Cache 這個比較特殊 是HTTP2的新特性

考慮到HTTP緩存是最主要 最具有代表性的緩存策略, 也是每一位前端工程師都應該深刻理解掌握的性能優化知識點, 下面優先針對HTTP緩存機制進行剖析。

HTTP緩存機制密探

HTTP緩存是我們日常開發中最爲熟悉的一種緩存機制。他又分爲強緩存協商緩存。優先級較高的是強緩存,在命中強緩存失敗的情況下,纔會走協商緩存

強緩存的特徵

強緩存是利用http頭中的Expires和Cache-Control兩個字段來控制的。強緩存中, 當請求再次發出時, 瀏覽器會根據其中的expires和cache-control判斷目標資源是否命中強緩存, 若命中, 則直接從緩存中獲取資源, 不會再與服務端發生通信。

命中強緩存的情況下, 返回的HTTP狀態碼爲200

強緩存的實現

實現強緩存, 過去我們一直用expires。

當服務器返回響應時, 在Response headers中將過期時間寫入expires字段。像這樣

給expires一個特寫

expires: Wed, 11 Sep 2019 16:12:18 GMT

可以看到, expires是一個時間戳, 接下來如果我們試圖再次項服務器請求資源,瀏覽器就會先對比本地時間和expires的時間戳, 如果本地時間小於expires設定的過期時間, 那麼就直接去緩存中取這個資源

 

從這樣的描述中大家不難猜測, expires是有問題的, 它最大的問題在於對本地時間的依賴。如果服務端和客戶端的時間設置可能不同, 或者我直接手動去把客戶端的時間改掉, 那麼expires將無法達到我們的預期。

 

考慮到expires的侷限性, HTTP1.1 新增了Cache-Control 字段來完成expires的任務。

expires能做的事情, Cache-Control都能做;expires完成不了的事情, Cache-Control也能做。因此,Cache-Control 可以視作是expires的完全替代方案。在當下前端實踐裏, 我們繼續使用expires的唯一目的就是向下兼容。

現在給Cache-Control字段一個特寫

cache-control: max-age=31536000

如大家所見,在Cache-Control中, 我們通過max-age來控制資源的有效期。max-age不是一個時間戳, 二十一個時間長度。在本例中,max-age是315360000秒,它意味着該資源在31536000秒內都是有效的,完美的規避了時間戳帶來的潛在問題

Cache-Control相對於expires更加準確,他的優先級也更高。當Cache-Control與expires同時出現時,我們以Cache-Control爲準

Cache-Control應用分析

Cache-Control的神通, 可不止於這個小小的max-age。如下用法也非常常見

cache-control:max-age=3600,s-maxage=3153600

s-maxage優先級高於max-age, 兩者同時出現時, 優先考慮s-maxage。如果s-maxage未過期, 則向代理服務器請求其緩存的內容。

這個 s-maxage 不像 max-age 一樣爲大家所熟知。的確,在項目不是特別大的場景下,max-age 足夠用了。但在依賴各種代理的大型架構中,我們不得不考慮代理服務器的緩存問題。s-maxage 就是用於表示 cache 服務器上(比如 cache CDN)的緩存的有效時間的,並只對 public 緩存有效。

s-maxage僅在代理服務器中生效,客戶端中我們只考慮max-age。

那麼什麼是 public 緩存呢?說到這裏,Cache-Control 中有一些適合放在一起理解的知識點,我們集中梳理一下:

public 與 private

public 與 private 是針對資源是否能夠被代理服務緩存而存在的一組對立概念。

如果我們爲資源設置了 public,那麼它既可以被瀏覽器緩存,也可以被代理服務器緩存;如果我們設置了 private,則該資源只能被瀏覽器緩存。private 爲默認值。但多數情況下,public 並不需要我們手動設置,比如有很多線上網站的 cache-control 是這樣的:

設置了 s-maxage,沒設置 public,那麼 CDN 還可以緩存這個資源嗎?答案是肯定的。因爲明確的緩存信息(例如“max-age”)已表示響應是可以緩存的。

no-store與no-cache

no-cache 繞開了瀏覽器:我們爲資源設置了 no-cache 後,每一次發起請求都不會再去詢問瀏覽器的緩存情況,而是直接向服務端去確認該資源是否過期(即走我們下文即將講解的協商緩存的路線)。

no-store 比較絕情,顧名思義就是不使用任何緩存策略。在 no-cache 的基礎上,它連服務端的緩存確認也繞開了,只允許你直接向服務端發送請求、並下載完整的響應。

協商緩存:瀏覽器與服務器合作之下的緩存策略

協商緩存依賴於服務端與瀏覽器之間的通信。

協商緩存機制下,瀏覽器需要向服務器去詢問緩存的相關信息,進而判斷是重新發起請求、下載完整的響應,還是從本地獲取緩存的資源。

如果服務端提示緩存資源未改動(Not Modified),資源會被重定向到瀏覽器緩存,這種情況下網絡請求對應的狀態碼是 304(如下圖)。

協商緩存的實現:從 Last-Modified 到 Etag

Last-Modified 是一個時間戳,如果我們啓用了協商緩存,它會在首次請求時隨着 Response Headers 返回:

Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT

隨後我們每次請求時,會帶上一個叫 If-Modified-Since 的時間戳字段,它的值正是上一次 response 返回給它的 last-modified 值:

If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT

服務器接收到這個時間戳後,會比對該時間戳和資源在服務器上的最後修改時間是否一致,從而判斷資源是否發生了變化。如果發生了變化,就會返回一個完整的響應內容,並在 Response Headers 中添加新的 Last-Modified 值;否則,返回如上圖的 304 響應,Response Headers 不會再添加 Last-Modified 字段。

使用 Last-Modified 存在一些弊端,這其中最常見的就是這樣兩個場景:

  • 我們編輯了文件,但文件的內容沒有改變。服務端並不清楚我們是否真正改變了文件,它仍然通過最後編輯時間進行判斷。因此這個資源在再次被請求時,會被當做新資源,進而引發一次完整的響應——不該重新請求的時候,也會重新請求。

  • 當我們修改文件的速度過快時(比如花了 100ms 完成了改動),由於 If-Modified-Since 只能檢查到以秒爲最小計量單位的時間差,所以它是感知不到這個改動的——該重新請求的時候,反而沒有重新請求了。

這兩個場景其實指向了同一個 bug——服務器並沒有正確感知文件的變化。爲了解決這樣的問題,Etag 作爲 Last-Modified 的補充出現了。

Etag 是由服務器爲每個資源生成的唯一的標識字符串,這個標識字符串是基於文件內容編碼的,只要文件內容不同,它們對應的 Etag 就是不同的,反之亦然。因此 Etag 能夠精準地感知文件的變化。 

Etag 和 Last-Modified 類似,當首次請求時,我們會在響應頭裏獲取到一個最初的標識符字符串,舉個例子,它可以是這樣的:

ETag: W/"2a3b-1602480f459"

那麼下一次請求時,請求頭裏就會帶上一個值相同的、名爲 if-None-Match 的字符串供服務端比對了:

If-None-Match: W/"2a3b-1602480f459"

Etag 的生成過程需要服務器額外付出開銷,會影響服務端的性能,這是它的弊端。因此啓用 Etag 需要我們審時度勢。正如我們剛剛所提到的——Etag 並不能替代 Last-Modified,它只能作爲 Last-Modified 的補充和強化存在。 Etag 在感知文件變化上比 Last-Modified 更加準確,優先級也更高。當 Etag 和 Last-Modified 同時存在時,以 Etag 爲準。

 

HTTP 緩存決策指南

行文至此,當代 HTTP 緩存技術用到的知識點,我們已經從頭到尾挖掘了一遍了。那麼在面對一個具體的緩存需求時,我們到底該怎麼決策呢?

走到決策建議這一步,我本來想給大家重新畫一個流程圖。但是畫來畫去終究不如 Chrome 官方給出的這張清晰、權威:

我們現在一起解讀一下這張流程圖:

當我們的資源內容不可複用時,直接爲 Cache-Control 設置 no-store,拒絕一切形式的緩存;否則考慮是否每次都需要向服務器進行緩存有效確認,如果需要,那麼設 Cache-Control 的值爲 no-cache;否則考慮該資源是否可以被代理服務器緩存,根據其結果決定是設置爲 private 還是 public;然後考慮該資源的過期時間,設置對應的 max-age 和 s-maxage 值;最後,配置協商緩存需要用到的 Etag、Last-Modified 等參數。

我個人非常推崇這張流程圖給出的決策建議,也強烈推薦大家在理解以上知識點的基礎上,將這張圖保存下來、在日常開發中用用看,它的可行度非常高。

MemoryCache

MemoryCache,是指存在內存中的緩存。從優先級上來說,它是瀏覽器最先嚐試去命中的一種緩存。從效率上來說,它是響應速度最快的一種緩存。

內存緩存是快的,也是“短命”的。它和渲染進程“生死相依”,當進程結束後,也就是 tab 關閉以後,內存裏的數據也將不復存在。

那麼哪些文件會被放入內存呢?

事實上,這個劃分規則,一直以來是沒有定論的。不過想想也可以理解,內存是有限的,很多時候需要先考慮即時呈現的內存餘量,再根據具體的情況決定分配給內存和磁盤的資源量的比重——資源存放的位置具有一定的隨機性。

雖然劃分規則沒有定論,但根據日常開發中觀察的結果,包括我們開篇給大家展示的 Network 截圖,我們至少可以總結出這樣的規律:資源存不存內存,瀏覽器秉承的是“節約原則”。我們發現,Base64 格式的圖片,幾乎永遠可以被塞進 memory cache,這可以視作瀏覽器爲節省渲染開銷的“自保行爲”;此外,體積不大的 JS、CSS 文件,也有較大地被寫入內存的機率——相比之下,較大的 JS、CSS 文件就沒有這個待遇了,內存資源是有限的,它們往往被直接甩進磁盤。

Service Worker Cache

Service Worker 是一種獨立於主線程之外的 Javascript 線程。它脫離於瀏覽器窗體,因此無法直接訪問 DOM。這樣獨立的個性使得 Service Worker 的“個人行爲”無法干擾頁面的性能,這個“幕後工作者”可以幫我們實現離線緩存、消息推送和網絡代理等功能。我們藉助 Service worker 實現的離線緩存就稱爲 Service Worker Cache。

Service Worker 的生命週期包括 install、active、working 三個階段。一旦 Service Worker 被 install,它將始終存在,只會在 active 與 working 之間切換,除非我們主動終止它。這是它可以用來實現離線存儲的重要先決條件。

下面我們就通過實戰的方式,一起見識一下 Service Worker 如何爲我們實現離線緩存(注意看註釋): 我們首先在入口文件中插入這樣一段 JS 代碼,用以判斷和引入 Service Worker:

window.navigator.serviceWorker.register('/test.js').then(function () {
    console.log('註冊成功')
}).catch(err => {
    console.error("註冊失敗")
})

在 test.js 中,我們進行緩存的處理。假設我們需要緩存的文件分別是 test.html,test.css 和 test.js:

// Service Worker會監聽 install事件,我們在其對應的回調裏可以實現初始化的邏輯  
self.addEventListener('install', event => {
  event.waitUntil(
    // 考慮到緩存也需要更新,open內傳入的參數爲緩存的版本號
    caches.open('test-v1').then(cache => {
      return cache.addAll([
        // 此處傳入指定的需緩存的文件名
        '/test.html',
        '/test.css',
        '/test.js'
      ])
    })
  )
})

// Service Worker會監聽所有的網絡請求,網絡請求的產生觸發的是fetch事件,我們可以在其對應的監聽函數中實現對請求的攔截,進而判斷是否有對應到該請求的緩存,實現從Service Worker中取到緩存的目的
self.addEventListener('fetch', event => {
  event.respondWith(
    // 嘗試匹配該請求對應的緩存值
    caches.match(event.request).then(res => {
      // 如果匹配到了,調用Server Worker緩存
      if (res) {
        return res;
      }
      // 如果沒匹配到,向服務端發起這個資源請求
      return fetch(event.request).then(response => {
        if (!response || response.status !== 200) {
          return response;
        }
        // 請求成功的話,將請求緩存起來。
        caches.open('test-v1').then(function(cache) {
          cache.put(event.request, response);
        });
        return response.clone();
      });
    })
  );
});

PS:大家注意 Server Worker 對協議是有要求的,必須以 https 協議爲前提。

Push Cache

Push Cache 是指 HTTP2 在 server push 階段存在的緩存。這塊的知識比較新,應用也還處於萌芽階段,我找了好幾個網站也沒找到一個合適的案例來給大家做具體的介紹。但應用範圍有限不代表不重要——HTTP2 是趨勢、是未來。在它還未被推而廣之的此時此刻,我仍希望大家能對 Push Cache 的關鍵特性有所瞭解:

  • Push Cache 是緩存的最後一道防線。瀏覽器只有在 Memory Cache、HTTP Cache 和 Service Worker Cache 均未命中的情況下才會去詢問 Push Cache。
  • Push Cache 是一種存在於會話階段的緩存,當 session 終止時,緩存也隨之釋放。
  • 不同的頁面只要共享了同一個 HTTP2 連接,那麼它們就可以共享同一個 Push Cache。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章