Service Worker 思维模式

如何看待服务工作线程。

服务工作线程功能强大,绝对值得学习。借助这些功能,您可以为用户提供全新的体验。您的网站可以立即加载。它可以离线使用。它可以作为平台专用应用安装,并且在使用体验上丝毫不逊色于原生应用,但同时又具有 Web 应用的广泛覆盖面和自由度。

但服务工件与大多数 Web 开发者的常用工具截然不同。它们的学习曲线较为陡峭,并且存在一些需要注意的问题。

Google 开发者和我最近合作了一个项目,名为 Service Workies,这是一款免费游戏,旨在帮助用户了解服务工件。在构建该应用并处理服务工件的复杂细节时,我遇到了一些问题。对我来说最有帮助的是,我想到了一些描述性的比喻。在本文中,我们将探索这些心理模型,并深入了解使服务工件既棘手又出色的矛盾特征。

相同,但不同

在编写服务工件代码时,您会发现很多内容都很熟悉。您可以使用自己喜爱的新 JavaScript 语言功能。监听生命周期事件的方式与监听界面事件的方式一样。您可以像往常一样使用 Promise 来管理控制流。

但其他服务工作线程行为却让您感到困惑不已。尤其是在您刷新页面后,没有看到代码更改生效时。

新图层

通常,在构建网站时,您只需考虑两个层:客户端和服务器。服务工作线程是一个位于中间的全新层。

服务工件充当客户端和服务器之间的中间层

您可以将 Service Worker 视为一种浏览器扩展程序,您的网站可以在用户的浏览器中安装此类扩展程序。安装后,Service Worker 会使用强大的中间层扩展您网站的浏览器。此 Service Worker 层可以拦截并处理您的网站发出的所有请求。

Service Worker 层有自己的生命周期,独立于浏览器标签页。简单的页面刷新不足以更新 Service Worker,就像您不会期望页面刷新会更新部署在服务器上的代码一样。每层都有自己的更新规则。

Service Workie 游戏中,我们将介绍 Service Worker 生命周期的许多细节,并为您提供大量实践机会来使用它。

功能强大,但受限

在您的网站上使用 Service Worker 可为您带来诸多好处。您的网站可以:

  • 即使用户处于离线状态,也能正常运行
  • 通过缓存显著提升性能
  • 使用推送通知
  • PWA 的形式安装

虽然服务工件可以执行很多操作,但其功能受到设计限制。它们无法执行任何同步操作,也无法与您的网站在同一线程中执行操作。这意味着您将无法访问以下内容:

  • localStorage
  • DOM
  • 窗口

好消息是,网页可以通过多种方式与其服务工作器进行通信,包括直接 postMessage、一对一消息通道和一对多广播通道

长期有效,但有效期较短

即使用户离开您的网站或关闭标签页,活跃的 Service Worker 也会继续运行。浏览器会保留此服务工作器,以便在用户下次访问您的网站时,该服务工作器已准备就绪。在发出第一个请求之前,服务工作线程有机会拦截该请求并控制页面。这正是网站能够离线运行的原因:即使用户没有连接到互联网,服务工作器也可以提供网页本身的缓存版本。

Service Worker 中,我们通过 Kolohe(一个友好的 Service Worker)拦截和处理请求来直观呈现此概念。

已停止

尽管 Service Worker 看起来是永久存在的,但几乎可以随时停止。浏览器不希望在当前未执行任何操作的 Service Worker 上浪费资源。停止与终止不同,Service Worker 仍会保持安装和激活状态。它只是进入休眠状态。下次需要时(例如,处理请求),浏览器会将其唤醒。

waitUntil

由于服务工件随时都有可能被休眠,因此需要一种方法来告知浏览器自己正在执行重要任务,不想休息。这正是 event.waitUntil() 的用武之地。此方法会延长其所使用的生命周期,使其在我们准备就绪之前不会停止也不会进入生命周期的下一个阶段。这样,我们就有时间设置缓存、从网络提取资源等。

以下示例会告知浏览器,在创建 assets 缓存并填充剑的图片之前,我们的服务工作线程尚未安装完毕:

self.addEventListener("install", event => {
  event.waitUntil(
    caches.open("assets").then(cache => {
      return cache.addAll(["/weapons/sword/blade.png"]);
    })
  );
});

注意全局状态

