使用 Service Workers

本文提供了关于 Service Workers 入门的相关信息,包括基本架构、注册 Service Worker、安装和激活新 Service Worker 的流程、更新 Service Worker、缓存控制和自定义响应,所有这些内容都以一个具有离线功能的简单应用程序为背景。

Service Workers 的前提

多年来,Web 用户一直面临着一个最主要的问题:网络连接丢失。即使是世界上最好的 Web 应用程序,如果无法下载,也会提供糟糕的用户体验。人们一直试图创造各种技术来解决这个问题,也解决了一些问题。但最主要的问题是,一直没有一个良好的整体控制机制来管理资产缓存和自定义网络请求。

Service Workers 解决了这些问题。使用 Service Worker,你可以将应用程序设置为首先使用缓存的资产,从而即使在离线的情况下也能提供默认体验,然后再从网络获取更多数据(通常称为“离线优先”)。这在原生应用程序中已经实现,这是原生应用程序通常优于 Web 应用程序的主要原因之一。

Service Worker 的功能类似于代理服务器,允许你修改请求和响应,并用其自身缓存中的项目替换它们。

设置 Service Workers 的使用

所有现代浏览器默认都启用了 Service Workers。要运行使用 Service Workers 的代码,你需要通过 HTTPS 提供你的代码 - 由于安全原因,Service Workers 只能在 HTTPS 下运行。需要支持 HTTPS 的服务器。为了进行实验,你可以使用 GitHub、Netlify、Vercel 等服务。为了方便本地开发,浏览器也认为 localhost 是一个安全的源。

基本架构

对于 Service Workers,基本设置通常会遵循以下步骤

  1. 使用 serviceWorkerContainer.register() 获取 Service Worker 代码并注册它。如果成功,Service Worker 将在 ServiceWorkerGlobalScope 中执行;这本质上是一种特殊的 worker 上下文,在主脚本执行线程之外运行,没有 DOM 访问权限。Service Worker 现在已准备好处理事件。
  2. 安装过程开始。install 事件始终是发送到 Service Worker 的第一个事件(这可以用于启动填充 IndexedDB 和缓存站点资产的过程)。在此步骤中,应用程序正在准备将所有内容提供给离线使用。
  3. install 处理程序完成时,Service Worker 被视为已安装。此时,以前版本的 Service Worker 可能处于活动状态并控制着打开的页面。因为我们不希望同一 Service Worker 的两个不同版本同时运行,所以新版本尚未激活。
  4. 一旦由旧版本 Service Worker 控制的所有页面都关闭,就可以安全地停用旧版本,新安装的 Service Worker 将收到一个 activate 事件。activate 的主要用途是清理以前版本的 Service Worker 中使用的资源。新 Service Worker 可以调用 skipWaiting() 来要求立即激活,而无需等待打开的页面关闭。然后,新 Service Worker 将立即收到 activate 事件,并接管所有打开的页面。
  5. 激活后,Service Worker 现在将控制页面,但只控制 register() 成功后打开的页面。换句话说,必须重新加载文档才能真正被控制,因为文档的创建过程是带或不带 Service Worker 的,并且在其整个生命周期内保持这种状态。为了覆盖这种默认行为并接管打开的页面,Service Worker 可以调用 clients.claim()
  6. 每当获取 Service Worker 的新版本时,此循环都会再次发生,并且在激活新版本时会清除旧版本的残留部分。

lifecycle diagram

以下是可用 Service Worker 事件的摘要

演示

为了演示注册和安装 Service Worker 的最基本知识,我们创建了一个名为 simple service worker 的演示,它是一个简单的星球大战乐高图片库。它使用一个基于 Promise 的函数从 JSON 对象中读取图片数据,并使用 fetch() 加载图片,然后将图片显示在页面下方的一条线上。目前,我们保持了内容的静态。它还注册、安装和激活了 Service Worker。

The words Star Wars followed by an image of a Lego version of the Darth Vader character

你可以查看 GitHub 上的源代码,以及 simple service worker 的实时运行情况

注册你的 worker

我们应用程序的 JavaScript 文件(app.js)中的第一个代码块如下所示。这是我们使用 Service Workers 的入口点。

js
const registerServiceWorker = async () => {
  if ("serviceWorker" in navigator) {
    try {
      const registration = await navigator.serviceWorker.register("/sw.js", {
        scope: "/",
      });
      if (registration.installing) {
        console.log("Service worker installing");
      } else if (registration.waiting) {
        console.log("Service worker installed");
      } else if (registration.active) {
        console.log("Service worker active");
      }
    } catch (error) {
      console.error(`Registration failed with ${error}`);
    }
  }
};

// …

