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。

恭喜!