离线和后台操作

通常,网站非常依赖可靠的网络连接,并且依赖用户在浏览器中打开页面。没有网络连接,大多数网站都无法使用;如果用户没有在浏览器标签页中打开网站,大多数网站也无法做任何事情。

然而,考虑以下场景:

  • 一个音乐应用允许用户在线时播放音乐,但可以在后台下载歌曲,然后在用户离线时继续播放。
  • 用户撰写了一封很长的电子邮件,点击“发送”,然后失去了网络连接。设备会在网络再次可用时,在后台发送这封电子邮件。
  • 用户的聊天应用收到来自联系人的消息,即使应用没有打开,它也会在应用图标上显示一个徽章,告知用户有新消息。

这些都是用户期望已安装应用具备的功能。本指南将介绍一组技术,使 PWA 能够实现以下功能:

  • 即使设备网络连接不稳定,也能提供良好的用户体验
  • 在应用未运行时更新其状态
  • 在应用未运行时,通知用户发生了重要事件

本指南中介绍的技术包括:

网站和工作线程

本指南中讨论的所有技术的基础是 Service Worker。本节将简要介绍 Worker 以及它们如何改变 Web 应用的架构。

通常,整个网站都在一个单独的线程中运行。这包括网站自身的 JavaScript,以及渲染网站 UI 的所有工作。其结果之一是,如果你的 JavaScript 运行了某些耗时操作,网站的主 UI 就会被阻塞,网站对用户而言会显得无响应。

Service Worker 是一种特定类型的 Web Worker,用于实现 PWA。与所有 Web Worker 一样,Service Worker 在与主 JavaScript 代码分离的单独线程中运行。主代码创建 Worker,传入 Worker 脚本的 URL。Worker 和主代码不能直接访问彼此的状态,但可以通过互相发送消息进行通信。Worker 可用于在后台运行计算密集型任务:由于它们在单独的线程中运行,因此实现应用 UI 的主 JavaScript 代码可以保持对用户的响应。

因此,PWA 总是具有高度架构分离,分为:

  • 主应用,包含 HTML、CSS 和实现应用 UI 的 JavaScript 部分(例如,通过处理用户事件)
  • Service Worker,处理离线和后台任务

在本指南中,当我们展示代码示例时,我们会用类似 // main.js// service-worker.js 的注释来指明代码属于应用的哪个部分。

离线操作

离线操作允许 PWA 即使设备没有网络连接也能提供良好的用户体验。这通过向应用添加 Service Worker 来实现。

Service Worker 控制 应用的部分或全部页面。Service Worker 安装后,可以从服务器为其控制的页面(例如,包括页面、样式、脚本和图像)获取资源,并将它们添加到本地缓存中。Cache 接口用于将资源添加到缓存。Cache 实例通过 Service Worker 全局作用域中的 WorkerGlobalScope.caches 属性可访问。

然后,每当应用请求资源时(例如,因为用户打开了应用或点击了内部链接),浏览器会在 Service Worker 的全局作用域中触发一个名为 fetch 的事件。通过监听此事件,Service Worker 可以拦截请求。

fetch 事件的事件处理程序会传递一个 FetchEvent 对象,该对象:

  • 提供对请求的访问,作为 Request 实例
  • 提供 respondWith() 方法,用于向请求发送响应。

Service Worker 处理请求的一种方式是“缓存优先”策略。在此策略中:

  1. 如果请求的资源存在于缓存中,则从缓存中获取资源并将其返回给应用。
  2. 如果请求的资源不存在于缓存中,则尝试从网络中获取资源。
    1. 如果资源可以获取,则将资源添加到缓存中以便下次使用,并将资源返回给应用。
    2. 如果资源无法获取,则返回一些默认的备用资源。

以下代码示例展示了这种实现:

js
// service-worker.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;
  }

  // If the response was not found in the cache,
  // try to get the resource from the network.
  try {
    const responseFromNetwork = await fetch(request);
    // If the network request succeeded, clone the response:
    // - put one copy in the cache, for the next time
    // - return the original to the app
    // Cloning is needed because a response can only be consumed once.
    putInCache(request, responseFromNetwork.clone());
    return responseFromNetwork;
  } catch (error) {
    // If the network request failed,
    // get the fallback response from the cache.
    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: "/fallback.html",
    }),
  );
});

