使用 Service Workers

本文提供有關 Service Worker 入門的資訊,包括基本架構、註冊 Service Worker、新 Service Worker 的安裝和啟用過程、更新 Service Worker、快取控制和自定義響應,所有這些都基於一個具有離線功能的應用程式。

Service Worker 的前提

多年來,Web 使用者一直面臨的一個主要問題是連線丟失。即使是世界上最好的 Web 應用程式,如果無法下載,也會提供糟糕的使用者體驗。已經有各種嘗試來建立技術來解決這個問題,其中一些問題已經解決了。但最主要的問題是沒有一個良好的整體控制機制來處理資產快取和自定義網路請求。

Service Worker 解決了這些問題。使用 Service Worker,您可以將應用程式設定為首先使用快取的資產,從而即使在離線狀態下也能提供預設體驗,然後再從網路獲取更多資料(通常稱為“離線優先”)。這在原生應用程式中已經可用,這也是原生應用程式通常優於 Web 應用程式的主要原因之一。

Service Worker 就像一個代理伺服器,允許您修改請求和響應,用其自己的快取中的專案替換它們。

設定以試玩 Service Worker

Service Worker 預設在所有現代瀏覽器中啟用。要執行使用 Service Worker 的程式碼,您需要透過 HTTPS 提供程式碼 — 出於安全原因,Service Worker 僅限於透過 HTTPS 執行。因此需要一個支援 HTTPS 的伺服器。為了進行實驗,您可以使用 GitHub、Netlify、Vercel 等服務。為了方便本地開發,localhost 也被瀏覽器視為安全來源。

基本架構

對於 Service Worker,通常遵循以下步驟進行基本設定:

  1. Service Worker 程式碼被獲取,然後使用serviceWorkerContainer.register()註冊。如果成功,Service Worker 會在ServiceWorkerGlobalScope中執行;這本質上是一種特殊的 Worker 上下文,在主指令碼執行執行緒之外執行,沒有 DOM 訪問許可權。Service Worker 現在已準備好處理事件。
  2. 安裝發生。install 事件始終是傳送給 Service Worker 的第一個事件(這可以用於啟動填充 IndexedDB 和快取站點資產的過程)。在此步驟中,應用程式正在準備使其所有內容可供離線使用。
  3. install 處理程式完成時,Service Worker 被視為已安裝。此時,Service Worker 的先前版本可能處於活動狀態並控制著開啟的頁面。由於我們不希望同時運行同一 Service Worker 的兩個不同版本,因此新版本尚未啟用。
  4. 一旦所有由舊版本 Service Worker 控制的頁面都關閉,就可以安全地淘汰舊版本,並且新安裝的 Service Worker 會收到一個 activate 事件。activate 的主要用途是清理 Service Worker 早期版本中使用的資源。新的 Service Worker 可以呼叫skipWaiting(),要求立即啟用,而無需等待開啟的頁面關閉。然後,新的 Service Worker 將立即收到 activate,並將接管任何開啟的頁面。
  5. 啟用後,Service Worker 將控制頁面,但僅限於在 register() 成功後開啟的頁面。換句話說,文件必須重新載入才能實際被控制,因為文件的生命週期從有或沒有 Service Worker 開始,並貫穿其整個生命週期。要覆蓋此預設行為並接管開啟的頁面,Service Worker 可以呼叫clients.claim()
  6. 每當 Service Worker 的新版本被獲取時,這個迴圈會再次發生,並且舊版本的殘留物會在新版本的啟用過程中被清理。

lifecycle diagram

以下是可用的 Service Worker 事件的摘要:

演示

為了演示 Service Worker 註冊和安裝的最基本知識,我們建立了一個名為simple service worker的演示,它是一個簡單的《星球大戰》樂高圖片庫。它使用一個基於 Promise 的函式從 JSON 物件讀取影像資料,並使用fetch()載入影像,然後將影像一行一行地顯示在頁面上。我們目前保持靜態。它還註冊、安裝並激活了一個 Service Worker。

The words Star Wars followed by an image of a Lego version of the Darth Vader character