registerServiceWorker();
  1. if 块执行一个特性检测测试,以确保在尝试注册 Service Worker 之前它得到支持。
  2. 接下来,我们使用 ServiceWorkerContainer.register() 函数来注册该站点的 Service Worker。Service Worker 代码位于我们应用程序内的 JavaScript 文件中(注意,这是相对于源的 URL,而不是引用它的 JS 文件。)
  3. scope 参数是可选的,可以用来指定你想要 Service Worker 控制的内容子集。在本例中,我们指定了 '/',这意味着应用程序源下的所有内容。如果你省略它,它会默认使用此值,但我们在这里为了说明目的而指定了它。

这将注册一个 Service Worker,它在 worker 上下文中运行,因此没有 DOM 访问权限。

单个 Service Worker 可以控制多个页面。每次加载你范围内的页面时,都会针对该页面安装 Service Worker,并在其上运行。因此,请记住,你需要小心 Service Worker 脚本中的全局变量:每个页面都不会得到自己的唯一 worker。

注意:关于 Service Workers 的一项很棒的功能是,如果你像我们上面展示的那样使用特性检测,不支持 Service Workers 的浏览器可以像预期的那样在线使用你的应用程序。

为什么我的 Service Worker 注册失败?

这可能是由于以下原因导致的

  • 你没有通过 HTTPS 运行应用程序。
  • Service Worker 文件的路径写得不正确 - 它需要相对于源来写,而不是应用程序的根目录。在我们的示例中,worker 位于 https://bncb2v.csb.app/sw.js,应用程序的根目录是 https://bncb2v.csb.app/。但路径需要写为 /sw.js
  • 也不允许指向与应用程序源不同的源的 Service Worker。
  • Service Worker 只能捕获来自 Service Worker 范围内的客户端的请求。
  • Service Worker 的最大范围是 worker 的位置(换句话说,如果脚本 sw.js 位于 /js/sw.js 中,它默认只能控制 /js/ 下的 URL)。可以使用 Service-Worker-Allowed 标头指定该 worker 的最大范围列表。
  • 在 Firefox 中,当用户处于 私密浏览模式 或历史记录已禁用,或者在 Firefox 关闭时清除 cookie 时,Service Worker API 将隐藏且不可使用。
  • 在 Chrome 中,当启用“阻止所有 cookie(不推荐)”选项时,注册将失败。

安装和激活:填充缓存

注册 Service Worker 后,浏览器将尝试为你的页面/站点安装和激活 Service Worker。

install 事件在成功完成安装后触发。install 事件通常用于使用你需要的资产填充浏览器的离线缓存功能,以便你的应用程序能够离线运行。为此,我们使用 Service Worker 的存储 API - cache - Service Worker 上的全局对象,它允许我们存储由响应提供的资产,并以其请求为键。此 API 的工作方式类似于浏览器的标准缓存,但它特定于你的域。缓存内容会保留,直到你清除它们。

以下是 Service Worker 如何处理 install 事件

js
const addResourcesToCache = async (resources) => {
  const cache = await caches.open("v1");
  await cache.addAll(resources);
};

self.addEventListener("install", (event) => {
  event.waitUntil(
    addResourcesToCache([
      "/",
      "/index.html",
      "/style.css",
      "/app.js",
      "/image-list.js",
      "/star-wars-logo.jpg",
      "/gallery/bountyHunters.jpg",
      "/gallery/myLittleVader.jpg",
      "/gallery/snowTroopers.jpg",
    ]),
  );
});
  1. 在这里,我们将一个install 事件监听器添加到服务工作者(因此是self),然后将一个ExtendableEvent.waitUntil()方法链接到该事件 - 这确保服务工作者只有在waitUntil()内部的代码成功执行后才会安装。
  2. addResourcesToCache()内部,我们使用caches.open()方法创建一个名为v1的新缓存,它将是我们网站资源缓存的版本 1。然后我们在创建的缓存上调用一个名为addAll()的函数,它将所有要缓存的资源的 URL 数组作为参数。这些 URL 相对于工作者的位置
  3. 如果承诺被拒绝,则安装失败,并且工作者不会执行任何操作。这没关系,因为你可以修复代码并在下次注册时重试。
  4. 成功安装后,服务工作者将激活。这在你的服务工作者第一次安装/激活时并没有太多明显的用途,但在服务工作者更新时意义更大(参见后面更新你的服务工作者部分)。

注意:Web 存储 API (localStorage) 的工作方式类似于服务工作者缓存,但它是同步的,因此不允许在服务工作者中使用。

注意:如果你需要,可以使用IndexedDB 在服务工作者中进行数据存储。

对请求的自定义响应