这意味着在许多情况下,即使网络连接不稳定,Web 应用也能运行良好。从主应用代码的角度来看,它是完全透明的:应用只发出网络请求并获得响应。此外,由于 Service Worker 在单独的线程中,因此在获取和缓存资源时,主应用代码可以保持对用户输入的响应。

注意: 这里描述的策略只是 Service Worker 实现缓存的一种方式。具体来说,在缓存优先策略中,我们首先检查缓存而不是网络,这意味着我们更有可能返回快速响应而无需承担网络成本,但也更有可能返回过时的响应。

另一种选择是网络优先策略,在该策略中,我们首先尝试从服务器获取资源,如果设备离线则回退到缓存。

最佳缓存策略取决于特定的 Web 应用及其使用方式。

有关设置 Service Worker 并使用它们添加离线功能的更多详细信息,请参阅我们的使用 Service Worker 指南

后台操作

虽然离线操作是 Service Worker 最常见的用途,但它们还允许 PWA 即使在主应用关闭时也能运行。这是可能的,因为 Service Worker 可以在主应用未运行时运行。

这并不意味着 Service Worker 一直都在运行:浏览器可能会在它们认为合适时停止 Service Worker。例如,如果 Service Worker 闲置了一段时间,它就会被停止。但是,当发生了需要 Service Worker 处理的事件时,浏览器会重新启动 Service Worker。这使得 PWA 能够以以下方式实现后台操作:

  • 在主应用中,注册一个请求,让 Service Worker 执行一些操作
  • 在适当的时候,如果需要,Service Worker 将被重新启动,并且在 Service Worker 的作用域中会触发一个事件
  • Service Worker 将执行操作

在接下来的部分中,我们将讨论一些不同的功能,它们使用此模式来使 PWA 在主应用未打开时也能工作。

后台同步

假设用户撰写了一封电子邮件并点击了“发送”。在传统网站中,他们必须保持标签页打开,直到应用发送了电子邮件:如果他们关闭标签页,或者设备失去连接,那么消息将不会被发送。后台同步,在 后台同步 API 中定义,是 PWA 解决此问题的方法。

后台同步使应用能够请求其 Service Worker 代替其执行任务。一旦设备具有网络连接,如果需要,浏览器将重新启动 Service Worker,并在 Service Worker 的作用域中触发一个名为 sync 的事件。然后 Service Worker 可以尝试执行任务。如果任务无法完成,则浏览器可能会通过再次触发事件来重试有限的次数。

注册同步事件

为了请求 Service Worker 执行任务,主应用可以访问 navigator.serviceWorker.ready,它会解析为一个 ServiceWorkerRegistration 对象。然后应用在该 ServiceWorkerRegistration 对象上调用 sync.register(),如下所示:

js
// main.js

async function registerSync() {
  const swRegistration = await navigator.serviceWorker.ready;
  swRegistration.sync.register("send-message");
}

请注意,应用会为任务传递一个名称:在此示例中为 "send-message"

处理同步事件

一旦设备有网络连接,sync 事件就会在 Service Worker 作用域中触发。Service Worker 检查任务名称并运行相应的函数,在此示例中为 sendMessage()

js
// service-worker.js

self.addEventListener("sync", (event) => {
  if (event.tag === "send-message") {
    event.waitUntil(sendMessage());
  }
});

请注意,我们将 sendMessage() 函数的结果传递给事件的 waitUntil() 方法。waitUntil() 方法接受一个 Promise 作为参数,并要求浏览器在 Promise 解决之前不要停止 Service Worker。这也是浏览器如何知道操作是否成功的方法:如果 Promise 被拒绝,则浏览器可能会通过再次触发 sync 事件来重试。

waitUntil() 方法并不能保证浏览器不会停止 Service Worker:如果操作花费时间过长,Service Worker 仍然会被停止。如果发生这种情况,操作将被中止,下次触发 sync 事件时,处理程序将从头开始再次运行,而不是从中断处恢复。

“太长”的时间是浏览器特定的。对于 Chrome,Service Worker 很可能会在以下情况被关闭:

  • 它已闲置 30 秒
  • 它已运行同步 JavaScript 30 秒
  • 传递给 waitUntil() 的 Promise 解决时间超过 5 分钟

后台抓取

