從 PWA 認識 Service Worker#

Service Worker 是漸進式網頁應用程式(PWA, Progressive Web App)的關鍵元件之一。PWA 涵蓋的範圍極廣,本章不打算重講整套,焦點放在 Service Worker 提供的快取能力。

嚴格來說 Service Worker 是 Web Worker 的一種。日常寫的 JavaScript 跑在 main thread(UI thread)上,Service Worker 則跑在獨立的執行緒上:

  • 不會被畫面渲染或運算所阻塞。
  • 能在瀏覽器關閉的情況下繼續在背景運作。
  • 它是位於瀏覽器與網路層之間的代理(proxy),透過監聽 fetch 事件可以攔截使用者發出的請求。
  • 提供 Cache API,可以決定要不要回傳快取內容。

在快取的優先順序上,Service Worker Cache 排在 HTTP 快取(HTTP Caching)之前。如果撇開各家瀏覽器自行實作的 memory cache 不算,Service Worker Cache 就是開發者可掌控範圍中的第一道防線。

Service Worker 讓網頁可以做到許多以前難以實現的事,例如離線瀏覽、推播通知、Background Sync 等。

生命週期概念#

Service Worker 擁有完全獨立於頁面的生命週期,且開發者對它的控制權非常細。需要特別記得幾件事:它沒有辦法直接操作 DOM,與頁面的訊息傳遞要透過 postMessage

要使用 Service Worker 必須符合幾個條件:

  • 網站必須使用 HTTPS(或 localhost)。
  • 必須先註冊 Service Worker 檔案。
if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("./sw.js", { scope: "/ironman" })
    .then((reg) => {
      console.log("Registration succeeded. Scope is " + reg.scope);
    })
    .catch((error) => {
      console.log("Registration failed with " + error);
    });
}

scope 決定 Service Worker 作用的範圍。沒有指定時預設為根目錄;上例則是 /ironman 之下都歸這個 Service Worker 管。

控制 Service Worker 的方式是事件監聽,最重要的有 installactivatemessagefetch

install:註冊靜態資源#

註冊成功後,瀏覽器會在背景啟動一個 Service Worker,這時觸發 install 事件。一般會在這個階段把離線需要的靜態資源預先放進快取,全部成功才會進入下一步;只要有一個失敗,整個安裝過程就視為失敗,並等待下次重新嘗試。

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open("v1").then((cache) => {
      return cache.addAll(["/ironman.js", "/ironman.css"]);
    })
  );
});

操作快取使用的是 Cache API,是 Service Worker 與快取互動的標準介面。

activate:接管頁面#

完成安裝後 Service Worker 會進入 activate 階段,並接管 scope 範圍內的頁面。這個階段適合做清理工作,例如把不再使用的舊版本快取刪除。

self.addEventListener("activate", (event) => {
  // 例如清掉舊版的 cache
});

當頁面沒有在使用時,Service Worker 會被 terminated 以節省記憶體;下次需要時再被喚醒。

fetch:攔截請求#

fetch 事件是 Service Worker 在快取扮演要角的關鍵。最基本的用法是:先看快取裡有沒有,沒有再走網路。

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    })
  );
});

從這個 API 設計就能看出 Service Worker 的彈性:請求被攔截下來之後,要回傳什麼內容、要不要存進快取、要不要降級,全部由開發者決定。

為什麼還需要 Service Worker Cache#

HTTP 快取(HTTP Caching)已經很實用了,為什麼還要再學 Service Worker 的快取機制?主要兩個原因:

  • 更細粒度的控制:HTTP Caching 受限於 Header 的語法,能做的事情相對有限。Service Worker 是用程式碼控制,幾乎想得到的策略都能實作。
  • 離線優先(Offline First):HTTP Caching 並不是為了離線而設計,且只有「拜訪過的頁面」才有快取。Service Worker 可以主動將需要的資源存進快取,配合各種策略實現完整的離線體驗。即使瀏覽器有 HTTP 快取,部分情境下也不一定會走快取,行為會因實作而異。

常見的 Cache Strategies#

實作離線體驗時,有幾種典型的策略:

  • cache only
  • network only
  • cache falling back to network
  • network falling back to cache
  • cache then network(也就是 stale-while-revalidate)

cache only#

無論如何只回傳快取版本。實務上很少單獨使用,比較適合與 codebase 一起 build 出來的靜態資源。

self.addEventListener("fetch", (event) => {
  event.respondWith(caches.match(event.request));
});

network only#

完全不查快取直接發請求。常見場景是非 GET 的請求,本來就不適合被快取。

self.addEventListener("fetch", (event) => {
  event.respondWith(fetch(event.request));
});

cache falling back to network#

最常見的模式:先查快取,沒有才打網路。是想實作離線優先(Offline First)時的好起點。

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    })
  );
});

network falling back to cache#

對變動頻率高的內容(例如文章列表、排行榜)較合適:先以網路為主拿到最新資料,失敗時回退到快取。缺點是當網路非常緩慢卻沒完全斷線時,使用者會等很久。

self.addEventListener("fetch", (event) => {
  event.respondWith(
    fetch(event.request).catch(() => {
      return caches.match(event.request);
    })
  );
});

cache then network(stale-while-revalidate)#

先回傳快取版本讓使用者立即看到內容,同時在背景發出請求並更新快取,下次就能看到較新的資料。這種策略在使用者體驗與資料新鮮度間做了平衡。

self.addEventListener("fetch", (event) => {
  const cached = caches.match(event.request);
  const fetched = fetch(event.request);
  const fetchedCopy = fetched.then((resp) => resp.clone());

  event.respondWith(
    Promise.race([fetched.catch(() => cached), cached])
      .then((resp) => resp || fetched)
      .catch(() => new Response(null, { status: 404 }))
  );

  event.waitUntil(
    Promise.all([fetchedCopy, caches.open("cache-v1")])
      .then(([response, cache]) => cache.put(event.request, response))
      .catch(() => {})
  );
});

與 HTTP Cache 一起搭配#

Service Worker Cache 與 HTTP 快取(HTTP Caching)的目標其實不完全相同,所以怎麼搭配也是門學問。如果兩者的過期時間設成一樣,HTTP Cache 幾乎沒有意義;建議讓兩者過期時間錯開,並選擇互補的快取策略,這樣才會像「雙層防護網」一樣發揮作用。

Google 對於兩層快取怎麼搭配有寫過一篇詳細文章,有需要時可以延伸閱讀。

Workbox#

直接手寫 Service Worker 通常會出現大量 boilerplate code。Google 推出的 Workbox 把常用模式封裝起來,多數情境只要幾行設定就能搞定。除非有非常客製化的需求,做 PWA 或想用上 Service Worker 的能力時,建議優先考慮 Workbox。

本章重點#

  • Service Worker 是跑在獨立執行緒、能攔截網路請求的代理層,是漸進式網頁應用程式(PWA, Progressive Web App)的核心之一。
  • 它的快取優先順序高於 HTTP 快取(HTTP Caching),是開發者第一道可控的快取。
  • 透過 installactivatefetch 等生命週期事件,可以決定快取什麼、何時清理、請求如何回應。
  • 常見策略包括 cache only、network only、cache falling back to network、network falling back to cache,以及 stale-while-revalidate。
  • Service Worker Cache 與 HTTP Cache 應錯開過期時間並搭配不同策略,才能發揮兩層保護的價值。
  • 真實專案推薦直接用 Workbox,省下手寫一堆樣板程式碼的成本。

原文出處#