您可以在GitHub 上檢視原始碼,並即時執行 simple service worker

註冊您的 Worker

我們應用程式的 JavaScript 檔案(app.js)中的第一個程式碼塊如下。這是我們使用 Service Worker 的入口點。

js
const registerServiceWorker = async () => {
  if ("serviceWorker" in navigator) {
    try {
      const registration = await navigator.serviceWorker.register("/sw.js", {
        scope: "/",
      });
      if (registration.installing) {
        console.log("Service worker installing");
      } else if (registration.waiting) {
        console.log("Service worker installed");
      } else if (registration.active) {
        console.log("Service worker active");
      }
    } catch (error) {
      console.error(`Registration failed with ${error}`);
    }
  }
};

// …

registerServiceWorker();
  1. if 塊執行功能檢測測試,以確保在嘗試註冊 Service Worker 之前支援 Service Worker。
  2. 接下來,我們使用ServiceWorkerContainer.register()函式為本站點註冊 Service Worker。Service Worker 程式碼位於我們應用程式中的 JavaScript 檔案中(請注意,這是檔案相對於源的 URL,而不是引用它的 JS 檔案)。
  3. scope 引數是可選的,可用於指定您希望 Service Worker 控制的內容子集。在這種情況下,我們指定了'/',這意味著應用程式源下的所有內容。如果您省略它,它也會預設為此值,但我們在此處指定它只是為了說明目的。

這會註冊一個 Service Worker,它在 Worker 上下文中執行,因此無法訪問 DOM。

一個 Service Worker 可以控制許多頁面。每次載入作用域內的頁面時,Service Worker 都會針對該頁面進行安裝並對其進行操作。因此,請記住,您需要謹慎使用 Service Worker 指令碼中的全域性變數:每個頁面都不會獲得自己獨特的 Worker。

注意: Service Worker 的一個優點是,如果您使用我們上面展示的功能檢測,不支援 Service Worker 的瀏覽器可以像往常一樣線上使用您的應用程式。

為什麼我的 Service Worker 註冊失敗?

Service Worker 註冊失敗有以下原因之一:

  • 您沒有在安全上下文(透過 HTTPS)中執行您的應用程式。
  • Service Worker 檔案的路徑不正確。路徑必須相對於源,而不是應用程式的根目錄。在我們的示例中,worker 位於https://bncb2v.csb.app/sw.js,應用程式的根目錄是https://bncb2v.csb.app/,因此 Service Worker 必須指定為/sw.js
  • 您的 Service Worker 路徑指向與您的應用程式不同源的 Service Worker。
  • Service Worker 註冊包含一個比 worker 路徑允許的範圍更廣的 scope 選項。Service Worker 的預設作用域是 worker 所在的目錄。換句話說,如果指令碼 sw.js 位於 /js/sw.js 中,則它預設只能控制 /js/ 路徑中的(或巢狀在其中的)URL。Service Worker 的作用域可以透過 Service-Worker-Allowed 標頭來擴大(或縮小)。
  • 啟用了瀏覽器特定的設定,例如阻止所有 cookie、隱私瀏覽模式、關閉時自動刪除 cookie 等。有關更多資訊,請參閱serviceWorker.register() 瀏覽器相容性

安裝和啟用:填充您的快取

您的 Service Worker 註冊後,瀏覽器將嘗試安裝並激活您的頁面/站點的 Service Worker。

install 事件是 Service Worker 安裝或更新時觸發的第一個事件。它只觸發一次,在註冊成功完成後立即觸發,通常用於透過您離線執行應用程式所需的資產來填充瀏覽器的離線快取功能。為此,我們使用 Service Worker 的儲存 API — cache — 這是 Service Worker 上的一個全域性物件,允許我們儲存由響應交付並以其請求為鍵的資產。此 API 的工作方式類似於瀏覽器的標準快取,但它特定於您的域。快取的內容會一直保留,直到您清除它們。

以下是我們的 Service Worker 處理 install 事件的方式:

js
const addResourcesToCache = async (resources) => {
  const cache = await caches.open("v1");
  await cache.addAll(resources);
};