后台同步对于相对较短的后台操作很有用,但正如我们刚刚看到的:如果 Service Worker 在相对较短的时间内没有完成处理同步事件,浏览器将停止 Service Worker。这是一项有意为之的措施,旨在通过最大程度地减少在应用处于后台时用户的 IP 地址暴露给服务器的时间,以节省电池寿命并保护用户隐私。

这使得后台同步不适合较长的操作,例如下载电影。对于这种情况,你需要 后台抓取 API。通过后台抓取,即使主应用 UI 和 Service Worker 都已关闭,也可以执行网络请求。

使用后台抓取:

  • 请求从主应用 UI 发起
  • 无论主应用是否打开,浏览器都会显示一个持久的 UI 元素,通知用户正在进行的请求,并允许他们取消或检查其进度
  • 当请求成功或失败完成时,或者用户要求检查请求进度时,浏览器会启动 Service Worker(如果需要),并在 Service Worker 的作用域中触发相应的事件。

发出后台抓取请求

通过在 ServiceWorkerRegistration 对象上调用 backgroundFetch.fetch(),在主应用代码中发起后台抓取请求,如下所示:

js
// main.js

async function requestBackgroundFetch(movieData) {
  const swRegistration = await navigator.serviceWorker.ready;
  const fetchRegistration = await swRegistration.backgroundFetch.fetch(
    "download-movie",
    ["/my-movie-part-1.webm", "/my-movie-part-2.webm"],
    {
      icons: movieIcons,
      title: "Downloading my movie",
      downloadTotal: 60 * 1024 * 1024,
    },
  );
  // …
}

我们向 backgroundFetch.fetch() 传递三个参数:

  1. 此抓取请求的标识符
  2. 一个 Request 对象或 URL 数组。单个后台抓取请求可以包含多个网络请求。
  3. 一个包含 UI 数据的对象,浏览器使用这些数据来显示请求的存在和进度。

backgroundFetch.fetch() 调用返回一个 Promise,该 Promise 解析为 BackgroundFetchRegistration 对象。这使得主应用能够随着请求的进展更新其自身的 UI。但是,如果主应用关闭,抓取将在后台继续。

浏览器将显示一个持久的 UI 元素,提醒用户请求正在进行,让他们有机会了解更多关于请求的信息,并根据需要取消它。UI 将包括从 iconstitle 参数中获取的图标和标题,并使用 downloadTotal 作为总下载大小的估计值,以显示请求的进度。

处理请求结果

当抓取成功或失败完成后,或者用户点击了进度 UI,浏览器就会启动应用的 Service Worker(如果需要),并在 Service Worker 的作用域中触发一个事件。可以触发以下事件:

  • backgroundfetchsuccess:所有请求均成功
  • backgroundfetchfail:至少一个请求失败
  • backgroundfetchabort:抓取被用户或主应用取消
  • backgroundfetchclick:用户点击了浏览器正在显示的进度 UI 元素

检索响应数据

backgroundfetchsuccessbackgroundfetchfailbackgroundfetchabort 事件的处理程序中,Service Worker 可以检索请求和响应数据。

要获取响应,事件处理程序会访问事件的 registration 属性。这是一个 BackgroundFetchRegistration 对象,它具有 matchAll()match() 方法,这些方法返回与给定 URL 匹配的 BackgroundFetchRecord 对象(或者,在 matchAll() 的情况下,如果没有给定 URL,则返回所有记录)。

每个 BackgroundFetchRecord 都有一个 responseReady 属性,它是一个 Promise,一旦响应可用,就会解析为 Response

因此,要访问响应数据,处理程序可以执行以下操作:

js
// service-worker.js

self.addEventListener("backgroundfetchsuccess", (event) => {
  const registration = event.registration;

  event.waitUntil(async () => {
    const registration = event.registration;
    const records = await registration.matchAll();
    const responsePromises = records.map(
      async (record) => await record.responseReady,
    );

    const responses = Promise.all(responsePromises);
    // do something with the responses
  });
});

由于处理程序退出后响应数据将不再可用,因此如果应用仍然需要数据,处理程序应将其存储起来(例如,在 Cache 中)。

更新浏览器的 UI

传递给 backgroundfetchsuccessbackgroundfetchfail 的事件对象还包含一个 updateUI() 方法,可用于更新浏览器显示的 UI,以便让用户了解抓取操作。通过 updateUI(),处理程序可以更新 UI 元素的标题和图标:

