Service Worker 生命周期

Jake Archibald
Jake Archibald

服务工件的生命周期是其最复杂的部分。如果您不知道它在尝试做什么以及有哪些好处,就会觉得它在与您对抗。不过,了解其运作方式后,您就可以将 Web 和原生模式的优势相结合,为用户提供无缝且不显眼的更新。

本文将深入探讨该功能,但每个部分开头的项目符号已涵盖您需要了解的大部分内容。

intent

生命周期的目的是:

  • 实现离线优先。
  • 允许新服务工件在不中断当前服务工件的情况下做好准备。
  • 确保在整个过程中,网页都在同一 Service Worker(或无 Service Worker)的控制下。
  • 确保一次只运行一个版本的网站。

最后一个非常重要。如果没有服务工件,用户可以将一个标签页加载到您的网站,然后稍后再打开另一个标签页。这可能会导致您的网站同时运行两个版本。有时这样做没问题,但如果您要处理存储空间,很容易就会出现两个标签页对共享存储空间的管理方式有截然不同的看法。这可能会导致错误,更糟糕的是,导致数据丢失。

第一个 Service Worker

简而言之:

  • install 事件是服务工件收到的第一个事件,并且只会发生一次。
  • 传递给 installEvent.waitUntil() 的 promise 会指示安装时长以及安装是否成功或失败。
  • 服务工件只有在成功完成安装并变为“活跃”状态后,才会收到 fetchpush 等事件。
  • 默认情况下,除非网页请求本身通过了 Service Worker,否则网页的提取操作不会通过 Service Worker。因此,您需要刷新页面才能看到服务工件的效果。
  • clients.claim() 可以替换此默认值,并控制非受控页面。

以以下 HTML 为例:

<!DOCTYPE html>
An image will appear here in 3 seconds:
<script>
  navigator.serviceWorker.register('/sw.js')
    .then(reg => console.log('SW registered!', reg))
    .catch(err => console.log('Boo!', err));

  setTimeout(() => {
    const img = new Image();
    img.src = '/dog.svg';
    document.body.appendChild(img);
  }, 3000);
</script>

它会注册一个服务工件,并在 3 秒后添加狗的图片。

下面是其服务工件 sw.js

self.addEventListener('install', event => {
  console.log('V1 installing…');

  // cache a cat SVG
  event.waitUntil(
    caches.open('static-v1').then(cache => cache.add('/cat.svg'))
  );
});

self.addEventListener('activate', event => {
  console.log('V1 now ready to handle fetches!');
});

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // serve the cat SVG from the cache if the request is
  // same-origin and the path is '/dog.svg'
  if (url.origin == location.origin && url.pathname == '/dog.svg') {
    event.respondWith(caches.match('/cat.svg'));
  }
});

它会缓存一张猫的图片,并在有 /dog.svg 请求时提供该图片。不过,如果您运行上述示例,则会在首次加载页面时看到狗。点击刷新,您就会看到猫。

范围和控制

Service Worker 注册的默认范围相对于脚本网址为 ./。这意味着,如果您在 //example.com/foo/bar.js 注册服务工件,其默认作用域为 //example.com/foo/

我们将网页、工作器和共享工作器称为 clients。您的服务工件只能控制范围内的客户端。客户端被“控制”后,其提取操作会通过作用域内的服务工作器进行。您可以检测客户端是否通过 navigator.serviceWorker.controller 进行控制,该值将为 null 或服务工件实例。

下载、解析和执行

您在调用 .register() 时会下载您的第一个 Service Worker。如果您的脚本在初始执行期间下载失败、解析失败或抛出错误,注册 promise 将被拒绝,并且系统会舍弃该服务工件。

Chrome 的开发者工具会在控制台中以及“应用”标签页的“服务工件”部分显示错误:

服务工件开发者工具标签页中显示错误

安装

服务工件收到的第一个事件是 install。该事件会在工作器执行后立即触发,并且每个服务工作器仅调用一次。如果您更改了服务工件脚本,浏览器会将其视为不同的服务工件,并会为其获取自己的 install 事件。我会稍后详细介绍更新

在能够控制客户端之前,您可以利用 install 事件缓存所需的一切内容。您传递给 event.waitUntil() 的 promise 可让浏览器知道安装何时完成以及是否成功。

如果您的 promise 被拒绝,则表示安装失败,并且浏览器会抛弃该服务工件。它绝不会控制客户端。这意味着,我们可以依赖 cat.svgfetch 事件的缓存中存在。它是一个依赖项。

激活

当您的服务工件准备好控制客户端并处理 pushsync 等功能事件后,您将收到 activate 事件。但这并不意味着调用 .register() 的页面将受到控制。

首次加载演示版时,即使在服务工件激活后很久才请求 dog.svg,它也不会处理该请求,您仍然会看到狗的图片。默认值为一致性,如果您的网页在加载时没有 Service Worker,其子资源也不会加载。如果您第二次加载演示版(即刷新页面),系统会控制演示版。网页和图片都会触发 fetch 事件,而您会看到猫的图片。

clients.claim