self.addEventListener("install", (event) => {
  event.waitUntil(
    addResourcesToCache([
      "/",
      "/index.html",
      "/style.css",
      "/app.js",
      "/image-list.js",
      "/star-wars-logo.jpg",
      "/gallery/bountyHunters.jpg",
      "/gallery/myLittleVader.jpg",
      "/gallery/snowTroopers.jpg",
    ]),
  );
});
  1. 這裡我們向 Service Worker(因此是 self)新增一個 install 事件監聽器,然後將ExtendableEvent.waitUntil()方法鏈式地新增到事件上 — 這確保 Service Worker 不會安裝,直到 waitUntil() 內的程式碼成功執行。
  2. addResourcesToCache() 內部,我們使用 caches.open() 方法建立一個名為 v1 的新快取,這將是我們的站點資源快取的版本 1。然後我們對建立的快取呼叫一個函式 addAll(),它的引數是您想要快取的所有資源的 URL 陣列。這些 URL 是相對於 worker 的位置的。
  3. 如果 Promise 被拒絕,安裝失敗,Worker 將不會做任何事情。這沒關係,因為您可以修復程式碼,然後在下次註冊發生時重試。
  4. 成功安裝後,Service Worker 會啟用。在 Service Worker 首次安裝/啟用時,這並沒有太多獨特的用途,但在 Service Worker 更新時(請參閱後面的更新您的 Service Worker部分)它會更有意義。

注意:Web Storage API(localStorage的工作方式與 Service Worker 快取類似,但它是同步的,因此不允許在 Service Worker 中使用。

注意:如果需要,IndexedDB 可以在 Service Worker 內部用於資料儲存。

對請求的自定義響應

現在您已經快取了您的站點資產,您需要告訴 Service Worker 如何處理快取的內容。這透過 fetch 事件完成。

  1. 每當 Service Worker 控制的任何資源被獲取時,都會觸發 fetch 事件,這包括指定範圍內的文件,以及這些文件中引用的任何資源(例如,如果 index.html 發出跨域請求以嵌入影像,它仍然會透過其 Service Worker)。

  2. 您可以將 fetch 事件監聽器附加到 Service Worker,然後呼叫事件上的 respondWith() 方法來劫持我們的 HTTP 響應並使用您自己的內容更新它們。

    js
    self.addEventListener("fetch", (event) => {
      event.respondWith(/* custom content goes here */);
    });
    
  3. 我們可以從在每種情況下都用 URL 與網路請求匹配的資源進行響應開始:

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

    caches.match(event.request) 允許我們將網路請求的每個資源與快取中可用的等效資源進行匹配(如果存在匹配項)。匹配是透過 URL 和各種標頭完成的,就像正常的 HTTP 請求一樣。

Fetch event diagram

恢復失敗的請求

因此,當 Service Worker 快取中有匹配項時,caches.match(event.request) 非常有用,但是當沒有匹配項時怎麼辦?如果我們不提供任何型別的失敗處理,我們的 Promise 將解析為 undefined,我們將不會得到任何返回。

在測試來自快取的響應後,我們可以回退到常規網路請求:

js
const cacheFirst = async (request) => {
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }
  return fetch(request);
};

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

如果資源不在快取中,則從網路請求它們。

使用更精密的策略,我們不僅可以從網路請求資源,還可以將其儲存到快取中,以便以後對該資源的請求也可以離線檢索。這意味著如果《星球大戰》畫廊添加了額外的圖片,我們的應用程式可以自動獲取並快取它們。以下程式碼片段實現了這樣的策略:

js
const putInCache = async (request, response) => {
  const cache = await caches.open("v1");
  await cache.put(request, response);
};

const cacheFirst = async (request, event) => {
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }
  const responseFromNetwork = await fetch(request);
  event.waitUntil(putInCache(request, responseFromNetwork.clone()));
  return responseFromNetwork;
};

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

