緣起

由於最近碰到要在負責的網頁式聊天機器人中,新增如同Line聊天室的貼圖功能,可想而知會有大量的貼圖圖片會需要在用戶裝置瀏覽器進行下載,不管是對圖片伺服器或是用戶等待的過程來說都是不小的負擔。

為了解決這個問題,透過使用了 PWA (Progressive Web App) 技術中常用的離線儲存方案 indexedDB 來快取貼圖圖片,不僅減少用戶端與網站伺服器端的連線流量,也提升的用戶體驗(UX: User Experience) 。

Service Worker

Service Worker是一個事件驅動的網站 Worker,根據不同的來源 (origin) 與網址路徑 (path) 進行註冊後,之後再次訪問同一網站後即會在背景執行這個 Service Worker。

Service Worker 無法直接存取 DOM 物件,他負責監聽和處理像是 fetch, notification(推播), sync 還有 Service Worker 註冊啟動 等相關事件。

透過處理上述事件,我們可以讓 web 網頁像一般的手機原生 App 一樣可以執行安裝、發送推播、離線瀏覽及連線後同步等等作業。

離線儲存

常見的 Web 儲存方案有下面幾種:

  1. Cookie
  2. LocalStorage
  3. Cache (precache)
  4. indexedDB

一般 SPA (Single Page Application) 中比較常用的是 Cookie 跟 LocalStorage 兩種儲存方法,不過在使用上都會有大小的限制(無法儲存較大的網站資源),像是 LocalStorage 的限制依照瀏覽器不同會落在大約 2MB ~ 10MB 左右,超過後就會發出Error。

而在 PWA 的離線儲存上通常會使用 Cache(precache) 或 indexedDB 來實作,它們的大小通常跟使用者裝置的 disk 空間有關係,以 Chrome 為例,所有網站可以用的共享儲存空間為硬碟可用空間的1/3, 每個網站 App 可以使用共享空間中的 20%。

舉例:

如果今天硬碟可用空間有 60GB,那麼其中的 1/3 也就是 20GB 會是共享的儲存空間,那每個網站可以用的大小就是這 20GB 中的 20%,也就是 4GB 左右,所以在儲存一般網頁圖片、影片或是聲音檔等資源應該是相當夠用的。 ( 參考來源: Chrome Developer

解決方案

在基本理解了 Service Worker 的相關知識後,接下來就是透過 Service Worker 攔截圖片下載的 fetch 事件,然後透過 indexedDB 儲存來快取網站圖片,簡單的流程圖如下:

flow chart ▲ Service Worker 圖片快取簡略流程圖

程式碼實作

在網頁載入時,可以透過 navigator.serviceWorker.register 進行 Service Worker 的安裝與註冊:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<script>
  // Check that service workers are supported
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker.register('./sw.js')
        .then(function(registration) {
          console.log('Registration succeeded.');
        }).catch(function(error) {
          console.log('Registration failed with ' + error);
        });
    });
  }
</script>

sw.js 中我們就可以定義攔截 fetch 事件 的相關程式邏輯

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
self.addEventListener('fetch', async (event) => {
  const url = new URL(event.request.url);
  if (
    event.request.destination === 'image' &&
    url.pathname.startsWith('/sticker/')
  ) {
    // Handle sticker images cache here
    event.respondWith(
      cacheMatch(url.pathname)
      .then(sticker => {
        if (sticker !== undefined) {
          /* 0. 在 indexedDB 找到圖片快取,使用快取中的圖片 */
          return new Response(sticker?.blob)
        } else {
          /* 1. 找不到快取,丟出錯誤
                (後續的錯誤處理會從網站上下載圖片) */
          throw new Error(
            `Sticker not found in DB: ${url.pathname}`
          );
        }
      })
      .catch(err => {
        /* 從網站上下載圖片 */
        return fetch(event.request)
          .then(async (httpResponse) => {
              const httpResponseToCache = httpResponse.clone();
              const imgBlob = await httpResponse.blob();
              cachePut({
                id: url.pathname,
                blob: imgBlob
              })
              return httpResponseToCache;
          })
          .catch(err => {
            throw err;
          })
      })
    );
  }
});

詳細實作可以參考 GitHub 範例專案