Service Worker 生命周期

Jake Archibald
Jake Archibald

Service Worker 的生命周期是最复杂的一环。如果你不知道它要做什么以及有什么好处,你可能会感觉它在让你败下阵。了解其工作原理后,您就可以为用户提供流畅、无干扰的更新,结合了网络和原生模式的精华。

这是一个深度教程,但每个部分开头的项目列表包含了您需要了解的大部分内容。

意图

生命周期的目的在于:

  • 实现离线优先。
  • 允许新的 Service Worker 自行做好准备,而无需中断当前的 Service Worker。
  • 确保整个过程中作用域页面由同一个 Service Worker(或者没有 Service Worker)控制。
  • 确保每次只运行网站的一个版本。

最后一点很重要如果没有 Service Worker,用户可以将一个标签页加载到您的网站,稍后打开另一个标签页。这会导致同时运行网站的两个版本。有时候这样做没什么问题,但如果您正在处理存储空间,则很容易出现两个标签页对应如何管理其共享存储空间的看法截然不同。这可能会导致错误,更糟糕的是数据丢失。

第一个 Service Worker

简而言之:

  • install 事件是 Service Worker 获取的第一个事件,并且仅发生一次。
  • 传递给 installEvent.waitUntil() 的 promise 可指示安装的时长以及安装是否成功。
  • 在成功完成安装并进入“活跃”状态之前,Service Worker 不会收到 fetchpush 等事件。
  • 默认情况下,除非页面请求本身需要通过 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 时提供该图片。不过,如果您运行上述示例,则首次加载网页时,您会看到一条小狗。按“Refresh”即可看到小猫。

范围和控制

Service Worker 注册的默认作用域是相对于脚本网址的 ./。这意味着,如果您在 //example.com/foo/bar.js 注册一个 Service Worker,则它的默认作用域为 //example.com/foo/

我们调用页面、Worker 和 Shared Worker clients。您的 Service Worker 只能控制位于作用域内的客户端。客户端“受控制”后,其提取操作将执行作用域内的 Service Worker。您可以通过 navigator.serviceWorker.controller(其将为 null 或一个 Service Worker 实例)检测客户端是否受控制。

下载、解析和执行

当您调用 .register() 时,将下载您的第一个 Service Worker。如果您的脚本在初始执行中未能下载、解析或抛出错误,寄存器 promise 将拒绝,并舍弃 Service Worker。

Chrome 的开发者工具会在控制台和应用标签的 Service Worker 部分显示该错误:

Service Worker 的“开发者工具”标签页中显示的错误

安装

Service Worker 获取的第一个事件为 install。该事件在 Worker 执行时立即触发,并且它只能被每个 Service Worker 调用一次。如果您更改 Service Worker 脚本,浏览器会将其视为另一个 Service Worker,并且它会获取自己的 install 事件。我会在稍后详细介绍这些更新

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

如果您的 promise 拒绝,则表明安装失败,浏览器将丢弃 Service Worker。它绝不会控制客户端。这意味着,我们不能依赖于 fetch 事件的缓存中存在 cat.svg。它是一个依赖项。

激活

一旦您的 Service Worker 准备好控制客户端并处理 pushsync 等功能事件,您将获得一个 activate 事件。但这并不意味着调用 .register() 的网页将受控。

首次加载演示时,即使在 Service Worker 激活很长时间后请求了 dog.svg,它也不会处理该请求,并且您仍会看到狗的图片。默认值为 consistency,如果在页面加载时不使用 Service Worker,那么也不会使用它的子资源。如果您再次加载演示(换言之,就是刷新页面),系统将对演示进行控制。页面和图片都将执行 fetch 事件,您将看到一只猫。

clients.claim

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

下面是上述演示的变体,它会在其 activate 事件中调用 clients.claim()。您应该看到第一只猫。我说“应该”,因为这跟时间有关。如果在图片尝试加载之前,Service Worker 激活且 clients.claim() 生效,那么,您只会看到一只猫。

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

更新 Service Worker

简而言之:

  • 如果发生以下任一情况,就会触发更新:
    • 导航到报告范围内的页面。
    • 功能事件,例如 pushsync,除非在过去 24 小时内进行了更新检查。
    • 仅在 Service Worker 网址已更改时调用 .register()。但是,您应避免更改工作器网址
  • 在检查已注册的 Service Worker 脚本的更新时,大多数浏览器(包括 Chrome 68 及更高版本)都默认忽略缓存标头。在通过 importScripts() 提取 Service Worker 内加载的资源时,它们仍会遵循缓存标头。您可以通过在注册 Service Worker 时设置 updateViaCache 选项来替换此默认行为。
  • 如果您的 Service Worker 的字节与浏览器已有的字节不同,则认为其已更新。(我们正在扩展此内容,以将导入的脚本/模块也包含在内。)
  • 更新后的 Service Worker 将与现有 Service Worker 一起启动,并获取自己的 install 事件。
  • 如果新 worker 出现不正常的状态代码(例如 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 不再控制任何客户端。此状态称为“waiting”,这是浏览器确保每次只运行一个 Service Worker 版本的方式。

如果您运行更新后的演示,您应该仍会看到一张猫的图片,因为 V2 工作器尚未激活。您可以看到在开发者工具的“Application”标签页中等待的新 Service Worker:

显示等待的新 Service Worker 的开发者工具

即使您在演示中只打开一个标签页,刷新页面也不足以让新版本接管。这与浏览器导航的工作原理有关。当您浏览时,在收到响应标头之前,当前页面不会消失;即使这样,如果响应具有 Content-Disposition 标头,当前页面也可能会保留。由于存在这种重叠情况,在刷新时当前 Service Worker 始终会控制一个客户端。

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

此模式与 Chrome 更新的方式类似。Chrome 的更新会在后台下载,但只有在 Chrome 重启后才能生效。在此期间,您可以继续使用当前版本而不会受干扰。不过,这在开发过程中会带来麻烦,但开发者工具可以采取一些方法来简化操作,本文稍后部分将对此进行介绍。

激活

旧 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
  );
});

但是,您可能需要在对 Service Worker 执行 postMessage() 时调用它。例如,您希望在用户互动后 skipWaiting()

下面是一个使用 skipWaiting() 的演示。您无需离开页面,就能看到一头牛的图片。与 clients.claim() 一样,它也是一个竞态,因此只有在新 Service Worker 在页面尝试加载图片之前获取、安装并激活时,您才会看到牛。

手动更新

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

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

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

避免更改 Service Worker 脚本的网址

在阅读了这篇有关缓存最佳做法的博文中,您可以考虑为每个 Service Worker 版本提供唯一的网址。不要这样做!这对 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 的网址。这是为了进行演示,您可以在版本之间切换。在生产环境中我不会这样做。

简化开发工作

Service Worker 生命周期是专为用户构建的,但在开发过程中有点麻烦。幸运的是,我们有几个工具可以帮到您:

重新加载时更新

这是我的最爱。

显示“update on load(重新加载)”的开发者工具

这会更改生命周期,使其对开发者友好。每次导航都会:

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

这意味着,每次浏览(包括刷新)时,您都会看到相应更新,无需重新加载两次或关闭标签页。

跳过等待

开发者工具显示“skip wait”

如果您有一个工作线程在等待,您可以点击开发者工具中的“skip wait”,立即将其提升为“active”。

Shift + 重新加载

如果您强制重新加载页面 (shift-reload),则将完全绕过 Service Worker。将不会受到控制。此功能已列入规范,因此,它适用于其他支持 Service Worker 的浏览器。

处理更新

Service Worker 是作为可扩展 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 时更加胸有成竹。