Service Worker 的生活

如果不了解 Service Worker 的生命周期,就很难知道 Service Worker 的生命周期。它们的内部运作方式看起来不透明,甚至有些随意。 请记住一点,与任何其他浏览器 API 一样,Service Worker 的行为是明确定义和指定的,使离线应用成为可能,同时在不影响用户体验的情况下加快更新速度。

在深入了解 Workbox 之前,了解 Service Worker 生命周期很重要,这样 Workbox 的工作才有意义。

定义术语

在进入 Service Worker 生命周期之前,有必要围绕该生命周期的运行方式定义一些术语。

控制力和范围

控制这一概念对于理解 Service Worker 的工作原理至关重要。被描述为由 Service Worker 控制的页面是一个允许 Service Worker 代表其拦截网络请求的页面。Service Worker 存在并且能够为给定作用域内的页面执行工作。

范围

Service Worker 的作用域由其在 Web 服务器上的位置决定。如果 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 已注册且当前处于活跃状态,因此它控制着页面。包含 Service Worker 的范围、当前状态及其网址的表单将可见。注意:必须重新加载页面与作用域无关,而与 Service Worker 生命周期无关,稍后将对此进行说明。
  3. 现在,前往 https://service-worker-scope-viewer.glitch.me/index.html。 即使已在此源上注册了 Service Worker,仍然会有消息表明当前没有 Service Worker。这是因为,此页面不在已注册 Service Worker 的范围内。

范围限制了 Service Worker 可以控制的页面。在此示例中,这意味着从 /subdir/sw.js 加载的 Service Worker 只能控制位于 /subdir/ 或其子树中的页面。

以上介绍了范围在默认情况下的工作原理,但可以通过设置 Service-Worker-Allowed 响应标头以及将 scope 选项传递给 register 方法来替换允许的最大范围。

除非有充分的理由将 Service Worker 的作用域限定为某个源的子集,否则请从 Web 服务器的根目录加载 Service Worker,使其作用域尽可能广,而无需担心 Service-Worker-Allowed 标头。这对每个人来说就简单得多了。

客户

当我们说 Service Worker 正在控制页面时,它实际上是在控制一个客户端。客户端是指网址处于该 Service Worker 作用域内的任何打开的页面。具体而言,这些是 WindowClient 的实例。

新 Service Worker 的生命周期

为了让 Service Worker 能够控制页面,对页面来说,它必须先存在。 我们先来看看在为没有活跃 Service Worker 的网站部署全新的 Service Worker 时会发生什么情况。

注册

注册是 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。如果 Service Worker 预缓存任何内容,则可避免带宽争用。
  2. 虽然 Service Worker 得到了很好的支持,但快速检查有助于避免在不受支持的浏览器中出现错误。
  3. 当页面完全加载时,如果支持 Service Worker,请注册 /sw.js

您需要了解以下重要事项:

  • Service Worker 仅通过 HTTPS 或 localhost 提供
  • 如果 Service Worker 的内容包含语法错误,则注册会失败,Service Worker 会被舍弃。
  • 提醒:Service Worker 在作用域内运行。此处的作用域是整个源,因为它是从根目录加载的。
  • 注册开始时,Service Worker 的状态会设置为 'installing'

注册完成后,将开始安装。

安装

Service Worker 会在注册后触发其 install 事件。每个 Service Worker 只能调用 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 被拒绝,安装将会失败。如果发生这种情况,Service Worker 将被舍弃。

如果 promise 执行 resolve,安装会成功,并且 Service Worker 的状态将更改为 'installed',然后激活。

提升活跃度

如果注册和安装成功,Service Worker 就会激活,并且其状态会变为 'activating'。可以在 Service Worker 的 activate 事件激活期间完成工作。此事件中的典型任务是删减旧缓存,但对于全新的 Service Worker,这个时间目前无关紧要,我们将在我们谈论 Service Worker 更新时加以扩展。

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

处理 Service Worker 更新

部署第一个 Service Worker 后,可能需要稍后进行更新。例如,如果请求处理或预缓存逻辑发生变化,则可能需要进行更新。

何时更新

在以下情况下,浏览器将检查 Service Worker 是否有更新:

  • 用户导航到 Service Worker 的作用域内的页面。
  • 使用与当前已安装的 Service Worker 不同的网址调用 navigator.serviceWorker.register(),但不要更改 Service Worker 的网址!
  • 使用与已安装的 Service Worker 相同的网址调用 navigator.serviceWorker.register(),但范围不同。同样,为避免出现这种情况,请尽可能将作用域限定在源站的根部。
  • 在过去 24 小时内触发了 'push''sync' 等事件,但暂时先不用担心这些事件。

如何进行更新

了解浏览器何时更新 Service Worker 非常重要,但了解“方式”同样重要。假设 Service Worker 的网址或范围保持不变,则当前安装的 Service Worker 只有在其内容发生更改时才会更新到新版本。

浏览器通过以下两种方式检测变化:

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

此时,浏览器会完成许多繁重的工作。 为了确保浏览器具备可靠检测 Service Worker 内容的更改所需的全部信息,不要让 HTTP 缓存保留它,也不要更改其文件名。当导航到 Service Worker 作用域内的新页面时,浏览器会自动执行更新检查。

手动触发更新检查

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

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

对于传统网站,或者在用户会话不是长期存在的任何情况下,可能不需要触发手动更新。

安装

使用打包器生成静态资源时,这些资源的名称中会包含哈希,例如 framework.3defa9d2.js。假设其中一些资产已预缓存,以便稍后离线访问。这将需要更新 Service Worker 以预缓存更新后的资源:

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. 预缓存的资产名称已更改。

有一点需要注意,更新后的 Service Worker 会与上一个 Service Worker 一起安装。这意味着旧 Service Worker 仍控制着所有打开的页面,在安装后,新 Service Worker 将进入等待状态,直到其被激活。

默认情况下,当旧 Service Worker 没有控制任何客户端时,新 Service Worker 将激活。当相关网站上所有打开的标签页都已关闭时,就会出现这种情况。

提升活跃度

在安装更新的 Service Worker 并且等待阶段结束时,它会激活并舍弃旧的 Service Worker。在更新的 Service Worker 的 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);
      }
    }));
  }));
});

旧缓存无法自行整理。我们需要自己执行此操作,否则可能会超出存储空间配额。由于第一个 Service Worker 中的 'MyFancyCacheName_v1' 已过期,缓存允许列表会更新以指定 'MyFancyCacheName_v2',这会删除具有不同名称的缓存。

移除旧缓存后,activate 事件将完成。此时,新 Service Worker 将控制页面,最终取代旧 Service Worker!

生命周期永远不变

无论 Workbox 是用于处理 Service Worker 的部署和更新,还是直接使用 Service Worker API,了解 Service Worker 生命周期都很有意义。有了这种理解,Service Worker 的行为应该看起来比神秘更合乎逻辑。

如果您有意深入了解这一主题,不妨参阅 Jake Archibald 撰写的这篇文章。 关于服务生命周期的整个流程有很多细微差别,但这是可知的,并且在使用 Workbox 时,这些知识将大有裨益。