Service Worker 思维模式

如何看待服务工作线程。

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

但服务工件与大多数 Web 开发者的常用工具截然不同。这类游戏的学习过程较为复杂,有些问题需要你注意。

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

相同,但不同

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

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

新图层

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

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

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

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

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

功能强大,但功能有限

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

  • 即使在用户离线时也能完美运行
  • 通过缓存显著提升性能
  • 使用推送通知
  • PWA 的形式安装

尽管 Service Worker 可以执行各种操作,但它们受到设计的限制。它们无法与您的网站同步或在同一线程中执行任何操作。这意味着无法访问以下内容:

  • localStorage
  • DOM
  • 窗口

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

长期存在,但短期有效

即使在用户离开您的网站或关闭标签页后,活跃的 Service Worker 仍会继续工作。浏览器会保留此服务工作器,以便在用户下次访问您的网站时,该服务工作器已准备就绪。在发出第一个请求之前,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"]);
    })
  );
});

注意全局状态

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

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

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。Service Worker 是不可变的。而是要创建一个全新的。这个新的 Service Worker(我们将其称为 SW2)将会安装,但不会激活。它必须等待当前服务工作器 (SW1) 终止(当用户离开您的网站时)。

干扰其他服务工件的缓存

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

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

跳过 skipWaiting

Service Worker 还可以使用有风险的 skipWaiting() 方法,在完成安装后立即控制页面。除非您有意尝试替换存在 bug 的服务工件,否则通常不建议这样做。新 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 完全不同的缓存来满足自己的需求。

缓存的可视化

干净利落地结束

当您的服务工件达到 activated 状态时,您就知道它已接管,之前的服务工件已过时。此时,必须在旧 Service Worker 之后进行清理。这不仅可以遵守用户的缓存存储空间限制,还可以防止意外 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 Worker,将有助于您自信地构建自己的心态。掌握其中的窍门后,您就能为用户打造不可思议的体验。

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