Service Worker 生命周期

Jake Archibald
Jake Archibald

Service Worker 的生命周期是最复杂的一环。如果您不了解它要做什么以及它有哪些优势,那么您会感觉它让您败下阵来。然而,一旦您明白它的工作原理,您就可以向用户提供几乎无法察觉的无缝更新,从而使网络和原生模式的优势为您所用。

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

该生命周期旨在:

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

最后一点非常重要。如果没有 Service Worker,用户可以将一个标签页加载到您的网站,稍后打开另一个标签页。这会导致同时运行网站的两个版本。有时候这样做没什么问题,但如果您正在处理存储,那么,出现两个标签很容易会让您的操作中断,因为它们的共享的存储空间管理机制大相径庭。这可能会导致错误,更糟糕的情况是导致数据丢失。

第一个 Service Worker

简而言之:

  • install 事件是服务工作线程获取的第一个事件,并且只发生一次。
  • 传递到 installEvent.waitUntil() 的一个 promise 可表明安装的持续时间以及安装是否成功。
  • 在成功完成安装并处于“活动状态”之前,Service Worker 不会收到 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>

它会注册一个 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 注册一个服务工作线程,则它的默认作用域为 //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()不过,您应避免更改 worker 网址
  • 大多数浏览器(包括 Chrome 68 及更高版本)在检查已注册的 Service Worker 脚本的更新时,默认情况下都会忽略缓存标头。在通过 importScripts() 获取 Service Worker 内加载的资源时,它们仍会遵循缓存标头。您可以在注册 Service Worker 时设置 updateViaCache 选项,以替换此默认行为。
  • 如果 Service Worker 的字节与浏览器已有的字节不同,则考虑更新 Service Worker。(我们正在扩展此内容,以便将导入的脚本/模块也包含在内。)
  • 更新后的 Service Worker 与现有 Service Worker 一起启动,并获取自己的 install 事件。
  • 如果新 Worker 出现不正常状态代码(例如,404)、解析失败,在执行中引发错误或在安装期间被拒,则系统将舍弃新 Worker,但当前 Worker 仍处于活动状态。
  • 安装成功后,更新的工作线程将 wait,直到现有工作线程控制零个客户端。(请注意,在刷新期间客户端会重叠)。
  • 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 Worker 尚未激活。在 DevTools 的“Application”标签中,您会看到等待的新 Service Worker:

DevTools 显示等待的新 Service Worker

即使在演示中您仅打开一个标签,刷新页面时也不会显示新版本。原因在于浏览器导航的工作原理。当您浏览时,在收到响应标头之前,当前页面不会消失,即使此响应具有 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
  );
});

但是,您可能希望在对 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 显示“skip waiting”

如果您有一个工作线程在等待,您可以按 DevTools 中的“skip waiting”以立即将其提升到“active”。

Shift-reload

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

处理更新

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

生命周期永不停息

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