Service Worker 生命周期

Service Worker 的生命周期是其最复杂的部分。如果您不了解它的目的以及好处,可能会感觉它在与您作对。但是,一旦您了解了它的工作原理,您就可以为用户提供无缝、不唐突的更新,融合 Web 和原生模式的最佳特性。

这是一篇深入探讨的文章,但每个部分开头的要点涵盖了您需要了解的大部分内容。

意图

生命周期的意图是

  • 使离线优先成为可能。
  • 允许新的 Service Worker 在不中断当前 Service Worker 的情况下做好准备。
  • 确保在整个过程中,作用域内的页面都由同一个 Service Worker(或没有 Service Worker)控制。
  • 确保您的站点一次只运行一个版本。

最后一个非常重要。如果没有 Service Worker,用户可以加载一个选项卡到您的站点,然后稍后打开另一个选项卡。这可能会导致您的站点同时运行两个版本。有时这没问题,但如果您正在处理存储,您很容易最终得到两个选项卡对共享存储的管理方式持有非常不同的意见。这可能会导致错误,或者更糟糕的是,数据丢失。

第一个 Service Worker

简而言之

  • install 事件是 Service Worker 收到的第一个事件,它只发生一次。
  • 传递给 installEvent.waitUntil() 的 Promise 信号指示安装的持续时间以及成功或失败。
  • Service Worker 在成功完成安装并变为“active”状态之前,不会收到诸如 fetchpush 之类的事件。
  • 默认情况下,页面的 fetch 请求不会通过 Service Worker,除非页面请求本身通过了 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>

它注册了一个 Service Worker,并在 3 秒后添加了一张狗的图片。

这是它的 Service Worker,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 注册的默认作用域是相对于脚本 URL 的 ./。这意味着如果您在 //example.com/foo/bar.js 注册 Service Worker,则其默认作用域为 //example.com/foo/

我们将页面、Worker 和共享 Worker 称为 clients。您的 Service Worker 只能控制作用域内的客户端。一旦客户端被“控制”,其 fetch 请求将通过作用域内的 Service Worker。您可以通过 navigator.serviceWorker.controller 检测客户端是否受控制,它将为 null 或 Service Worker 实例。

下载、解析和执行

当您调用 .register() 时,会下载您的第一个 Service Worker。如果您的脚本下载、解析失败或在其初始执行中抛出错误,则注册 Promise 将拒绝,并且 Service Worker 将被丢弃。

Chrome 的 DevTools 在控制台中以及应用程序选项卡的 Service Worker 部分显示错误

Error displayed in service worker DevTools tab

安装

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

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

如果您的 Promise 拒绝,则表示安装失败,并且浏览器会丢弃 Service Worker。它永远不会控制客户端。这意味着我们可以依靠 cat.svg 出现在我们的 fetch 事件的缓存中。这是一个依赖项。

激活

一旦您的 Service Worker 准备好控制客户端并处理诸如 pushsync 之类的功能事件,您将收到一个 activate 事件。但这并不意味着调用 .register() 的页面将被控制。

第一次加载演示时,即使在 Service Worker 激活后很久才请求 dog.svg,它也不会处理该请求,您仍然会看到狗的图片。默认设置是一致性,如果您的页面在没有 Service Worker 的情况下加载,则其子资源也不会加载。如果您第二次加载演示(换句话说,刷新页面),它将受到控制。页面和图片都将通过 fetch 事件,您将看到一只猫。

clients.claim

您可以通过在 Service Worker 激活后在其中调用 clients.claim() 来控制不受控制的客户端。

这是上面演示的一个变体,它在其 activate 事件中调用 clients.claim()。您应该在第一次看到一只猫。我说“应该”,因为这在时间上很敏感。您只有在 Service Worker 激活并且 clients.claim() 在图片尝试加载之前生效时才会看到一只猫。

如果您使用 Service Worker 以不同于通过网络加载页面的方式加载页面,clients.claim() 可能会很麻烦,因为您的 Service Worker 最终会控制一些在没有它的情况下加载的客户端。

更新 Service Worker

简而言之

  • 如果发生以下任何一种情况,则会触发更新
    • 导航到作用域内的页面。
    • 诸如 pushsync 之类的功能事件,除非在过去 24 小时内已进行更新检查。
    • 调用 .register() 仅当 Service Worker URL 已更改时。但是,您应该避免更改 Worker URL
  • 包括 Chrome 68 及更高版本在内的大多数浏览器,在检查已注册的 Service Worker 脚本的更新时,默认情况下会忽略缓存标头。当通过 importScripts() 获取 Service Worker 内加载的资源时,它们仍然会遵守缓存标头。您可以通过在注册 Service Worker 时设置 updateViaCache 选项来覆盖此默认行为。
  • 如果您的 Service Worker 与浏览器已有的 Service Worker 的字节数不同,则认为您的 Service Worker 已更新。(我们正在扩展这一点,以包括导入的脚本/模块。)
  • 更新后的 Service Worker 与现有 Service Worker 并行启动,并获得其自己的 install 事件。
  • 如果您的新 Worker 的状态代码不是 ok(例如,404)、解析失败、在执行期间抛出错误或在安装期间拒绝,则新 Worker 将被丢弃,但当前 Worker 仍处于活动状态。
  • 成功安装后,更新后的 Worker 将 wait,直到现有 Worker 不再控制任何客户端。(请注意,在刷新期间客户端会重叠。)
  • self.skipWaiting() 阻止等待,这意味着 Service Worker 在完成安装后立即激活。