js
// service-worker.js

self.addEventListener("backgroundfetchsuccess", (event) => {
  // retrieve and store response data
  // …

  event.updateUI({ title: "Finished your download!" });
});

self.addEventListener("backgroundfetchfail", (event) => {
  event.updateUI({ title: "Could not complete download" });
});

响应用户交互

当用户点击浏览器在抓取进行时显示的 UI 元素时,会触发 backgroundfetchclick 事件。

此处预期的响应是打开一个窗口,向用户提供有关抓取操作的更多信息,这可以通过 Service Worker 使用 clients.openWindow() 来完成。例如:

js
// service-worker.js

self.addEventListener("backgroundfetchclick", (event) => {
  const registration = event.registration;

  if (registration.result === "success") {
    clients.openWindow("/play-movie");
  } else {
    clients.openWindow("/movie-download-progress");
  }
});

周期性后台同步

周期性后台同步 API 允许 PWA 在主应用关闭时在后台定期更新其数据。

这可以极大地改善 PWA 提供的离线体验。考虑一个依赖于相对新鲜内容的应用程序,比如新闻应用程序。如果用户打开应用程序时设备处于离线状态,那么即使有基于 Service Worker 的缓存,新闻内容也只会和上次应用程序打开时一样新鲜。通过周期性后台同步,应用程序可以在设备有连接时在后台刷新其新闻,因此能够向用户显示相对新鲜的内容。

这利用了这样一个事实:尤其是在移动设备上,连接性与其说是差,不如说是间歇性的:通过利用设备有连接的时间,应用程序可以弥补连接中断。

注册周期性同步事件

注册周期性同步事件的代码模式与注册同步事件的模式相同。ServiceWorkerRegistration 有一个 periodicSync 属性,该属性有一个 register() 方法,该方法接受周期性同步的名称作为参数。

但是,periodicSync.register() 接受一个额外的参数,它是一个带有 minInterval 属性的对象。这表示同步尝试之间的最小间隔(以毫秒为单位):

js
// main.js

async function registerPeriodicSync() {
  const swRegistration = await navigator.serviceWorker.ready;
  swRegistration.periodicSync.register("update-news", {
    // try to update every 24 hours
    minInterval: 24 * 60 * 60 * 1000,
  });
}

处理周期性同步事件

尽管 PWA 在 register() 调用中请求特定的间隔,但浏览器生成周期性同步事件的频率由浏览器决定。用户经常打开和交互的应用程序更有可能接收到周期性同步事件,并且接收频率更高,而用户很少或从不交互的应用程序则接收到的事件较少(甚至没有)。

当浏览器决定生成周期性同步事件时,模式如下:如果需要,它会启动 Service Worker,并在 Service Worker 的全局作用域中触发一个 periodicSync 事件。

Service Worker 的事件处理程序检查事件的名称,并在事件的 waitUntil() 方法内部调用相应的函数:

js
// service-worker.js

self.addEventListener("periodicsync", (event) => {
  if (event.tag === "update-news") {
    event.waitUntil(updateNews());
  }
});

updateNews() 内部,Service Worker 可以获取并缓存最新的新闻。updateNews() 函数应该相对快速地完成:如果 Service Worker 更新内容花费太长时间,浏览器将停止它。

取消注册周期性同步

当 PWA 不再需要周期性后台更新时(例如,因为用户在应用的设置中将其关闭),PWA 应该通过调用 periodicSyncunregister() 方法来要求浏览器停止生成周期性同步事件:

js
// main.js

async function unregisterPeriodicSync() {
  const swRegistration = await navigator.serviceWorker.ready;
  swRegistration.periodicSync.unregister("update-news");
}

推送

推送 API 使 PWA 能够接收来自服务器的推送消息,无论应用是否正在运行。当设备收到消息时,应用的 Service Worker 会启动并处理消息,并向用户显示一个 通知。规范允许“静默推送”(不显示通知),但由于隐私问题(例如,推送可能被用于跟踪用户位置),目前没有浏览器支持此功能。

向用户显示通知会分散他们的注意力,并且可能非常烦人,因此请谨慎使用推送消息。通常,它们适用于需要提醒用户某事,并且无法等到他们下次打开应用的情况。

