CycleTracker:Service Workers

到目前为止,我们已经为 CycleTracker 编写了 HTML、CSS 和 JavaScript。我们添加了一个清单文件,其中定义了颜色、图标、URL 和其他应用功能。我们有一个可用的 PWA!但它尚无法离线工作。在本节中,我们将编写将我们的功能齐全的 Web 应用程序转换为可作为独立应用程序分发并在离线状态下无缝工作的 PWA 所需的 JavaScript。

如果您尚未这样做,请复制 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 中的错误已修复、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,并且会发生安装事件。

当应用程序首次使用或浏览器检测到新版本的 Service Worker 时,就会发生 install 事件。当较旧的 Service Worker 被新的 Service Worker 替换时,较旧的 Service Worker 将用作 PWA 的 Service Worker,直到新的 Service Worker 被激活。

仅在安全上下文中可用,WorkerGlobalScope.caches 属性返回与当前上下文关联的 CacheStorage 对象。CacheStorage.open() 方法返回一个 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"
      ]);
    })()
  );
});

任务

添加一个安装事件侦听器,该侦听器将 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,它会解析为一个数组,该数组包含与所有命名 Cache 对象对应的字符串,顺序为其创建顺序。

我们使用 Promise.all() 方法遍历该命名缓存 Promise 列表。all() 方法将可迭代 Promise 列表作为输入,并返回单个 Promise。对于命名缓存列表中的每个名称,检查缓存是否为当前活动缓存。如果不是,则使用 Cachedelete() 方法将其删除。

最后一行,await clients.claim() 使用 claim() 方法(来自 Clients 接口),使我们的 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);
          }
        }),
      );
      await clients.claim();
    })(),
  );
});

任务

将上述 activate 事件监听器添加到 sw.js 文件中。

fetch 事件

我们可以利用 fetch 事件,防止已安装的 PWA 在用户在线时发出请求。监听 fetch 事件可以拦截所有请求,并使用缓存的响应进行响应,而不是访问网络。大多数应用程序不需要这种行为。事实上,许多商业模式希望用户定期向服务器发送请求以进行跟踪和营销目的。因此,虽然拦截请求对于某些情况来说可能是一种反模式,但为了提高我们的 CycleTracker 应用的隐私性,我们不希望应用发出不必要的服务器请求。

由于我们的 PWA 由单个页面组成,对于页面导航请求,我们返回到 index.html 主页。没有其他页面,我们也不希望访问服务器。如果 Fetch API 的 Request 只读 mode 属性为 navigate,表示它正在查找网页,我们使用 FetchEvent 的 respondWith() 方法阻止浏览器的默认 fetch 处理,提供我们自己的响应 Promise,并使用 caches.match() 方法。

对于所有其他请求模式,我们像在 安装事件响应 中所做的那样打开缓存,而是将事件请求传递给相同的 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);
          }
        }),
      );
      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 的版本,方法是 检查 Application 工具(或 Sources 工具)中缓存的名称

注意:当对任何应用程序资源(包括 CSS、HTML 和 JS 代码以及图像资产)进行更改时,更新 VERSION 非常重要。版本号或对 Service Worker 文件的任何更改是强制更新用户应用的唯一方法。

注册 Service Worker

现在我们的 Service Worker 脚本已完成,我们需要注册 Service Worker。

我们首先使用 特性检测 检查浏览器是否支持 Service Worker API,以检查全局 navigator 对象上是否存在 serviceWorker 属性。

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

如果支持该属性,我们就可以使用 Service Worker API 的 ServiceWorkerContainer 接口的 register() 方法。

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

虽然以上内容足以满足 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)或操作系统(Samsung、iOS)设置,在高级设置下找到浏览器(iOS)或网站数据(Android、Samsung)站点设置,并删除 CycleTracker 的数据,然后再重新加载页面。

使用开发者工具

您可能不希望在每次保存时都更新版本号。在您准备好将 PWA 的新版本发布到生产环境并向所有人提供 PWA 的新版本之前,您可以取消注册 Service Worker,而不是在保存时更改版本号。

您可以通过单击 浏览器开发者工具 中的“取消注册”按钮来取消注册 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 的 Application 面板中的 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 应用程序。然后,我们添加了两个功能——清单文件和 Service Worker——将其转换为 PWA。如果您想与他人共享您的应用,请通过安全连接提供它。或者,如果您只想自己使用周期跟踪器,请 创建一个本地开发环境安装 PWA,然后尽情享受!安装后,您不再需要运行 localhost。

恭喜!