在服务工件激活后,您可以在服务工件中调用 clients.claim() 来控制无控制的客户端。

下面是上述演示的变体,它会在 activate 事件中调用 clients.claim()。您应该会在第一次使用时看到猫。我说“应该”,是因为这对时间非常敏感。只有在服务工件激活且 clients.claim() 在图片尝试加载之前生效时,您才会看到猫咪。

如果您使用服务工件加载网页的方式与通过网络加载的方式不同,clients.claim() 可能会带来麻烦,因为您的服务工件最终会控制一些未使用它加载的客户端。

更新服务工件

简而言之:

  • 如果发生以下任一情况,系统都会触发更新:
    • 导航到范围内的页面。
    • 功能事件,例如 pushsync,除非在过去 24 小时内已进行过更新检查。
    • 仅在 Service Worker 网址发生更改时调用 .register()不过,您应避免更改工作器网址
  • 大多数浏览器(包括 Chrome 68 及更高版本)在检查已注册的服务工件脚本的更新时,默认会忽略缓存标头。在通过 importScripts() 提取在 Service Worker 中加载的资源时,它们仍会遵循缓存标头。您可以在注册服务工件时设置 updateViaCache 选项,以替换此默认行为。
  • 如果您的服务工件与浏览器中已有的服务工件在字节数上不同,则系统会将其视为已更新。(我们还将扩展此功能,以涵盖导入的脚本/模块。)
  • 更新后的 Service Worker 会与现有 Service Worker 一起启动,并获得自己的 install 事件。
  • 如果新 worker 的状态代码不为“ok”(例如 404)、解析失败、在执行期间抛出错误或在安装期间被拒绝,系统会舍弃新 worker,但当前 worker 会保持活跃状态。
  • 成功安装后,更新后的 worker 将 wait,直到现有 worker 控制的客户端数量为零。(请注意,客户在刷新期间会重叠。)
  • self.skipWaiting() 会阻止等待,这意味着服务工件会在安装完成后立即激活。

假设我们更改了服务工脚本,以便在响应时返回一张马的照片,而不是猫的照片:

const expectedCaches = ['static-v2'];

self.addEventListener('install', event => {
  console.log('V2 installing…');

  // cache a horse SVG into a new cache, static-v2
  event.waitUntil(
    caches.open('static-v2').then(cache => cache.add('/horse.svg'))
  );
});

self.addEventListener('activate', event => {
  // delete any caches that aren't in expectedCaches
  // which will get rid of static-v1
  event.waitUntil(
    caches.keys().then(keys => Promise.all(
      keys.map(key => {
        if (!expectedCaches.includes(key)) {
          return caches.delete(key);
        }
      })
    )).then(() => {
      console.log('V2 now ready to handle fetches!');
    })
  );
});

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // serve the horse SVG from the cache if the request is
  // same-origin and the path is '/dog.svg'
  if (url.origin == location.origin && url.pathname == '/dog.svg') {
    event.respondWith(caches.match('/horse.svg'));
  }
});

查看上述功能的演示。您应该仍然会看到猫的图片。原因如下…

安装

请注意,我已将缓存名称从 static-v1 更改为 static-v2。这意味着,我可以设置新的缓存,而无需覆盖旧版服务工件仍在使用的当前缓存中的内容。

此模式会创建特定于版本的缓存,类似于原生应用与其可执行文件捆绑的资源。您可能还有一些不特定于版本的缓存,例如 avatars

正在等待

成功安装后,更新后的服务工作器会延迟激活,直到现有服务工作器不再控制客户端。此状态称为“等待”,浏览器通过这种方式来确保一次只运行一个版本的服务工件。

如果您运行了更新后的演示版,则应该仍然会看到猫咪图片,因为 V2 Worker 尚未激活。您可以在开发者工具的“应用”标签页中看到正在等待的新服务工件:

显示新服务工作器正在等待的 DevTools

即使您只打开了一个标签页来查看演示版,刷新页面也不足以让新版本接管。这是由于浏览器导航的工作方式。在您导航时,当前页面会一直显示,直到收到响应标头为止;即使收到响应标头,当前页面也可能会保留,前提是响应包含 Content-Disposition 标头。由于存在这种重叠,因此在刷新期间,当前的服务工件始终会控制客户端。

如需获取更新,请关闭或退出使用当前服务工件的所有标签页。然后,当您再次前往演示版时,应该会看到马。

此模式与 Chrome 的更新方式类似。Chrome 更新会在后台下载,但只有在 Chrome 重启后才会应用。在此期间,您可以继续使用当前版本,不会受到任何影响。不过,在开发过程中,这会带来诸多麻烦。不过,DevTools 提供了一些方法来简化此过程,我将在本文后面介绍这些方法。

激活

当旧版服务工件消失且新版服务工件能够控制客户端时,此事件便会触发。这是执行旧 worker 仍在使用时无法执行的操作(例如迁移数据库和清除缓存)的理想时机。

在上面的演示中,我维护了一个预期存在的缓存列表,并在 activate 事件中移除所有其他缓存,从而移除旧的 static-v1 缓存。