如果請求 URL 在快取中不可用,我們使用 await fetch(request) 從網路請求資源。之後,我們將響應的克隆放入快取。putInCache() 函式使用 caches.open('v1')cache.put() 將資源新增到快取。原始響應返回給瀏覽器,以便傳遞給呼叫它的頁面。

克隆響應是必要的,因為請求和響應流只能讀取一次。為了將響應返回給瀏覽器並將其放入快取,我們必須克隆它。因此,原始響應返回給瀏覽器,克隆傳送到快取。它們各自只讀取一次。

可能看起來有點奇怪的是,putInCache() 返回的 Promise 沒有被等待。原因是,我們不希望在響應克隆已新增到快取後才返回響應。但是,我們確實需要對 Promise 呼叫 event.waitUntil(),以確保 Service Worker 在快取填充之前不會終止。

我們現在唯一的問題是,如果請求與快取中的任何內容都不匹配,並且網路不可用,我們的請求仍然會失敗。讓我們提供一個預設的備用方案,這樣無論發生什麼,使用者至少都能得到一些東西:

js
const putInCache = async (request, response) => {
  const cache = await caches.open("v1");
  await cache.put(request, response);
};

const cacheFirst = async ({ request, fallbackUrl, event }) => {
  // First try to get the resource from the cache
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }

  // Next try to get the resource from the network
  try {
    const responseFromNetwork = await fetch(request);
    // response may be used only once
    // we need to save clone to put one copy in cache
    // and serve second one
    event.waitUntil(putInCache(request, responseFromNetwork.clone()));
    return responseFromNetwork;
  } catch (error) {
    const fallbackResponse = await caches.match(fallbackUrl);
    if (fallbackResponse) {
      return fallbackResponse;
    }
    // when even the fallback response is not available,
    // there is nothing we can do, but we must always
    // return a Response object
    return new Response("Network error happened", {
      status: 408,
      headers: { "Content-Type": "text/plain" },
    });
  }
};

self.addEventListener("fetch", (event) => {
  event.respondWith(
    cacheFirst({
      request: event.request,
      fallbackUrl: "/gallery/myLittleVader.jpg",
      event,
    }),
  );
});

我們選擇了這張備用圖片,因為唯一可能失敗的更新是新圖片,因為所有其他內容都依賴於我們在前面看到的 install 事件監聽器中的安裝。

Service Worker 導航預載入

如果啟用,導航預載入功能會在發出 fetch 請求後立即開始下載資源,並與 Service Worker 啟用並行。這確保了在導航到頁面時立即開始下載,而不是必須等待 Service Worker 啟用。這種延遲相對較少發生,但一旦發生就無法避免,並且可能很顯著。

首先必須在 Service Worker 啟用期間啟用該功能,使用registration.navigationPreload.enable()

js
self.addEventListener("activate", (event) => {
  event.waitUntil(self.registration?.navigationPreload.enable());
});

然後使用event.preloadResponsefetch 事件處理程式中等待預載入的資源完成下載。

繼續前面的示例,我們在快取檢查之後、如果快取不成功則從網路獲取之前插入程式碼以等待預載入的資源。

新流程是:

  1. 檢查快取
  2. 等待 event.preloadResponse,它作為 preloadResponsePromise 傳遞給 cacheFirst() 函式。如果返回結果,則快取它。
  3. 如果這些都沒有定義,那麼我們就會訪問網路。
js
const addResourcesToCache = async (resources) => {
  const cache = await caches.open("v1");
  await cache.addAll(resources);
};

const putInCache = async (request, response) => {
  const cache = await caches.open("v1");
  await cache.put(request, response);
};

