# 淺談快取機制

為什麼需要快取?因為我們希望能夠快速獲得想要的資訊。

一般來說,前端能夠處理的快取有幾種:

  1. 透過 http 的 cache 機制。例如 301 以及對應的 header
  2. CDN
  3. ServiceWorker
  4. 透過 localStorage 存資料

以下來一一介紹。

# CDN(Content Delivery Node)

在請求一張圖片(或其他資源)時,伺服器如果架設在美國,而請求來自台灣,則 TCP 傳輸會受到延遲時間跟掉包率的影響,距離越遠當然延遲也就越大。

而 CDN 的原理用很簡單的方式來說,就是將資源快取到各個分布的節點當中,這樣使用者在請求資源時,就可以直接從離使用者最近的網路節點回應,加快回應速度。伺服器如果掛掉或某個節點掛掉了,還可以靠其他節點的快取來回應。

不過也正因為如此,要管理快取也比較麻煩一些。

如果圖片更新了怎麼辦?要怎麼讓 CDN 即時更新,這就需要一些取捨跟機制了。

最簡單的方式是,每一張圖片都給一個 hash。如 avatar.01.jpg,當圖片更新時,我就上傳新的 avatar.02.jpg,不過這代表你在應用程式中的檔名都需要修改,或是這個欄位是從資料庫的 URL 而來,也需要修改資料庫欄位,可能還會需要重新部署應用程式。

這樣子的好處是可以確保請求資源的即時性。

第二個方式是手動將快取清除,或者是設定比較短的過期時間,因為新的圖片要傳送到各個 CDN 節點需要時間,因此在剛開始可能一樣會有延遲。

CDN 通常也會提供一個機制讓你對資源做 invalid 或是版本管理,所以通常在做靜態資源管理時,會建議將資源放在 CDN 上,可以省下不少麻煩事。

# HTTP Header

靜態檔案可以透過 http header 提供的機制來做快取。主要的原理是透過 Cache-ControlETag 這兩個 header 來做對應的快取處理。

當瀏覽器遇到這個標頭時,會將靜態資源保存在 disk 當中,並根據裡頭的值設定過期時間。當請求發出時,會先確認本地檔案是否過期,再發送請求與伺服器要資料。在 Chrome 上,你可以打開開發者工具到 network 這個 tab 來查看快取來源。

詳細的行為會依照 Cache-Control 的值不同而有不同的處理,TechBridge 上的文章 (opens new window)解釋的相當詳細。

# ETag

為了確保快取檔案的正確性,我們需要一個機制來問伺服器現在快取的檔案內容是否和之前一樣,而 ETag 就是用類似 hash 的機制來產生一組編碼,比對一下 ETag,如果相同就直接回傳 304,就不用重複下載一整包相同的內容了;如果 ETag 不同再下載檔案。

要注意的是,這跟 from disk cache 不同,from disk cache 並不會發送請求,而 304 則是已經向伺服器發出請求,但不用下載檔案。

# Service Worker

ServiceWorker 除了實作 PWA 之外,你也可以把 Service Worker 當成一個 request 的 hijack。每個發送的 request 都可以透過 service worker 的 event listener 給抓起來。

透過這樣的機制,我們可以寫一個簡單的 cache。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(res => {
        if (res) {
          return new Response('<p>Hijack request!</p>', {
            headers: { 'Content-Type': 'text/html' }
          })
        }
        
        return fetch(event.request).then(res => {
          return caches.open('v1')
          	.then(cache => {
              cache.put(event.request, res.clone())
              return res
            })
        })
      })
  )
  event.respondWith(
    // 檢查快取中是否有可用的資源
    caches.match(event.request).then( {
      if(response) { // 使用 Service Worker 回應
        return new Response('<p>Hello from your friendly neighbourhood service worker!</p>', {
          headers: { 'Content-Type': 'text/html' }
        });
      } else {
        console.log('No response found in cache. About to fetch from network...');
      }

      // Service Worker 沒有設定相對應的回應,發出 HTTP Request
      return fetch(event.request).then(function(response) {
        console.log('Response from network is:', response);
        // 加入快取供之後使用
        return caches.open('v1').then(function(cache) {
          cache.put(event.request, response.clone());
          return response;
        });
      }).catch(function(error) { // 錯誤處理
        console.error('Fetching failed:', error);
        throw error;
      });
    })
  );
});

我們先看看快取裡是否有對應的資源,如果有就回傳,沒有的話再實際發請求去拿,並且放入 cache 當中。在實際開發中的情況會比上述的程式碼複雜更多,如果是用 wwebpack 可以考慮搭配 OfflinePlugin (opens new window) 來簡化邏輯。

# localStorage

你可以用 localStorage 存取任何字串。API 也相當簡單直覺 localStorage.setItemlocalStorage.getItem 以及 localStorage.clear

不過使用上要考慮:

  • 容量限制為 5MB,對大部分的應用程式來說應該算是夠用。
  • 使用者可以在瀏覽器上禁用 WebStorage API,這會造成 localStorage 存取時跳出 Exception
  • 只能以字串的形式儲存,可以用 JSON.stringifyJSON.parse 來解析資料
  • 使用者可以在 localStorage 修改、刪除、增加資料,所以要小心安全性(特別是 XSS) 的問題
  • 任何第三方的程式庫都能夠存取 localStorage
  • localStorage 只有 JavaScript 才能存取,不像 Cookie 可以自動由瀏覽器送出
  • localStorage 也符合同源政策,意味著只有同一個 domain 下才能存取

# Memory cache

透過 JavaScript 原生的物件,你也可以寫一個簡單的 cache 來保存一些資料。但要做到這件事仍然要考慮一些事,例如:

  • 快取需要考慮過期時間嗎?(雖然每次關閉 application 就沒了,但如果在 application 持續開啟的時候呢?)
  • 快取需要考慮容量問題嗎?(最常見的方法是利用 LRU 來刪除)
  • 快取需要在值更新後通知嗎?

根據需要可能會有不同的實作。

# 小結

There are only two hard things in Computer Science: cache invalidation and naming things. — Phil Karlton