Service Worker 的生活

如果不了解服务工件的生命周期,就很难知道它们在做什么。它们的内部运作机制似乎不透明,甚至是任意的。请务必注意,与任何其他浏览器 API 一样,服务工件行为已明确定义和指定,可实现离线应用,同时还能促进更新,而不会中断用户体验。

在深入了解 Workbox 之前,请务必了解 Service Worker 生命周期,以便了解 Workbox 的运作方式。

定义字词

在深入了解服务工件生命周期之前,有必要定义一些与该生命周期运作方式相关的术语。

控制和范围

控制概念对于了解服务工件的工作原理至关重要。被描述为由 Service Worker 控制的网页是指允许 Service Worker 代表其拦截网络请求的网页。Service Worker 存在,并且能够在给定范围内为网页执行工作。

范围

服务工件的作用域取决于其在网络服务器上的位置。如果 Service Worker 在位于 /subdir/index.html 的网页上运行,并且位于 /subdir/sw.js,则 Service Worker 的范围为 /subdir/。如需了解作用域的实际运作方式,请查看以下示例:

  1. 前往 https://service-worker-scope-viewer.glitch.me/subdir/index.html。系统会显示一条消息,提示没有 Service Worker 在控制该网页。不过,该网页会从 https://service-worker-scope-viewer.glitch.me/subdir/sw.js 注册 Service Worker。
  2. 重新加载页面。 由于 Service Worker 已注册且目前处于活跃状态,因此它会控制网页。系统会显示一个表单,其中包含服务工件的范围、当前状态及其网址。注意:必须重新加载页面与作用域无关,而是与服务工件生命周期有关,我们稍后会对此进行说明。
  3. 现在,前往 https://service-worker-scope-viewer.glitch.me/index.html。 即使在此来源上注册了 Service Worker,系统仍会显示一条消息,提示当前没有 Service Worker。这是因为此网页不在注册的 Service Worker 的范围内。

作用域限制了 Service Worker 控制的网页。在此示例中,这意味着从 /subdir/sw.js 加载的服务工件只能控制位于 /subdir/ 或其子树中的网页。

以上是默认的范围限定方式,但您可以通过设置 Service-Worker-Allowed 响应标头以及将 scope 选项传递给 register 方法来替换允许的最大范围。

除非有充分的理由将 Service Worker 作用域限制为来源的一部分,否则请从网络服务器的根目录加载 Service Worker,以使其作用域尽可能广泛,并且无需担心 Service-Worker-Allowed 标头。这样对所有人来说都更简单。

客户

当我们说 Service Worker 在控制网页时,实际上是指它在控制客户端。客户端是指网址位于该服务工件范围内的任何打开的网页。具体而言,这些是 WindowClient 的实例。

新服务工件的生命周期

为了让服务工件能够控制网页,必须先使其“存在”。我们先来看看,如果为没有任何活跃的服务工件(SW)的网站部署全新的 SW,会发生什么情况。

注册

注册是 Service Worker 生命周期的初始步骤:

<!-- In index.html, for example: -->
<script>
  // Don't register the service worker
  // until the page has fully loaded
  window.addEventListener('load', () => {
    // Is service worker available?
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js').then(() => {
        console.log('Service worker registered!');
      }).catch((error) => {
        console.warn('Error registering service worker:');
        console.warn(error);
      });
    }
  });
</script>

此代码在主线程上运行,并执行以下操作:

  1. 由于用户首次访问网站时没有注册 Service Worker,因此请等待网页完全加载完毕,然后再注册 Service Worker。这样做有助于避免在服务工件预缓存任何内容时出现带宽争用。
  2. 虽然 Service Worker 得到了良好的支持,但快速检查有助于避免在不支持 Service Worker 的浏览器中出现错误。
  3. 当页面完全加载后,如果支持 Service Worker,请注册 /sw.js

以下是一些需要了解的关键事项:

  • Service Worker 仅通过 HTTPS 或 localhost 提供
  • 如果服务工件的代码包含语法错误,注册会失败,并且系统会舍弃该服务工件。
  • 提醒:Service Worker 在某个作用域内运行。在这里,作用域是整个来源,因为它是从根目录加载的。
  • 注册开始时,服务工件状态会设为 'installing'

注册完成后,系统便会开始安装。

安装

服务工件会在注册后触发其 install 事件。每个服务工件只会调用一次 install,并且在更新之前不会再次触发。您可以使用 addEventListener 在 worker 的作用域中注册 install 事件的回调:

// /sw.js
self.addEventListener('install', (event) => {
  const cacheKey = 'MyFancyCacheName_v1';

  event.waitUntil(caches.open(cacheKey).then((cache) => {
    // Add all the assets in the array to the 'MyFancyCacheName_v1'
    // `Cache` instance for later use.
    return cache.addAll([
      '/css/global.bc7b80b7.css',
      '/css/home.fe5d0b23.css',
      '/js/home.d3cc4ba4.js',
      '/js/jquery.43ca4933.js'
    ]);
  }));
});

这会创建一个新的 Cache 实例并预缓存资源。 我们稍后有机会再讨论预缓存,因此现在我们先重点介绍 event.waitUntil 的作用。event.waitUntil 会接受一个 Promise,并等待该 Promise 解析完毕。在此示例中,该 promise 会执行两项异步操作:

  1. 创建一个名为 'MyFancyCache_v1' 的新 Cache 实例。
  2. 创建缓存后,系统会使用其异步 addAll 方法预缓存一组资源网址。