假设我们更改了 Service Worker 脚本以响应马的图片而不是猫

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。这意味着我可以设置新缓存,而不会覆盖当前缓存中的内容,旧的 Service Worker 仍在使用的缓存。

此模式创建特定于版本的缓存,类似于本机应用程序会与其可执行文件捆绑在一起的资源。您可能还拥有非特定于版本的缓存,例如 avatars

等待

成功安装后,更新后的 Service Worker 将延迟激活,直到现有 Service Worker 不再控制客户端。此状态称为“等待”,它是浏览器确保一次只运行一个版本的 Service Worker 的方式。

如果您运行了更新后的演示,您仍然应该看到猫的图片,因为 V2 Worker 尚未激活。您可以在 DevTools 的“Application”选项卡中看到新的 Service Worker 正在等待

DevTools showing new service worker waiting

即使您只打开了一个演示选项卡,刷新页面也不足以让新版本接管。这是由于浏览器导航的工作方式。当您导航时,当前页面不会消失,直到收到响应标头为止,即使这样,如果响应具有 Content-Disposition 标头,当前页面也可能会保留。由于这种重叠,当前 Service Worker 在刷新期间始终控制着客户端。

要获取更新,请关闭或导航离开使用当前 Service Worker 的所有选项卡。然后,当您再次导航到演示时,您应该会看到马。

此模式类似于 Chrome 更新的工作方式。Chrome 的更新在后台下载,但在 Chrome 重新启动之前不会应用。在此期间,您可以继续使用当前版本,而不会受到干扰。但是,这在开发期间很痛苦,但 DevTools 有一些方法可以使其更容易,我将在本文后面的内容中介绍。

激活

一旦旧的 Service Worker 消失,并且您的新 Service Worker 能够控制客户端,就会触发此事件。这是执行在旧 Worker 仍在使用时无法执行的操作的理想时机,例如迁移数据库和清除缓存。

在上面的演示中,我维护了一个我希望存在的缓存列表,并在 activate 事件中删除了任何其他缓存,这会删除旧的 static-v1 缓存。

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

跳过等待阶段

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

这会导致您的 Service Worker 踢出现有的活动 Worker,并在其进入等待阶段后立即激活自身(如果它已处于等待阶段,则立即激活)。它不会导致您的 Worker 跳过安装,只是跳过等待。

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

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

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

但是您可能希望将其作为 postMessage() 给 Service Worker 的结果来调用它。就像您希望在用户交互后 skipWaiting() 一样。

这是一个使用 skipWaiting() 的演示。您应该看到一张奶牛的图片,而无需导航离开。与 clients.claim() 一样,这是一个竞争条件,因此只有在新 Service Worker 获取、安装和激活之后,页面尝试加载图片之前,您才能看到奶牛。

手动更新

正如我之前提到的,浏览器会在导航和功能事件后自动检查更新,但您也可以手动触发它们

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

如果您希望用户长时间使用您的站点而不重新加载,您可能希望按间隔(例如每小时)调用 update()

避免更改 Service Worker 脚本的 URL

如果您已阅读我关于缓存最佳实践的文章,您可能会考虑为每个版本的 Service Worker 提供唯一的 URL。不要这样做! 这通常是 Service Worker 的不良做法,只需更新当前位置的脚本即可。

它可能会让您遇到这样的问题

  1. index.htmlsw-v1.js 注册为 Service Worker。
  2. sw-v1.js 缓存并提供 index.html,使其可以离线优先工作。
  3. 您更新 index.html,使其注册您新的闪亮的 sw-v2.js

如果您执行上述操作,用户永远不会获得 sw-v2.js,因为 sw-v1.js 正在从其缓存中提供旧版本的 index.html。您已将自己置于需要更新 Service Worker 才能更新 Service Worker 的境地。呃。

但是,对于上面的演示,我确实更改了 Service Worker 的 URL。这是为了演示的目的,您可以切换版本。这不是我会在生产环境中做的事情。

使开发变得容易

Service Worker 生命周期是为用户构建的,但在开发期间有点麻烦。值得庆幸的是,有一些工具可以提供帮助

重新加载时更新

这是我最喜欢的。

DevTools showing 'update on reload'

这会将生命周期更改为对开发人员友好。每次导航都会

  1. 重新获取 Service Worker。
  2. 即使它在字节上相同,也将其作为新版本安装,这意味着您的 install 事件会运行,并且您的缓存会更新。
  3. 跳过等待阶段,以便新的 Service Worker 激活。
  4. 导航页面。

这意味着您将在每次导航(包括刷新)时获得更新,而无需重新加载两次或关闭选项卡。

跳过等待

DevTools showing 'skip waiting'

如果您有一个 Worker 正在等待,您可以在 DevTools 中点击“跳过等待”以立即将其提升为“活动”状态。

Shift-重新加载

如果您强制重新加载页面(Shift-重新加载),它将完全绕过 Service Worker。它将不受控制。此功能在规范中,因此它在其他支持 Service Worker 的浏览器中也有效。

处理更新

Service Worker 被设计为可扩展 Web的一部分。其理念是,我们作为浏览器开发人员,承认我们并不比 Web 开发人员更擅长 Web 开发。因此,我们不应提供狭隘的高级 API,这些 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.
});

生命周期永无止境

如您所见,了解 Service Worker 生命周期是值得的——有了这种理解,Service Worker 的行为应该看起来更合乎逻辑,而不是那么神秘。这些知识将使您在部署和更新 Service Worker 时更有信心。