使用 Service Workers

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

Service Worker 的前提

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

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

Service Worker 就像一个代理服务器,允许您修改请求和响应,用其自己的缓存中的项目替换它们。

设置以试玩 Service Worker

Service Worker 默认在所有现代浏览器中启用。要运行使用 Service Worker 的代码,您需要通过 HTTPS 提供代码 — 出于安全原因,Service Worker 仅限于通过 HTTPS 运行。因此需要一个支持 HTTPS 的服务器。为了进行实验,您可以使用 GitHub、Netlify、Vercel 等服务。为了方便本地开发,localhost 也被浏览器视为安全来源。

基本架构

对于 Service Worker,通常遵循以下步骤进行基本设置:

  1. Service Worker 代码被获取,然后使用serviceWorkerContainer.register()注册。如果成功,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 Worker 的入口点。

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 之前支持 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 Worker 的一个优点是,如果您使用我们上面展示的功能检测,不支持 Service Worker 的浏览器可以像往常一样在线使用您的应用程序。

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

Service Worker 注册失败有以下原因之一:

  • 您没有在安全上下文(通过 HTTPS)中运行您的应用程序。
  • Service Worker 文件的路径不正确。路径必须相对于源,而不是应用程序的根目录。在我们的示例中,worker 位于https://bncb2v.csb.app/sw.js,应用程序的根目录是https://bncb2v.csb.app/,因此 Service Worker 必须指定为/sw.js
  • 您的 Service Worker 路径指向与您的应用程序不同源的 Service Worker。
  • Service Worker 注册包含一个比 worker 路径允许的范围更广的 scope 选项。Service Worker 的默认作用域是 worker 所在的目录。换句话说,如果脚本 sw.js 位于 /js/sw.js 中,则它默认只能控制 /js/ 路径中的(或嵌套在其中的)URL。Service Worker 的作用域可以通过 Service-Worker-Allowed 标头来扩大(或缩小)。
  • 启用了浏览器特定的设置,例如阻止所有 cookie、隐私浏览模式、关闭时自动删除 cookie 等。有关更多信息,请参阅serviceWorker.register() 浏览器兼容性

安装和激活:填充您的缓存

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

install 事件是 Service Worker 安装或更新时触发的第一个事件。它只触发一次,在注册成功完成后立即触发,通常用于通过您离线运行应用程序所需的资产来填充浏览器的离线缓存功能。为此,我们使用 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. 这里我们向 Service Worker(因此是 self)添加一个 install 事件监听器,然后将ExtendableEvent.waitUntil()方法链式地添加到事件上 — 这确保 Service Worker 不会安装,直到 waitUntil() 内的代码成功执行。
  2. addResourcesToCache() 内部,我们使用 caches.open() 方法创建一个名为 v1 的新缓存,这将是我们的站点资源缓存的版本 1。然后我们对创建的缓存调用一个函数 addAll(),它的参数是您想要缓存的所有资源的 URL 数组。这些 URL 是相对于 worker 的位置的。
  3. 如果 Promise 被拒绝,安装失败,Worker 将不会做任何事情。这没关系,因为您可以修复代码,然后在下次注册发生时重试。
  4. 成功安装后,Service Worker 会激活。在 Service Worker 首次安装/激活时,这并没有太多独特的用途,但在 Service Worker 更新时(请参阅后面的更新您的 Service Worker部分)它会更有意义。

注意:Web Storage API(localStorage的工作方式与 Service Worker 缓存类似,但它是同步的,因此不允许在 Service Worker 中使用。

注意:如果需要,IndexedDB 可以在 Service Worker 内部用于数据存储。

对请求的自定义响应

现在您已经缓存了您的站点资产,您需要告诉 Service Worker 如何处理缓存的内容。这通过 fetch 事件完成。

  1. 每当 Service Worker 控制的任何资源被获取时,都会触发 fetch 事件,这包括指定范围内的文档,以及这些文档中引用的任何资源(例如,如果 index.html 发出跨域请求以嵌入图像,它仍然会通过其 Service Worker)。

  2. 您可以将 fetch 事件监听器附加到 Service Worker,然后调用事件上的 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

恢复失败的请求

因此,当 Service Worker 缓存中有匹配项时,caches.match(event.request) 非常有用,但是当没有匹配项时怎么办?如果我们不提供任何类型的失败处理,我们的 Promise 将解析为 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, event) => {
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }
  const responseFromNetwork = await fetch(request);
  event.waitUntil(putInCache(request, responseFromNetwork.clone()));
  return responseFromNetwork;
};

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

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

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

可能看起来有点奇怪的是,putInCache() 返回的 Promise 没有被等待。原因是,我们不希望在响应克隆已添加到缓存后才返回响应。但是,我们确实需要对 Promise 调用 event.waitUntil(),以确保 Service Worker 在缓存填充之前不会终止。

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

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

const cacheFirst = async ({ request, fallbackUrl, event }) => {
  // 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
    event.waitUntil(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",
      event,
    }),
  );
});

我们选择了这张备用图片,因为唯一可能失败的更新是新图片,因为所有其他内容都依赖于我们在前面看到的 install 事件监听器中的安装。

Service Worker 导航预加载

如果启用,导航预加载功能会在发出 fetch 请求后立即开始下载资源,并与 Service Worker 激活并行。这确保了在导航到页面时立即开始下载,而不是必须等待 Service Worker 激活。这种延迟相对较少发生,但一旦发生就无法避免,并且可能很显著。

首先必须在 Service Worker 激活期间启用该功能,使用registration.navigationPreload.enable()

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

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

继续前面的示例,我们在缓存检查之后、如果缓存不成功则从网络获取之前插入代码以等待预加载的资源。

新流程是:

  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,
  event,
}) => {
  // 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);
    event.waitUntil(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
    event.waitUntil(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",
      event,
    }),
  );
});

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

更新 Service Worker

如果您的 Service Worker 以前已安装,但刷新或页面加载时有新版本的 worker 可用,则新版本会在后台安装,但尚未激活。它仅在不再有任何页面加载仍在使用旧 Service Worker 时才激活。一旦不再有此类页面加载,新 Service Worker 就会激活。

注意:可以使用Clients.claim()绕过此限制。

您需要将新 Service Worker 中的 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…
    ]),
  );
});

在 Service Worker 安装期间,旧版本仍负责 fetches。新版本正在后台安装。我们将新缓存命名为 v2,因此旧的 v1 缓存不会受到干扰。

当没有页面使用旧版本时,新 worker 激活并负责 fetches。

删除旧缓存

正如我们在上一节中看到的,当您将 Service Worker 更新到新版本时,您将在其 install 事件处理程序中创建一个新缓存。当有受 worker 旧版本控制的打开页面时,您需要保留两个缓存,因为旧版本需要其版本的缓存。您可以使用 activate 事件从以前的缓存中删除数据。

传递给 waitUntil() 的 Promise 将阻塞其他事件直到完成,因此您可以确信您的清理操作将在新的 Service Worker 上收到第一个 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());
});

开发者工具

  • Chrome
  • Firefox
    • Firefox 工具栏自定义选项中提供的“忘记此网站”按钮可用于清除 Service Worker 及其缓存。
  • Edge

另见