推送通知的一个常见用例是聊天应用:当用户收到来自其联系人的消息时,它会作为推送消息传递,并且应用会显示一个通知。

推送消息不是直接从应用服务器发送到设备。相反,您的应用服务器将消息发送到推送服务,设备可以从该服务检索消息并将其传递给应用。

这也意味着您的服务器发送到推送服务的消息需要进行加密(以便推送服务无法读取它们)和签名(以便推送服务知道消息确实来自您的服务器,而不是冒充您的服务器的人)。

推送服务由浏览器供应商或第三方运营,应用服务器使用 HTTP Push 协议与它通信。应用服务器可以使用第三方库(如 web-push)来处理协议细节。

订阅推送消息

订阅推送消息的模式如下:

Diagram showing push message subscription steps

  1. 作为先决条件,应用服务器需要配备一个公钥/私钥对,以便它可以签署推送消息。消息签名需要遵循 VAPID 规范。

  2. 在设备上,应用使用 PushManager.subscribe() 方法订阅来自服务器的消息。subscribe() 方法:

    • 接受应用服务器的公钥作为参数:这是推送服务用于验证来自应用服务器的消息签名的方式。

    • 返回一个解析为 PushSubscription 对象的 Promise。此对象包括:

      • 推送服务的端点:这是应用服务器知道将推送消息发送到何处的方式。
      • 您的服务器将用于加密发送到推送服务的消息的公共加密密钥
  3. 应用将端点和公共加密密钥发送到您的服务器(例如,使用 fetch())。

此后,应用服务器即可开始发送推送消息。

发送、传递和处理推送消息

当服务器上发生服务器希望应用程序处理的事件时,服务器可以发送消息,步骤序列如下:

Diagram showing push message sending and delivery steps

  1. 应用服务器使用其私有签名密钥对消息进行签名,并使用推送服务的公共加密密钥对消息进行加密。应用服务器可以使用 web-push 等库来简化此操作。
  2. 应用服务器使用 HTTP Push 协议将消息发送到推送服务的端点,并且可以选择再次使用 web-push 等库。
  3. 推送服务检查消息上的签名,如果签名有效,则推送服务将消息排队等待传送。
  4. 当设备具有网络连接时,推送服务将加密消息传送给浏览器。
  5. 当浏览器收到加密消息时,它会解密该消息。
  6. 浏览器在必要时启动 Service Worker,并在 Service Worker 的全局作用域中触发一个名为 push 的事件。事件处理程序会接收一个 PushEvent 对象,其中包含消息数据。
  7. 在其事件处理程序中,Service Worker 对消息进行任何处理。像往常一样,事件处理程序调用 event.waitUntil() 以请求浏览器保持 Service Worker 运行。
  8. 在其事件处理程序中,Service Worker 使用 registration.showNotification() 创建通知。
  9. 如果用户点击或关闭通知,则分别在 Service Worker 的全局作用域中触发 notificationclicknotificationclose 事件。这些事件使应用程序能够处理用户对通知的响应。

权限和限制

浏览器必须在为 Web 开发人员提供强大 API 的同时保护用户免受恶意、剥削性或编写不当网站的侵害之间取得平衡。它们提供的主要保护之一是用户可以关闭网站页面,之后它在他们的设备上就不再活跃了。本文描述的 API 往往会违反这一保证,因此浏览器必须采取额外措施来帮助确保用户了解这一点,并且 API 的使用方式符合用户的利益。

本节将概述这些步骤。其中一些 API 需要明确的用户权限,以及各种其他限制和设计选择,以帮助保护用户。

  • 后台同步 API 不需要显式的用户权限,但只能在主应用打开时发出后台同步请求,并且浏览器会限制重试次数和后台同步操作所需的时间。

  • 后台抓取 API 需要 "background-fetch" 用户权限,并且浏览器会显示抓取操作的进行进度,允许用户取消它。

  • 周期性后台同步 API 需要 "periodic-background-sync" 用户权限,并且浏览器应该允许用户完全禁用周期性后台同步。此外,浏览器可能会将同步事件的频率与用户选择与应用交互的程度挂钩:因此,用户很少使用(甚至从不使用)的应用可能很少收到事件(甚至根本没有事件)。

  • 推送 API 需要 "push" 用户权限,并且所有浏览器都要求推送事件对用户可见,这意味着它们会生成用户可见的通知。

另见

参考

指南