离线和后台操作

通常,网站非常依赖于可靠的网络连接以及用户在浏览器中打开其页面。如果没有网络连接,大多数网站都无法使用,如果用户没有在浏览器标签页中打开网站,大多数网站都无法执行任何操作。

但是,请考虑以下场景

  • 一个音乐应用允许用户在线流式传输音乐,但可以在后台下载曲目,然后在用户离线时继续播放。
  • 用户撰写了一封长邮件,按下“发送”,然后失去网络连接。设备会在网络再次可用时在后台发送邮件。
  • 用户的聊天应用收到其联系人之一的消息,即使应用未打开,它也会在应用图标上显示徽章,以告知用户他们有新消息。

这些都是用户对已安装应用的期望功能。在本指南中,我们将介绍一组使 PWA 能够执行以下操作的技术:

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

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

网站和工作线程

我们将在本指南中讨论的所有技术的基石是服务工作线程。在本节中,我们将简要介绍工作线程及其如何改变 Web 应用的架构。

通常,整个网站在一个线程中运行。这包括网站自己的 JavaScript 以及呈现网站 UI 的所有工作。这样做的一个结果是,如果你的 JavaScript 运行一些长时间运行的操作,网站的主要 UI 将被阻塞,并且网站对用户来说似乎没有响应。

一个服务工作线程是一种特定类型的Web 工作线程,用于实现 PWA。与所有 Web 工作线程一样,服务工作线程在与主 JavaScript 代码分开的线程中运行。主代码创建工作线程,并将工作线程脚本的 URL 传递给它。工作线程和主代码无法直接访问彼此的状态,但可以通过相互发送消息进行通信。工作线程可用于在后台运行计算量大的任务:因为它们在单独的线程中运行,所以应用中实现应用 UI 的主 JavaScript 代码可以保持对用户的响应。

因此,PWA 始终具有一个高级架构,分为:

  • 主应用,包含 HTML、CSS 以及实现应用 UI 的一部分 JavaScript(例如,通过处理用户事件)
  • 服务工作线程,处理离线和后台任务

在本指南中,当我们显示代码示例时,我们将使用注释(如// main.js// service-worker.js)指示代码属于应用的哪个部分。

离线操作

离线操作允许 PWA 即使在设备没有网络连接的情况下也能提供良好的用户体验。这可以通过向应用添加服务工作线程来实现。

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

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

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

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

服务工作线程处理请求的一种方法是“缓存优先”策略。在此策略中:

  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 应用也能正常运行。从主应用代码的角度来看,它是完全透明的:应用只是发出网络请求并获取响应。此外,由于服务工作线程位于单独的线程中,因此主应用代码可以在获取和缓存资源时保持对用户输入的响应。

注意:此处描述的策略只是服务工作线程实现缓存的一种方式。具体来说,在缓存优先策略中,我们在网络之前首先检查缓存,这意味着我们更有可能返回快速响应而不会产生网络成本,但更有可能返回过时的响应。

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

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

有关设置服务工作线程和使用它们添加离线功能的更多详细信息,请参阅我们的服务工作线程使用指南

后台操作

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

这并不意味着服务工作线程始终运行:浏览器可能会在认为合适时停止服务工作线程。例如,如果服务工作线程已闲置一段时间,它将被停止。但是,当发生需要处理的事件时,浏览器将重新启动服务工作线程。这使 PWA 能够以以下方式实现后台操作:

  • 在主应用中,注册请求以让服务工作线程执行某些操作
  • 在适当的时候,如果需要,浏览器会重新启动服务工作线程,并在服务工作线程的作用域中触发事件
  • 服务工作线程将执行该操作

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

后台同步

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

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

注册同步事件

为了让 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 应要求浏览器停止生成周期性同步事件,方法是调用unregister() 方法periodicSync

js
// main.js

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

推送

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

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

推送通知的常见用例是聊天应用程序:当用户从其联系人之一接收消息时,它将作为推送消息传递,并且应用程序会显示通知。

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

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

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

订阅推送消息

订阅推送消息的模式如下所示

Diagram showing push message subscription steps

  1. 作为先决条件,应用服务器需要预配一个公钥/私钥对,以便它可以对推送消息进行签名。签名消息需要遵循VAPID规范。
  2. 在设备上,应用使用PushManager.subscribe()方法订阅来自服务器的消息。subscribe()方法
    • 将应用服务器的公钥作为参数:推送服务将使用此公钥来验证来自应用服务器的消息签名。
    • 返回一个Promise,该Promise解析为一个PushSubscription对象。此对象包含
      • 推送服务的端点:应用服务器通过此端点知道将推送消息发送到哪里。
      • 您的服务器将用于加密发送到推送服务的公钥加密密钥
  3. 应用将端点和公钥加密密钥发送到您的服务器(例如,使用fetch())。

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

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

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

Diagram showing push message sending and delivery steps

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

权限和限制

浏览器必须找到一种平衡,以便能够为 Web 开发人员提供强大的 API,同时保护用户免受恶意、利用或编写不良的网站的侵害。他们提供的其中一项主要保护措施是,用户可以关闭网站的页面,然后它在用户的设备上不再处于活动状态。本文档中描述的 API 往往会违反此保证,因此浏览器必须采取额外措施来帮助确保用户了解这一点,以及 API 的使用方式与用户的利益一致。

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

  • 后台同步 API 不需要明确的用户权限,但发出后台同步请求只能在主应用打开时进行,并且浏览器限制重试次数和后台同步操作可以持续的时间长度。
  • 后台获取 API 需要"background-fetch"用户权限,并且浏览器会显示获取操作的正在进行的进度,使用户能够取消它。
  • 定期后台同步 API 需要"periodic-background-sync"用户权限,并且浏览器应允许用户完全禁用定期后台同步。此外,浏览器可能会将同步事件的频率与用户选择与应用交互的程度相关联:因此,用户很少使用的应用可能会收到很少的事件(甚至根本没有事件)。
  • 推送 API 需要"push"用户权限,并且所有浏览器都要求推送事件对用户可见,这意味着它们会生成对用户可见的通知。

另请参阅

参考

指南