如果传递给 event.waitUntil 的 promise 被拒绝,安装将失败。如果发生这种情况,系统会舍弃该服务工件。

如果这些 Promise 解析,安装将成功,并且服务工件的状态将更改为 'installed',然后激活。

激活

如果注册和安装成功,服务工件会激活,其状态变为 'activating'。您可以在服务工件的 activate 事件中执行激活期间的工作。此事件中的典型任务是修剪旧缓存,但对于全新的服务工件,这目前并不相关,我们将在讨论服务工件更新时对此进行详细介绍。

对于新的服务工件,activate 会在 install 成功后立即触发。激活完成后,服务工件的状态变为 'activated'。请注意,默认情况下,新 Service Worker 不会在下次导航或页面刷新之前开始控制页面。

处理 Service Worker 更新

部署第一个服务工件后,您可能需要稍后对其进行更新。例如,如果请求处理或预缓存逻辑发生更改,则可能需要更新。

更新时间

在以下情况下,浏览器会检查服务工件的更新:

更新方式

了解浏览器何时更新服务工件很重要,但“如何”更新也很重要。假设 Service Worker 的网址或作用域保持不变,则当前安装的 Service Worker 仅在其内容发生更改时才会更新为新版本。

浏览器会通过以下几种方式检测更改:

  • importScripts 请求对脚本进行的任何逐字节更改(如果适用)。
  • 服务工件的顶级代码中的任何更改,都会影响浏览器为其生成的指纹。

浏览器在这里要完成大量繁重工作。 为确保浏览器拥有可靠检测服务工件内容更改所需的一切信息,请勿指示 HTTP 缓存保留该内容,也不要更改其文件名。当浏览器导航到服务工件作用域内的新页面时,会自动执行更新检查。

手动触发更新检查

关于更新,注册逻辑通常不应更改。不过,如果网站上的会话是长时有效的,则可能是一个例外情况。 在导航请求很少的单页应用中,可能会发生这种情况,因为应用通常会在生命周期开始时遇到一个导航请求。在这种情况下,可以在主线程中触发手动更新:

navigator.serviceWorker.ready.then((registration) => {
  registration.update();
});

对于传统网站,或者在用户会话不持久的任何情况下,可能无需触发手动更新。

安装

使用捆绑器生成静态资源时,这些资源的名称中会包含哈希值,例如 framework.3defa9d2.js。假设其中一些资源已预缓存,以供稍后离线访问。这需要更新服务工件,以预缓存更新后的资源:

self.addEventListener('install', (event) => {
  const cacheKey = 'MyFancyCacheName_v2';

  event.waitUntil(caches.open(cacheKey).then((cache) => {
    // Add all the assets in the array to the 'MyFancyCacheName_v2'
    // `Cache` instance for later use.
    return cache.addAll([
      '/css/global.ced4aef2.css',
      '/css/home.cbe409ad.css',
      '/js/home.109defa4.js',
      '/js/jquery.38caf32d.js'
    ]);
  }));
});

与前面的第一个 install 事件示例相比,有两点不同:

  1. 系统会创建一个键为 'MyFancyCacheName_v2' 的新 Cache 实例。
  2. 预缓存的资源名称已更改。

请注意,更新后的服务工件会与之前的服务工件一起安装。这意味着,旧版服务工件仍会控制所有打开的页面,而新版服务工件在安装后会进入等待状态,直到被激活。

默认情况下,当旧服务工件不再控制任何客户端时,新服务工件将激活。当相关网站的所有打开标签页都关闭时,就会发生这种情况。

激活

更新后的服务工件安装完毕且等待阶段结束后,该服务工件会激活,旧服务工件会被舍弃。在更新后的服务工件的 activate 事件中执行的一项常见任务是修剪旧缓存。使用 caches.keys 获取所有打开的 Cache 实例的键,然后使用 caches.delete 删除不在定义的许可名单中的缓存,以移除旧缓存:

self.addEventListener('activate', (event) => {
  // Specify allowed cache keys
  const cacheAllowList = ['MyFancyCacheName_v2'];

  // Get all the currently active `Cache` instances.
  event.waitUntil(caches.keys().then((keys) => {
    // Delete all caches that aren't in the allow list:
    return Promise.all(keys.map((key) => {
      if (!cacheAllowList.includes(key)) {
        return caches.delete(key);
      }
    }));
  }));
});

旧缓存不会自行整理。我们需要自行执行此操作,否则可能会超出存储空间配额。由于第一个服务工件的 'MyFancyCacheName_v1' 已过时,因此缓存许可名单会更新为指定 'MyFancyCacheName_v2',这会删除名称不同的缓存。

移除旧缓存后,activate 事件将结束。此时,新 Service Worker 将接管网页,最终取代旧 Service Worker!

生命周期永不停息

无论是使用 Workbox 来处理服务工件部署和更新,还是直接使用 Service Worker API,了解服务工件生命周期都是有益的。有了这样的理解,服务工件行为应该会显得更合理,而不是神秘。

如果您有兴趣深入了解此主题,不妨参阅 Jake Archibald 撰写的这篇文章。服务生命周期的整个过程包含许多细微之处,但这些细微之处是可以了解的,在使用 Workbox 时,这些知识将发挥重要作用。