CycleTracker:Service Worker

到目前為止,我們已經為 CycleTracker 編寫了 HTML、CSS 和 JavaScript。我們添加了一個清單檔案,其中定義了顏色、圖示、URL 和其他應用功能。我們已經有了一個可用的 PWA!但它還不能離線工作。在本節中,我們將編寫所需的 JavaScript,將我們功能完備的 Web 應用轉換為一個可以作為獨立應用分發並能無縫離線工作的 PWA。

如果您尚未完成,請複製 HTMLCSSJavaScript清單 JSON 檔案。將它們分別儲存為 index.htmlstyle.cssapp.jscycletracker.json

在本節中,我們將建立 sw.js,即 Service Worker 指令碼,它將把我們的 Web 應用轉換為 PWA。我們已經有一個 JavaScript 檔案;HTML 檔案的最後一行呼叫了 app.js。這個 JavaScript 提供了標準 Web 應用功能的所有功能。我們不會像使用 <script>src 屬性呼叫 app.js 檔案那樣呼叫 sw.js 檔案,而是透過註冊 Service Worker 在 Web 應用及其 Service Worker 之間建立關係。

在本課程結束時,您將擁有一個功能完備的 PWA;一個漸進增強的 Web 應用,可以完全安裝,甚至在使用者離線時也能正常工作。

Service Worker 的職責

Service Worker 是使應用離線工作同時確保應用始終保持最新狀態的關鍵。為了實現這一點,Service Worker 應包括以下內容:

  • 版本號(或其他識別符號)。
  • 要快取的資源列表。
  • 快取版本名稱。

Service Worker 還負責:

  • 在安裝應用時安裝快取。
  • 根據需要更新自身和其他應用檔案。
  • 刪除不再使用的快取檔案。

我們透過響應三個 Service Worker 事件來完成這些任務,包括:

版本號

一旦 PWA 安裝在使用者的機器上,唯一能通知瀏覽器有更新檔案需要檢索的方法是 Service Worker 發生變化。如果對任何其他 PWA 資源進行了更改 — 如果 HTML 更新了,CSS 中修復了 bug,app.js 中添加了函式,影像被壓縮以減小檔案大小等等 — 已安裝 PWA 的 Service Worker 將不知道它需要下載更新的資源。只有當 Service Worker 以任何方式被修改時,PWA 才會知道可能需要更新快取;這是 Service Worker 的任務。

雖然從技術上講,更改任何字元可能就足夠了,但 PWA 的最佳實踐是建立一個版本號常量,並按順序更新該常量以指示檔案更新。更新版本號(或日期)可以為 Service Worker 提供正式的編輯,即使 Service Worker 本身沒有更改其他任何內容,併為開發人員提供了一種識別應用版本的方法。

任務

透過包含版本號來啟動 JavaScript 檔案

js
const VERSION = "v1";

將檔案儲存為 sw.js

離線資源列表

為了獲得良好的離線體驗,快取檔案列表應包含 PWA 離線體驗中使用的所有資源。雖然清單檔案可能列出了各種大小的眾多圖示,但應用快取只需要包含應用在離線模式下使用的資產。

js
const APP_STATIC_RESOURCES = [
  "/",
  "/index.html",
  "/style.css",
  "/app.js",
  "/icon-512x512.png",
];

您無需在列表中包含所有不同作業系統和裝置使用的各種圖示。但請務必包含應用中使用的任何影像,包括可能在應用載入緩慢時可見的啟動頁面中使用的資產,或在任何“您需要連線網際網路才能獲得完整體驗”型別的頁面中使用的資產。

不要在要快取的資源列表中包含 Service Worker 檔案。

任務

將 CycleTracker PWA 要快取的資源列表新增到 sw.js

解決方案示例

我們包含了本教程其他部分建立的 CycleTracker 離線執行時所需的靜態資源。我們當前的 sw.js 檔案是:

js
const VERSION = "v1";

const APP_STATIC_RESOURCES = [
  "/",
  "/index.html",
  "/style.css",
  "/app.js",
  "/cycletracker.json",
  "/icons/wheel.svg",
];

我們包含了 wheel.svg 圖示,儘管我們當前的應用沒有使用它,以防您正在增強 PWA UI,例如在沒有周期資料時顯示徽標。

應用快取名稱

我們有一個版本號和需要快取的檔案。在快取檔案之前,我們需要建立一個快取名稱,用於儲存應用的靜態資源。這個快取名稱應該進行版本控制,以確保在應用更新時,將建立一個新快取並刪除舊快取。

任務