现在你已经缓存了网站资源,你需要告诉服务工作者对缓存的内容做些什么。这可以通过fetch事件来完成。

  1. 每次由服务工作者控制的任何资源被获取时,都会触发fetch事件,其中包括指定范围内的文档,以及这些文档中引用的任何资源(例如,如果index.html对嵌入图像发出跨域请求,这仍然会通过其服务工作者)。
  2. 你可以将fetch事件监听器附加到服务工作者,然后在事件上调用respondWith()方法来劫持我们的 HTTP 响应并使用你自己的内容更新它们。
    js
    self.addEventListener("fetch", (event) => {
      event.respondWith(/* custom content goes here */);
    });
    
  3. 我们可以从在每种情况下使用与网络请求 URL 相匹配的资源进行响应开始
    js
    self.addEventListener("fetch", (event) => {
      event.respondWith(caches.match(event.request));
    });
    
    caches.match(event.request) 允许我们将从网络请求的每个资源与缓存中可用的等效资源进行匹配(如果有匹配的资源可用)。匹配是通过 URL 和各种标头完成的,就像正常的 HTTP 请求一样。

Fetch event diagram

恢复失败的请求

所以当服务工作者缓存中存在匹配项时,caches.match(event.request) 非常有用,但如果没有匹配项呢?如果我们没有提供任何类型的故障处理,我们的承诺将以undefined解析,我们不会得到任何返回值。

在测试缓存的响应后,我们可以回退到常规网络请求

js
const cacheFirst = async (request) => {
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }
  return fetch(request);
};

self.addEventListener("fetch", (event) => {
  event.respondWith(cacheFirst(event.request));
});

如果资源不在缓存中,则会从网络请求它们。

使用更详细的策略,我们不仅可以从网络请求资源,还可以将它保存到缓存中,以便以后对该资源的请求也可以离线检索。这意味着如果在星球大战画廊中添加了额外的图像,我们的应用程序可以自动获取并缓存它们。以下代码段实现了这种策略

js
const putInCache = async (request, response) => {
  const cache = await caches.open("v1");
  await cache.put(request, response);
};

const cacheFirst = async (request) => {
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }
  const responseFromNetwork = await fetch(request);
  putInCache(request, responseFromNetwork.clone());
  return responseFromNetwork;
};

self.addEventListener("fetch", (event) => {
  event.respondWith(cacheFirst(event.request));
});

如果请求 URL 在缓存中不可用,我们使用await fetch(request)从网络请求资源。之后,我们将响应的克隆放入缓存。putInCache()函数使用caches.open('v1')cache.put()将资源添加到缓存。原始响应将返回到浏览器,并提供给调用它的页面。

克隆响应是必要的,因为请求和响应流只能读取一次。为了将响应返回到浏览器并将它放入缓存,我们必须克隆它。因此,原始响应将返回到浏览器,而克隆将发送到缓存。它们各自被读取一次。

看起来有点奇怪的是,putInCache() 返回的承诺没有被等待。但原因是,在返回响应之前,我们不想等待响应克隆被添加到缓存。

我们现在唯一的问题是,如果请求与缓存中的任何内容都不匹配,并且网络不可用,我们的请求仍然会失败。让我们提供一个默认的回退,以便无论发生什么,用户至少都会得到一些东西

js
const putInCache = async (request, response) => {
  const cache = await caches.open("v1");
  await cache.put(request, response);
};

const cacheFirst = async ({ request, fallbackUrl }) => {
  // First try to get the resource from the cache
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }

  // Next try to get the resource from the network
  try {
    const responseFromNetwork = await fetch(request);
    // response may be used only once
    // we need to save clone to put one copy in cache
    // and serve second one
    putInCache(request, responseFromNetwork.clone());
    return responseFromNetwork;
  } catch (error) {
    const fallbackResponse = await caches.match(fallbackUrl);
    if (fallbackResponse) {
      return fallbackResponse;
    }
    // when even the fallback response is not available,
    // there is nothing we can do, but we must always
    // return a Response object
    return new Response("Network error happened", {
      status: 408,
      headers: { "Content-Type": "text/plain" },
    });
  }
};

self.addEventListener("fetch", (event) => {
  event.respondWith(
    cacheFirst({
      request: event.request,
      fallbackUrl: "/gallery/myLittleVader.jpg",
    }),
  );
});

我们选择了这种回退图像,因为唯一可能失败的更新是新图像,因为其他一切都是在我们之前看到的install事件监听器中依赖于安装的。

Service Worker 导航预加载

如果启用,导航预加载功能会在发出获取请求后立即开始下载资源,并且与服务工作者激活并行进行。这确保下载在导航到页面时立即开始,而不是必须等到服务工作者激活。这种延迟发生的频率相对较低,但在发生时是不可避免的,并且可能很明显。

首先,必须在服务工作者激活期间使用registration.navigationPreload.enable()启用该功能

js
self.addEventListener("activate", (event) => {
  event.waitUntil(self.registration?.navigationPreload.enable());
});

然后在fetch事件处理程序中使用event.preloadResponse等待预加载的资源完成下载。

