Service Worker 思维模式

在考虑 Service Worker 时如何看待 Service Worker。

Service Worker 功能强大,值得学习。它们使您可以为用户提供全新的体验。您的网站可以立即加载。它可以离线使用。它可以作为平台专用应用进行安装,并且非常精美,而且具有网络的覆盖面和自由度。

但是,Service Worker 与我们大多数 Web 开发者习惯的方式不同。这类游戏需要你快速上手,需要注意一些问题。

Google Developers 和我最近合作开展了 Service Workies 项目,这是一个用于理解 Service Worker 的免费游戏。在构建它以及处理 Service Worker 的复杂来源时,我遇到了一些问题。对我最有帮助的是我提出了一些具有描述性的隐喻。在这篇博文中,我们将探索这些心智模式,并思考让 Service Worker 既有技巧又很棒的自相矛盾的特征。

相同,但有所不同

在为 Service Worker 编写代码时,您会发现许多熟悉的东西。您可以使用自己喜爱的新 JavaScript 语言功能。您可以像监听界面事件一样监听生命周期事件。您可以像往常一样使用 promise 来管理控制流。

但是,其他 Service Worker 行为会让您感到困惑。尤其是当您刷新页面后没有应用代码更改时。

新图层

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

Service Worker 充当客户端和服务器之间的中间层

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

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

Service Workies 游戏中,我们介绍了 Service Worker 生命周期的许多细节,并教您如何使用它。

功能强大,但存在一定限制

在网站上安排 Service Worker 将为您带来极大的好处。您的网站可以:

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

Service Worker 具有尽可能多的功能,但受到设计的限制。它们不能与您的网站同步或在同一线程中执行任何操作。也就是说,无权访问:

  • localStorage
  • DOM
  • 窗口

好消息是,您的网页可以通过多种方式与其 Service Worker 通信,包括直接 postMessage、一对一消息通道和一对多广播通道

存在时间长,但存在时间短

即使在用户离开您的网站或关闭标签页后,活跃的 Service Worker 仍会继续存在。浏览器会妥善保存此 Service Worker,以便下次用户返回您的网站时,Service Worker 可供使用。在发出第一个请求之前,Service Worker 有机会拦截该请求并控制页面。正因如此,网站仍可离线工作,即 Service Worker 可以提供网页本身的缓存版本,即使用户未连接到互联网也是如此。

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

已停止

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

waitUntil

由于进入休眠状态是不断发生的可能性,因此您的 Service Worker 需要通过某种方式让浏览器知道它何时正在执行重要的操作,而不是小憩片刻。这正是 event.waitUntil() 的用武之地。此方法会扩展它使用的生命周期,使其不会停止,并且不会进入其生命周期的下一阶段,直到准备就绪。这让我们有时间设置缓存、从网络获取资源等。

以下示例告知浏览器,只有在创建 assets 缓存并填充剑形图片之后,我们的 Service Worker 才会完成安装:

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

留意全球状态

当发生这种启动/停止时,Service Worker 的全局范围会重置。因此,请注意不要在 Service Worker 中使用任何全局状态,否则当您下次唤醒并出现与预期不同的状态时,将会感到不快。

考虑下面这个使用全局状态的示例:

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

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

在每个请求中,此 Service Worker 都会记录一个数字,假设为 0.13981866382421893hasHandledARequest 变量也会更改为 true。现在,Service Worker 会暂时闲置,于是浏览器将其停止。下次有请求时,再次需要 Service Worker,因此浏览器将其唤醒。系统会再次评估其脚本。现在,hasHandledARequest 已重置为 false,而 favoriteNumber 则完全不同 - 0.5907281835659033

您不能依赖于 Service Worker 中的存储状态。此外,创建消息通道等内容的实例也可能会导致 bug:每次 Service Worker 停止/启动时,您都会获得一个全新的实例。

Service Workies 第 3 章中,我们直观呈现了已停止的 Service Worker 在等待唤醒期间失去所有颜色。

停止的 Service Worker 的可视化

彼此关联,但彼此独立

您的页面一次只能由一个 Service Worker 控制。但可以同时安装两个 Service Worker。当您更改 Service Worker 代码并刷新页面时,您实际上并未修改 Service Worker。Service Worker 是不可变的。您要创建的是全新的。这个新的 Service Worker(我们将其称为 SW2)将会安装,但不会激活。它必须等待当前 Service Worker (SW1) 终止(当用户离开您的网站时)。

与其他 Service Worker 的缓存混淆

在安装时,SW2 可以进行一些设置,通常是创建和填充缓存。但请注意:这个新的 Service Worker 可以访问当前 Service Worker 有权访问的一切内容。如果不小心,新的等待 Service Worker 可能会给您当前的 Service Worker 造成混乱。下面列举了一些可能会给您带来麻烦的示例:

  • SW2 可以删除 SW1 正在使用的缓存。
  • SW2 可以修改 SW1 正在使用的缓存内容,导致 SW1 以页面没有预期的资源进行响应。

跳过 skipWaiting

Service Worker 还可以使用有风险的 skipWaiting() 方法在完成安装后立即控制页面。通常,这不是一个好主意,除非您有意尝试替换有缺陷的 Service Worker。新 Service Worker 可能正在使用当前页面不符合预期的更新资源,从而导致错误和 bug。

开始清理

防止 Service Worker 相互破坏的方法是确保它们使用不同的缓存。最简单的方法是对它们使用的缓存名称进行版本控制。

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 完全不同的缓存来执行所需的操作。

缓存的可视化

结束清理

一旦 Service Worker 达到 activated 状态,您就知道它已被接管,并且之前的 Service Worker 是多余的。此时,务必在旧的 Service Worker 之后进行清理。它不仅会遵循用户的缓存存储空间限制,还能防范意外 bug。

caches.match() 方法是常用的快捷方式,用于从存在匹配项的任何缓存中检索项。不过,它会按照缓存的创建顺序遍历这些缓存。假设您在两个不同的缓存中拥有脚本文件 app.js 的两个版本:assets-1assets-2。您的网页需要使用存储在 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 Worker 时保持正确的心态将有助于您自信地构建 Service Worker。掌握好这些功能之后,您便可以为用户打造不可思议的体验。

如果您想通过玩游戏来了解所有这一切,那么恭喜您!前往 Service Workies,您将了解 Service Worker 如何杀死离线野兽。