使用 VERSION 號來建立一個帶版本號的 CACHE_NAME,並將其作為常量新增到 sw.js

解決方案示例

我們將快取命名為 period-tracker-,並附加當前的 VERSION。由於常量宣告在一行上,為了更好的可讀性,我們將其放在資源常量陣列之前。

js
const VERSION = "v1";
const CACHE_NAME = `period-tracker-${VERSION}`;

const APP_STATIC_RESOURCES = [
  // …
];

我們已成功聲明瞭常量;一個唯一的識別符號,作為陣列的離線資源列表,以及每次識別符號更新時都會更改的應用快取名稱。現在讓我們專注於安裝、更新和刪除未使用的快取資源。

PWA 安裝時儲存快取

當用戶安裝或僅僅訪問帶有 Service Worker 的網站時,Service Worker 作用域中會觸發一個 install 事件。我們希望監聽此事件,在安裝時用 PWA 的靜態資源填充快取。每次 Service Worker 版本更新時,瀏覽器都會安裝新的 Service Worker,併發生安裝事件。

install 事件在應用首次使用時發生,或者當瀏覽器檢測到新版本的 Service Worker 時發生。當舊的 Service Worker 被新的替換時,舊的 Service Worker 將作為 PWA 的 Service Worker 使用,直到新的 Service Worker 被啟用。

僅在安全上下文中可用,WorkerGlobalScope.caches 屬性返回一個與當前上下文關聯的 CacheStorage 物件。CacheStorage.open() 方法返回一個 Promise,該 Promise 解析為與作為引數傳入的快取名稱匹配的 Cache 物件。

Cache.addAll() 方法將一個 URL 陣列作為引數,檢索它們,然後將響應新增到給定的快取中。ExtendableEvent.waitUntil() 方法告訴瀏覽器工作正在進行,直到 Promise 解決,並且如果它希望該工作完成,則不應終止 Service Worker。雖然瀏覽器負責在必要時執行和終止 Service Worker,但 waitUntil 方法是向瀏覽器發出的請求,要求在執行任務時不要終止 Service Worker。

js
self.addEventListener("install", (e) => {
  e.waitUntil(
    (async () => {
      const cache = await caches.open("cacheName_identifier");
      cache.addAll(["/", "/index.html", "/style.css", "/app.js"]);
    })(),
  );
});

任務

新增一個 install 事件監聽器,該監聽器將 APP_STATIC_RESOURCES 中列出的檔案檢索並存儲到名為 CACHE_NAME 的快取中。

解決方案示例

js
self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      cache.addAll(APP_STATIC_RESOURCES);
    })(),
  );
});

更新 PWA 並刪除舊快取

如前所述,當現有 Service Worker 被新的 Service Worker 替換時,現有 Service Worker 將作為 PWA 的 Service Worker 使用,直到新的 Service Worker 啟用。我們使用 activate 事件刪除舊快取以避免空間不足。我們遍歷命名的 Cache 物件,刪除除了當前快取之外的所有快取,然後將 Service Worker 設定為 PWA 的 controller

我們監聽當前 Service Worker 的全域性作用域 activate 事件。

我們獲取現有命名快取的名稱。我們使用 CacheStorage.keys() 方法(再次透過 WorkerGlobalScope.caches 屬性訪問 CacheStorage),該方法返回一個 Promise,該 Promise 解析為一個數組,其中包含所有命名 Cache 物件的字串,按其建立順序排列。

我們使用 Promise.all() 方法遍歷命名快取 Promise 列表。all() 方法將可迭代 Promise 列表作為輸入,並返回一個單一的 Promise。對於命名快取列表中的每個名稱,檢查快取是否是當前活動的快取。如果不是,則使用 Cachedelete() 方法將其刪除。

最後一行,await clients.claim() 使用 Clients 介面的 claim() 方法,使我們的 Service Worker 能夠將自身設定為我們客戶端的控制器;“客戶端”指的是 PWA 的執行例項。claim() 方法使 Service Worker 能夠“宣告控制”其作用域內的所有客戶端。這樣,在同一作用域內載入的客戶端無需重新載入。

js
self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      const names = await caches.keys();
      await Promise.all(
        names.map((name) => {
          if (name !== CACHE_NAME) {
            return caches.delete(name);
          }
          return undefined;
        }),
      );
      await clients.claim();
    })(),
  );
});

任務

將上述 activate 事件監聽器新增到您的 sw.js 檔案中。

fetch 事件