发生这种启动/停止时,服务工作器的全局作用域会重置。因此,请务必不要在服务工作器中使用任何全局状态,否则当它下次唤醒时,状态与预期不同,您会很失望。

请考虑以下使用全局状态的示例:

const favoriteNumber = Math.random();
let hasHandledARequest = false;

self.addEventListener("fetch", event => {
  console.log(favoriteNumber);
  console.log(hasHandledARequest);
  hasHandledARequest = true;
});

在处理每个请求时,此服务工件都会记录一个数字(假设为 0.13981866382421893)。hasHandledARequest 变量也会更改为 true。现在,Service Worker 会空闲一段时间,因此浏览器会停止它。下次有请求时,系统又需要服务工作器,因此浏览器会唤醒它。系统会再次评估其脚本。现在,hasHandledARequest 已重置为 false,而 favoriteNumber 则是完全不同的内容 - 0.5907281835659033

您无法依赖服务工件中的存储状态。此外,创建消息通道等实例也可能会导致 bug:每次服务工件停止/启动时,您都会获得一个全新的实例。

Service Worker 第 3 章中,我们将停止的 Service Worker 可视化为在等待被唤醒时失去所有颜色。

已停止的服务工件的可视化

在一起,但分开

您的网页一次只能由一个 Service Worker 控制。但它可以同时安装两个服务工件。当您更改 Service Worker 代码并刷新页面时,实际上并没有修改 Service Worker。Service Worker 是immutable的。您要创建的是全新的付款资料。这个新的 Service Worker(我们称之为 SW2)将安装,但尚未激活。它必须等待当前服务工作器 (SW1) 终止(当用户离开您的网站时)。

干扰其他服务工件的缓存

在安装过程中,SW2 可以进行设置(通常是创建和填充缓存)。不过请注意:这个新的 Service Worker 可以访问当前 Service Worker 可以访问的所有内容。如果您不小心,新的等待状态服务工作器可能会给当前服务工作器带来麻烦。以下是一些可能会给您带来麻烦的示例:

  • SW2 可能会删除 SW1 正在使用的缓存。
  • SW2 可能会修改 SW1 正在使用的缓存的内容,导致 SW1 响应网页未预期的资源。

跳过 skipWaiting

Service Worker 还可以在安装完成后立即使用风险较高的 skipWaiting() 方法来控制网页。除非您有意尝试替换存在 bug 的服务工件,否则通常不建议这样做。新的服务工作器可能会使用当前网页不期望的更新资源,从而导致错误和 bug。

从清理开始

如需防止服务工件相互覆盖,请确保它们使用不同的缓存。实现此目的的最简单方法是为它们使用的缓存名称设置版本。

const version = 1;
const assetCacheName = `assets-${version}`;

self.addEventListener("install", event => {
  caches.open(assetCacheName).then(cache => {
    // confidently do stuff with your very own cache
  });
});

部署新的 Service Worker 时,您需要提升 version,以便它使用与上一个 Service Worker 完全分离的缓存来执行所需操作。

缓存的可视化

干净利落地结束

当您的服务工件达到 activated 状态时,您就知道它已接管,之前的服务工件已过时。此时,请务必清理旧服务工作线程。这不仅可以遵守用户的缓存存储空间限制,还可以防止意外 bug。

caches.match() 方法是一种常用的快捷方式,用于从包含匹配项的任何缓存中检索项。但它会按创建顺序迭代缓存。假设您在两个不同的缓存(assets-1assets-2)中拥有脚本文件 app.js 的两个版本。您的网页预期使用存储在 assets-2 中的较新脚本。但是,如果您尚未删除旧缓存,caches.match('app.js') 将从 assets-1 返回旧缓存,并且很可能会破坏您的网站。

要清理之前的 Service Worker 后续工作,只需删除新 Service Worker 不需要的任何缓存即可:

const version = 2;
const assetCacheName = `assets-${version}`;

self.addEventListener("activate", event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheName !== assetCacheName){
            return caches.delete(cacheName);
          }
        });
      );
    });
  );
});

防止您的 Service Worker 相互干扰需要付出一些努力并遵守一些规则,但值得您这么做。

Service Worker 思维方式

在思考服务工件时,采用正确的思维方式有助于您自信地构建服务工件。熟悉这些功能后,您将能够为用户打造出色的体验。

如果您想通过玩游戏来了解这一切,那么您很幸运!欢迎玩一玩 Service Workies,在其中学习服务工作线程的运作方式,以便消灭离线怪兽。