如果您将 promise 传递给 event.waitUntil(),它会缓冲功能事件(fetchpushsync 等),直到 promise 解析为止。因此,当 fetch 事件触发时,激活便会完全完成。

跳过等待阶段

等待阶段意味着您一次只能运行一个版本的网站,但如果您不需要该功能,则可以通过调用 self.skipWaiting() 来更快地激活新的服务工件。

这会导致您的服务工件在进入等待阶段后(如果已处于等待阶段,则立即)踢出当前的活跃工件并激活自身。它不会导致工作器跳过安装,只会等待。

调用 skipWaiting() 的时间并不重要,只要是在等待期间或之前调用即可。在 install 事件中调用它非常常见:

self.addEventListener('install', event => {
  self.skipWaiting();

  event.waitUntil(
    // caching etc
  );
});

不过,您可能需要将其作为对服务工件的 postMessage() 调用的结果进行调用。例如,您希望在用户互动后执行 skipWaiting()

下面是一个使用 skipWaiting() 的演示。您应该会看到一张牛的照片,而无需离开当前页面。与 clients.claim() 一样,这也是一场竞赛,因此只有当新服务工件在页面尝试加载图片之前提取、安装和激活时,您才会看到奶牛。

手动更新

如前所述,浏览器会在导航和功能事件之后自动检查更新,但您也可以手动触发这些事件:

navigator.serviceWorker.register('/sw.js').then(reg => {
  // sometime later…
  reg.update();
});

如果您希望用户长时间使用您的网站而无需重新加载,则可能需要按一定的间隔(例如每小时)调用 update()

避免更改 Service Worker 脚本的网址

如果您已阅读我关于缓存最佳实践的文章,不妨考虑为每个版本的服务工件分配唯一的网址。请勿这样做!对于 Service Worker,这通常是一种不良做法,请直接在其当前位置更新脚本。

这可能会导致出现如下问题:

  1. index.htmlsw-v1.js 注册为服务工件。
  2. sw-v1.js 会缓存和提供 index.html,因此它支持离线优先。
  3. 您更新 index.html,以便它注册全新的 sw-v2.js

如果您执行上述操作,用户将永远不会收到 sw-v2.js,因为 sw-v1.js 会从其缓存中提供旧版 index.html。您需要更新服务工件才能更新服务工件。恶心。

不过,对于上面的演示,我更改了服务工件的网址。因此,为了演示目的,您可以切换版本。我不会在生产环境中执行此操作。

轻松进行开发

在构建服务工件生命周期时,我们会考虑用户的需求,但在开发过程中,这会带来一些麻烦。幸运的是,我们提供了一些工具来帮助您解决此问题:

重新加载时更新

我最喜欢这张。

显示“重新加载时更新”的 DevTools

这会使生命周期变得更易于开发者使用。每项导航操作都将:

  1. 重新提取服务工件。
  2. 将其作为新版本安装,即使字节完全相同也是如此,这意味着您的 install 事件会运行,并且您的缓存会更新。
  3. 跳过等待阶段,以便新服务工件激活。
  4. 浏览页面。

这意味着,您每次导航(包括刷新)时都会收到更新,而无需重新加载两次或关闭标签页。

跳过等待

开发者工具显示“跳过等待”

如果有工作器处于等待状态,您可以在开发者工具中点击“跳过等待”以立即将其提升为“活跃”状态。

Shift-Reload

如果您强制重新加载页面(按住 Shift 键并重新加载),系统会完全绕过服务工件。它将处于无人控制状态。此功能在规范中有所提及,因此在其他支持服务工件的浏览器中也能正常运行。

处理更新

服务工件是可扩展 Web 的一部分。我们的想法是,作为浏览器开发者,我们承认自己在 Web 开发方面不如 Web 开发者。因此,我们不应提供使用我们喜欢的模式来解决特定问题的狭义高级 API,而应让您能够访问浏览器的核心,并以适合您的用户的方式按需执行操作。

因此,为了尽可能支持尽可能多的模式,整个更新周期都是可观察的:

navigator.serviceWorker.register('/sw.js').then(reg => {
  reg.installing; // the installing worker, or undefined
  reg.waiting; // the waiting worker, or undefined
  reg.active; // the active worker, or undefined

  reg.addEventListener('updatefound', () => {
    // A wild service worker has appeared in reg.installing!
    const newWorker = reg.installing;

    newWorker.state;
    // "installing" - the install event has fired, but not yet complete
    // "installed"  - install complete
    // "activating" - the activate event has fired, but not yet complete
    // "activated"  - fully active
    // "redundant"  - discarded. Either failed install, or it's been
    //                replaced by a newer version

    newWorker.addEventListener('statechange', () => {
      // newWorker.state has changed
    });
  });
});

navigator.serviceWorker.addEventListener('controllerchange', () => {
  // This fires when the service worker controlling this page
  // changes, eg a new worker has skipped waiting and become
  // the new active worker.
});

生命周期永不停歇

如您所见,了解服务工件生命周期非常有用。有了这些了解,服务工件行为应该会显得更合理、更易懂。这些知识有助于您更自信地部署和更新服务工件。