我們可以利用 fetch 事件,以防止已安裝的 PWA 在使用者線上時發出請求。監聽 fetch 事件使得攔截所有請求並用快取響應代替網路請求成為可能。大多數應用程式不需要這種行為。事實上,許多商業模式希望使用者定期發出伺服器請求以進行跟蹤和營銷。因此,儘管攔截請求可能對某些人來說是一種反模式,但為了提高我們的 CycleTracker 應用程式的隱私性,我們不希望應用程式發出不必要的伺服器請求。

由於我們的 PWA 由一個單一頁面組成,對於頁面導航請求,我們返回 index.html 主頁。沒有其他頁面,我們也不希望訪問伺服器。如果 Fetch API 的 Request 只讀 mode 屬性是 navigate,這意味著它正在尋找網頁,我們使用 FetchEvent 的 respondWith() 方法來阻止瀏覽器的預設 fetch 處理,透過使用 caches.match() 方法提供我們自己的響應 Promise。

對於所有其他請求模式,我們像 安裝事件響應 中那樣開啟快取,而是將事件請求傳遞給相同的 match() 方法。它檢查請求是否是儲存的 Response 的鍵。如果是,它返回快取的響應。如果不是,我們返回 404 狀態 作為響應。

使用 Response() 建構函式傳遞一個 null 主體和 status: 404 作為選項,並不意味著我們的 PWA 中存在錯誤。相反,我們需要的一切都應該已經在快取中,如果不在,我們也不會去伺服器解決這個非問題。

js
self.addEventListener("fetch", (event) => {
  // when seeking an HTML page
  if (event.request.mode === "navigate") {
    // Return to the index.html page
    event.respondWith(caches.match("/"));
    return;
  }

  // For every other request type
  event.respondWith(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      const cachedResponse = await cache.match(event.request.url);
      if (cachedResponse) {
        // Return the cached response if it's available.
        return cachedResponse;
      }
      // Respond with a HTTP 404 response status.
      return new Response(null, { status: 404 });
    })(),
  );
});

完整的 Service Worker 檔案

您的 sw.js 檔案應與以下 JavaScript 類似。請注意,當更新 APP_STATIC_RESOURCES 陣列中列出的任何資源時,此 Service Worker 中唯一必須更新的常量或函式是 VERSION 的值。

js
// The version of the cache.
const VERSION = "v1";

// The name of the cache
const CACHE_NAME = `period-tracker-${VERSION}`;

// The static resources that the app needs to function.
const APP_STATIC_RESOURCES = [
  "/",
  "/index.html",
  "/app.js",
  "/style.css",
  "/icons/wheel.svg",
];

// On install, cache the static resources
self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      cache.addAll(APP_STATIC_RESOURCES);
    })(),
  );
});

// delete old caches on activate
self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      const names = await caches.keys();
      await Promise.all(
        names.map((name) => {
          if (name !== CACHE_NAME) {
            return caches.delete(name);
          }
          return undefined;
        }),
      );
      await clients.claim();
    })(),
  );
});

// On fetch, intercept server requests
// and respond with cached responses instead of going to network
self.addEventListener("fetch", (event) => {
  // As a single page app, direct app to always go to cached home page.
  if (event.request.mode === "navigate") {
    event.respondWith(caches.match("/"));
    return;
  }

  // For all other requests, go to the cache first, and then the network.
  event.respondWith(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      const cachedResponse = await cache.match(event.request.url);
      if (cachedResponse) {
        // Return the cached response if it's available.
        return cachedResponse;
      }
      // If resource isn't in the cache, return a 404.
      return new Response(null, { status: 404 });
    })(),
  );
});

更新 Service Worker 時,VERSION 常量不需要更新,因為 Service Worker 指令碼內容本身的任何更改都會觸發瀏覽器安裝新的 Service Worker。但是,更新版本號是一個好習慣,因為它使開發人員(包括您自己)更容易檢視當前瀏覽器中執行的 Service Worker 版本,透過 在“應用”工具中檢查快取名稱(或“源”工具)。

注意:當對任何應用程式資源(包括 CSS、HTML 和 JS 程式碼以及影像資源)進行更改時,更新 VERSION 至關重要。版本號,或 Service Worker 檔案的任何更改,是強制為使用者更新應用程式的唯一方法。

註冊 Service Worker

現在我們的 Service Worker 指令碼已完成,我們需要註冊 Service Worker。

我們首先透過使用 特性檢測 全域性 navigator 物件上是否存在 serviceWorker 屬性來檢查瀏覽器是否支援 Service Worker API

js
// Does "serviceWorker" exist
if ("serviceWorker" in navigator) {
  // If yes, we register the service worker
}