继续前面几节中的示例,我们在缓存检查之后,并在从网络获取资源(如果失败)之前,插入等待预加载资源的代码。

新的流程是

  1. 检查缓存
  2. 等待event.preloadResponse,它作为preloadResponsePromise传递给cacheFirst()函数。如果返回,则缓存结果。
  3. 如果这两个都没有定义,那么我们将转到网络。
js
const addResourcesToCache = async (resources) => {
  const cache = await caches.open("v1");
  await cache.addAll(resources);
};

const putInCache = async (request, response) => {
  const cache = await caches.open("v1");
  await cache.put(request, response);
};

const cacheFirst = async ({ request, preloadResponsePromise, fallbackUrl }) => {
  // First try to get the resource from the cache
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }

  // Next try to use (and cache) the preloaded response, if it's there
  const preloadResponse = await preloadResponsePromise;
  if (preloadResponse) {
    console.info("using preload response", preloadResponse);
    putInCache(request, preloadResponse.clone());
    return preloadResponse;
  }

  // Next try to get the resource from the network
  try {
    const responseFromNetwork = await fetch(request);
    // response may be used only once
    // we need to save clone to put one copy in cache
    // and serve second one
    putInCache(request, responseFromNetwork.clone());
    return responseFromNetwork;
  } catch (error) {
    const fallbackResponse = await caches.match(fallbackUrl);
    if (fallbackResponse) {
      return fallbackResponse;
    }
    // when even the fallback response is not available,
    // there is nothing we can do, but we must always
    // return a Response object
    return new Response("Network error happened", {
      status: 408,
      headers: { "Content-Type": "text/plain" },
    });
  }
};

// Enable navigation preload
const enableNavigationPreload = async () => {
  if (self.registration.navigationPreload) {
    await self.registration.navigationPreload.enable();
  }
};

self.addEventListener("activate", (event) => {
  event.waitUntil(enableNavigationPreload());
});

self.addEventListener("install", (event) => {
  event.waitUntil(
    addResourcesToCache([
      "/",
      "/index.html",
      "/style.css",
      "/app.js",
      "/image-list.js",
      "/star-wars-logo.jpg",
      "/gallery/bountyHunters.jpg",
      "/gallery/myLittleVader.jpg",
      "/gallery/snowTroopers.jpg",
    ]),
  );
});

self.addEventListener("fetch", (event) => {
  event.respondWith(
    cacheFirst({
      request: event.request,
      preloadResponsePromise: event.preloadResponse,
      fallbackUrl: "/gallery/myLittleVader.jpg",
    }),
  );
});

请注意,在这个示例中,无论资源是“正常”下载还是预加载,我们都为该资源下载并缓存相同的数据。你可以选择在预加载时下载和缓存不同的资源。有关更多信息,请参见NavigationPreloadManager > 自定义响应

更新你的 Service Worker

如果你的服务工作者之前已经安装,但之后在刷新或页面加载时有新版本的 worker 可用,则新版本将在后台安装,但尚未激活。只有在没有更多使用旧服务工作者的页面加载后,它才会激活。只要没有更多此类页面加载,新服务工作者就会激活。

注意:可以使用Clients.claim()绕过这一点。

你将希望在新的服务工作者中将你的install事件监听器更新为类似于以下内容(注意新的版本号)

js
const addResourcesToCache = async (resources) => {
  const cache = await caches.open("v2");
  await cache.addAll(resources);
};

self.addEventListener("install", (event) => {
  event.waitUntil(
    addResourcesToCache([
      "/",
      "/index.html",
      "/style.css",
      "/app.js",
      "/image-list.js",

      // …

      // include other new resources for the new version…
    ]),
  );
});

在服务工作者正在安装时,前一个版本仍然负责获取操作。新版本正在后台安装。我们正在将新的缓存称为v2,因此以前v1缓存不会被破坏。

当没有页面使用前一个版本时,新工作者将激活并负责获取操作。

删除旧缓存

正如我们在上一节中看到的,当你将服务工作者更新到新版本时,你将在其install事件处理程序中创建一个新的缓存。当仍然有受前一个版本工作者控制的开放页面时,你需要保留两个缓存,因为前一个版本需要其版本的缓存。你可以使用activate事件来删除以前缓存中的数据。

传递给waitUntil()的承诺将阻塞其他事件直到完成,因此你可以放心,你的清理操作会在你的新服务工作者上获得第一个fetch事件之前完成。

js
const deleteCache = async (key) => {
  await caches.delete(key);
};

const deleteOldCaches = async () => {
  const cacheKeepList = ["v2"];
  const keyList = await caches.keys();
  const cachesToDelete = keyList.filter((key) => !cacheKeepList.includes(key));
  await Promise.all(cachesToDelete.map(deleteCache));
};

self.addEventListener("activate", (event) => {
  event.waitUntil(deleteOldCaches());
});

开发者工具

另请参阅