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 和共享的 Worker 称为 clients。您的 Service Worker 只能控制位于作用域内的客户端。在客户端“受控制”后,其提取将执行作用域内的 Service Worker。您可以通过 navigator.serviceWorker.controller(其将为 null 或一个 Service Worker 实例)检测客户端是否受控制。

下载、解析和执行

当您调用 .register() 时,会下载您的第一个 Service Worker。如果您的脚本在初始执行中无法下载、解析或引发错误,则注册器 promise 将拒绝,并舍弃此 Service Worker。

Chrome 的 DevTools 在控制台和应用标签的 Service Worker 部分中显示该错误:

Service Worker DevTools 标签页中显示的错误

安装

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 后,您可以通过在其中调用 clients.claim() 来控制未受控制的客户端。

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

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

更新 Service Worker

简而言之:

  • 如果出现以下任一情况,就会触发更新:
    • 导航到报告范围内的页面。
    • 功能事件,例如 pushsync,除非在过去 24 小时内进行了更新检查。
    • 仅在 Service Worker 网址已更改时调用 .register()。但是,您应避免更改工作器网址
  • 大多数浏览器(包括 Chrome 68 及更高版本)在检查已注册的 Service Worker 脚本的更新时,默认会忽略缓存标头。在通过 importScripts() 提取在 Service Worker 内加载的资源时,它们仍会遵循缓存标头。您可以在注册 Service Worker 时设置 updateViaCache 选项,以替换此默认行为。
  • 如果 Service Worker 的字节与浏览器已有的字节不同,则考虑更新 Service Worker。(我们正在扩展此内容,以便将导入的脚本/模块也包含在内。)
  • 更新后的 Service Worker 与现有 Service Worker 一起启动,并获取自己的 install 事件。
  • 如果新工作器出现不正常状态代码(例如 404)、解析失败、在执行期间抛出错误或在安装期间拒绝,则新工作器将被丢弃,但当前工作器仍保持活跃状态。
  • 安装成功后,更新的 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 工作器尚未激活。您可以在 DevTools 的“Application”标签中看到等待的新 Service Worker:

DevTools 显示正在等待的新 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 并激活自身(或者,如果 Service 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 生命周期是专为用户构建的,这就给开发工作带来一定的困难。幸运的是,有几个工具可以帮到你:

重新加载时更新

这是我的最爱。

DevTools 显示“update on reload”

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

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

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

跳过等待

DevTools 显示“skipwait”(跳过等待)

如果您有一个工作线程在等待,您可以点击 DevTools 中的“skip pending”(跳过等待),立即将其提升为“active”。

Shift - 重新加载

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

处理更新

Service Worker 是作为可扩展网页的一部分进行设计的。我们的想法是,作为浏览器开发者,我们必须承认,我们不比 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.
});

整个生命周期

如您所见,了解 Service Worker 的生命周期是非常有必要的,在理解 Service Worker 后,Service Worker 的行为应该看起来更符合逻辑并且不太神秘。掌握这些信息有助于您在部署和更新 Service Worker 时更加胸有成竹。