const cacheFirst = async ({
  request,
  preloadResponsePromise,
  fallbackUrl,
  event,
}) => {
  // First try to get the resource from the cache
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }

  // Next try to use (and cache) the preloaded response, if it's there
  const preloadResponse = await preloadResponsePromise;
  if (preloadResponse) {
    console.info("using preload response", preloadResponse);
    event.waitUntil(putInCache(request, preloadResponse.clone()));
    return preloadResponse;
  }

  // Next try to get the resource from the network
  try {
    const responseFromNetwork = await fetch(request);
    // response may be used only once
    // we need to save clone to put one copy in cache
    // and serve second one
    event.waitUntil(putInCache(request, responseFromNetwork.clone()));
    return responseFromNetwork;
  } catch (error) {
    const fallbackResponse = await caches.match(fallbackUrl);
    if (fallbackResponse) {
      return fallbackResponse;
    }
    // when even the fallback response is not available,
    // there is nothing we can do, but we must always
    // return a Response object
    return new Response("Network error happened", {
      status: 408,
      headers: { "Content-Type": "text/plain" },
    });
  }
};

// Enable navigation preload
const enableNavigationPreload = async () => {
  if (self.registration.navigationPreload) {
    await self.registration.navigationPreload.enable();
  }
};

self.addEventListener("activate", (event) => {
  event.waitUntil(enableNavigationPreload());
});

self.addEventListener("install", (event) => {
  event.waitUntil(
    addResourcesToCache([
      "/",
      "/index.html",
      "/style.css",
      "/app.js",
      "/image-list.js",
      "/star-wars-logo.jpg",
      "/gallery/bountyHunters.jpg",
      "/gallery/myLittleVader.jpg",
      "/gallery/snowTroopers.jpg",
    ]),
  );
});

self.addEventListener("fetch", (event) => {
  event.respondWith(
    cacheFirst({
      request: event.request,
      preloadResponsePromise: event.preloadResponse,
      fallbackUrl: "/gallery/myLittleVader.jpg",
      event,
    }),
  );
});

請注意,在此示例中,我們下載並快取資源的相同資料,無論它是“正常”下載還是預載入。您也可以選擇在預載入時下載並快取不同的資源。有關更多資訊,請參閱NavigationPreloadManager > 自定義響應

更新 Service Worker

如果您的 Service Worker 以前已安裝,但重新整理或頁面載入時有新版本的 worker 可用,則新版本會在後臺安裝,但尚未啟用。它僅在不再有任何頁面載入仍在使用舊 Service Worker 時才啟用。一旦不再有此類頁面載入,新 Service Worker 就會啟用。

注意:可以使用Clients.claim()繞過此限制。

您需要將新 Service Worker 中的 install 事件監聽器更新為如下所示(請注意新的版本號):

js
const addResourcesToCache = async (resources) => {
  const cache = await caches.open("v2");
  await cache.addAll(resources);
};

self.addEventListener("install", (event) => {
  event.waitUntil(
    addResourcesToCache([
      "/",
      "/index.html",
      "/style.css",
      "/app.js",
      "/image-list.js",

      // …

      // include other new resources for the new version…
    ]),
  );
});

在 Service Worker 安裝期間,舊版本仍負責 fetches。新版本正在後臺安裝。我們將新快取命名為 v2,因此舊的 v1 快取不會受到干擾。

當沒有頁面使用舊版本時,新 worker 啟用並負責 fetches。

刪除舊快取

正如我們在上一節中看到的,當您將 Service Worker 更新到新版本時,您將在其 install 事件處理程式中建立一個新快取。當有受 worker 舊版本控制的開啟頁面時,您需要保留兩個快取,因為舊版本需要其版本的快取。您可以使用 activate 事件從以前的快取中刪除資料。

傳遞給 waitUntil() 的 Promise 將阻塞其他事件直到完成,因此您可以確信您的清理操作將在新的 Service Worker 上收到第一個 fetch 事件時完成。

js
const deleteCache = async (key) => {
  await caches.delete(key);
};

const deleteOldCaches = async () => {
  const cacheKeepList = ["v2"];
  const keyList = await caches.keys();
  const cachesToDelete = keyList.filter((key) => !cacheKeepList.includes(key));
  await Promise.all(cachesToDelete.map(deleteCache));
};

self.addEventListener("activate", (event) => {
  event.waitUntil(deleteOldCaches());
});

開發者工具

  • Chrome
  • Firefox
    • Firefox 工具欄自定義選項中提供的“忘記此網站”按鈕可用於清除 Service Worker 及其快取。
  • Edge

另見