如果支援該屬性,我們就可以使用 Service Worker API 的 ServiceWorkerContainer 介面的 register() 方法。

js
if ("serviceWorker" in navigator) {
  // Register the app's service worker
  // Passing the filename where that worker is defined.
  navigator.serviceWorker.register("sw.js");
}

雖然上述內容足以滿足 CycleTracker 應用的需求,但 register() 方法確實返回一個 Promise,該 Promise 解析為 ServiceWorkerRegistration 物件。對於更健壯的應用程式,請檢查註冊錯誤:

js
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("sw.js").then(
    (registration) => {
      console.log("Service worker registration successful:", registration);
    },
    (error) => {
      console.error(`Service worker registration failed: ${error}`);
    },
  );
} else {
  console.error("Service workers are not supported.");
}

任務

開啟 index.html 並在包含 app.js 的指令碼之後、閉合 </body> 標籤之前新增以下 <script>

html
<!-- Register the app's service worker. -->
<script>
  if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("sw.js");
  }
</script>

您可以嘗試功能齊全的 CycleTracker 經期跟蹤 Web 應用,並在 GitHub 上檢視 Web 應用原始碼。是的,它確實有效,現在,它正式成為一個 PWA!

除錯 Service Worker

由於我們設定 Service Worker 的方式,一旦它被註冊,每個請求都將從快取中獲取,而不是載入新內容。在開發時,您將頻繁編輯程式碼。您可能希望定期在瀏覽器中測試您的編輯;很可能是每次儲存後。

透過更新版本號並進行硬重置

要獲取新快取,您可以更改 版本號,然後進行硬瀏覽器重新整理。進行硬重新整理的方式取決於瀏覽器和作業系統:

  • 在 Windows 上:Ctrl+F5、Shift+F5 或 Ctrl+Shift+R。
  • 在 macOS 上:Shift+Command+R。
  • macOS 上的 Safari:Option+Command+E 清空快取,然後 Option+Command+R。
  • 在移動裝置上:轉到瀏覽器(Android)或作業系統(三星、iOS)設定,在高階設定下找到瀏覽器(iOS)或網站資料(Android、三星)網站設定,刪除 CycleTracker 的資料,然後重新載入頁面。

使用開發者工具

您可能不希望每次儲存都更新版本號。在您準備將新版本的 PWA 釋出到生產環境並向所有人提供新版本 PWA 之前,您可以登出 Service Worker,而不是在儲存時更改版本號。

您可以透過單擊瀏覽器開發人員工具中的unregister按鈕來登出 Service Worker。硬重新整理頁面將重新註冊 Service Worker 並建立新快取。

Firefox developer tools application panel with a stopped service worker and an unregister button

在某些開發人員工具中,您可以手動登出 Service Worker,或者您可以選擇 Service Worker 的“重新載入時更新”選項,該選項會使開發人員工具在每次重新載入時重置並重新啟用 Service Worker,只要開發人員工具是開啟的。還有一個選項可以繞過 Service Worker 並從網路載入資源。此面板包括本教程未涵蓋的功能,但隨著您建立更高階的 PWA(包括同步推送,這兩個都包含在離線和後臺操作指南中),這些功能將很有幫助。

Edge developer tools showing the application panel set to a service worker

DevTools 應用程式面板中的 Service Worker 視窗提供了一個連結,可以訪問一個彈出視窗,其中包含瀏覽器中所有已註冊 Service Worker 的列表;而不僅僅是當前選項卡中開啟的應用程式的 Service Worker。每個 Service Worker 工作人員列表都有按鈕來停止、啟動或登出該單個 Service Worker。

Two service workers exist at localhost:8080. They can be unregistered from the list of service workers

換句話說,當您正在開發 PWA 時,您不必為每個應用檢視更新版本號。但請記住,當您完成所有更改後,在分發更新版本的 PWA 之前,請更新 Service Worker 的 VERSION 值。如果您忘記了,所有已經安裝您的應用或甚至在未安裝的情況下訪問您的線上 PWA 的使用者,都將無法看到您的更改!

我們完成了!

PWA 的核心是一個可以安裝的 Web 應用,並且透過漸進增強使其能夠離線工作。我們建立了一個功能完備的 Web 應用。然後,我們添加了將它轉換為 PWA 所需的兩個功能——清單檔案和 Service Worker。如果您想與他人分享您的應用,請透過安全連線使其可用。或者,如果您只想自己使用週期追蹤器,請建立本地開發環境安裝 PWA,然後享受吧!一旦安裝,您就不再需要執行 localhost。

恭喜!