经期跟踪器：Service worker - 渐进式 Web 应用（PWA）

经期跟踪器：Service worker
====================

* 上一页
* Overview: CycleTracker

目前为止，我们已经为经期跟踪器编写了 HTML、CSS 和 JavaScript，添加了清单文件用于定义颜色、图标、URL 以及其他的应用特性。我们已经有了一个能运行的 Web 应用！但它还不是一个 PWA。在这一章节，我们将要编写将我们的功能完整、能够作为独立应用分发并且能无缝地在离线状态下运行的 Web 应用转化为 PWA 所需的 JavaScript 脚本。

如果你还没有完成到这一步，请复制这些 HTML、CSS、JavaScript，还有清单 JSON 文件的内容，并分别按照以下文件名保存：`index.html`、`styles.css`、`app.js` 以及 `cycletracker.json`。

在本章节，我们要创建 `sw.js`——service worker 脚本，用于将我们的 Web 应用转化成 PWA。我们已经有了一个 JavaScript 文件——在 HTML 文件的最后一行里名为 `app.js` 的那一个。这个 JavaScript 脚本提供了全部标准 Web 应用特性的功能。我们并不会像使用 `app.js` 那样通过 `<script>` 元素的 `src` 属性来调用 `sw.js`，而是通过注册 service worker 来建立它与 Web 应用的联系。

在本课程的最后，你将会拥有一个功能完整的 PWA——一个能够被完整安装的、即使用户处于离线状态也能运行的逐步增强的 Web 应用。

Service worker 的职责
------------------

Service worker 能够让应用离线运行，并且时刻确保应用是最新的。为了能出色地做到这点，service worker 应当包含以下信息：

* 版本号（或者其他标识）。
* 缓存资源的列表。
* 缓存版本的名称。

Service worker 还需要负责：

* 在应用被安装时安装缓存资源。
* 根据需要更新自身和其他的应用文件。
* 移除不会再被用到的缓存文件。

我们通过响应以下三种 service worker 事件来实现这些任务：

* `fetch` (en-US) 事件、
* `install` (en-US) 事件，以及
* `activate` (en-US) 事件。
### 版本号

PWA 一但在用户的机器上被安装，唯一通知浏览器有要检索的更新文件的方法就是对 service worker 作出修改。如果 PWA 的其他资源发生了改动——比如 HTML 更新了、CSS 里的 bug 被修复了、`app.js` 添加了新函数、一张图片被压缩了以减少文件体积，等等——你安装的 PWA 的 service worker 是不会知道它需要下载更新资源的。只有 service worker 被改动了，PWA 才会知道它也许该更新缓存了，然后去发起更新的任务。

虽然修改任意字符在技术上都足以达成，但 PWA 的最佳做法是创建一个按顺序更新的版本号常量用以指示文件的更新。即使 service worker 本身没有其他任何的改动，也应当以更新版本号（或者日期）这种较为正式的方式对 service worker 做出修改，这也是给开发人员提供一种辨别应用版本的方法。

#### 任务

新建一个包含版本号的 JavaScript 文件：

```js
const VERSION = "v1";
```

将文件保存为 `sw.js`。

### 离线资源列表

为了能有优良的离线体验，缓存文件列表应当包含 PWA 离线时需要使用的所有资源。即使清单文件里可能会罗列出一大堆各种大小的图标，应用缓存也只需要包含应用在离线模式下需要使用的资源。

```js
const APP\_STATIC\_RESOURCES = [
  "/",
  "/index.html",
  "/styles.css",
  "/app.js",
  "/icon-512x512.png",
];
```

你不需要把在各个不同操作系统和设备上使用的各个图标都包含进列表里。而是应该把应用使用的所有图片包含进来，包括在应用启动缓慢时用户可能会看见的应用启动页面或者提示用户“你需要连接到互联网以获得完整体验”的页面所使用的素材。

不要把 service worker 文件包含到缓存资源列表里。

#### 任务

向 `sw.js` 添加用于经期跟踪器 PWA 的缓存资源列表。

#### 示例方案

我们包括了在本教程的其他章节中所创建的经期跟踪器在离线状态下运作所需的静态资源。我们的 `sw.js` 文件目前是这样的：

```js
const VERSION = "v1";

const APP\_STATIC\_RESOURCES = [
  "/",
  "/index.html",
  "/styles.css",
  "/app.js",
  "/cycletrack.json",
  "/icons/wheel.svg",
];
```

我们包括了 `wheel.svg` 图标，以备你要增强 PWA 的 UI 时使用，比如在没有经期数据时显示 logo，虽然我们的应用目前并没有使用它。

### 应用缓存名称

我们有了版本号，也有了需要缓存的文件。在缓存文件之前，我们需要创建一个在存储应用的静态资源时需要使用的缓存名称。这个缓存名称应当被版本化，以确保在应用更新时可以创建新的缓存同时删除旧的缓存。

#### 任务

使用 `VERSION` 版本号来创建一个版本化的 `CACHE_NAME` 缓存名，作为常量添加到 `sw.js`。

#### 示例方案

我们将缓存命名为 `period-tracker-` 后跟 `VERSION` 版本号。鉴于我们的常量声明都是单行的，为了方便阅读，我们将其放在了资源数组常量的前面。

```js
const VERSION = "v1";
const CACHE\_NAME = `period-tracker-${VERSION}`;

const APP\_STATIC\_RESOURCES = [ ... ];
```

我们成功声明了我们的常量：一个唯一的标识符、数组形式的离线资源列表以及随版本号更新的应用缓存名称。现在，让我们把注意力转向安装、更新以及删除无用的缓存资源。

### 在 PWA 安装过程中保存缓存

当用户安装 PWA 或者只是单纯的访问带有 service worker 的网站时，会在 service worker 的作用域触发一个 `install` 事件。我们想要监听这个事件，在安装期间用 PWA 的静态资源填充缓存。每当 service worker 的版本更新，浏览器都会安装新的 service worker 并触发安装事件。

`install` 事件会在应用第一次被安装或者浏览器检测到有新版本的 service worker 时触发。当旧的 service worker 将要被新的替换时，旧的 service worker 仍然会作为 PWA 的 service worker，直到新的 service worker 被激活。

全局变量 `caches` 只在安全上下文中可用，它会返回一个与当前上下文关联的 `CacheStorage` 对象。`CacheStorage.open()` 方法会返回一个 `Promise` 对象，可以兑现一个名称与作为参数传递的名称相符的 `Cache` 对象。

`Cache.addAll()` 方法接收一个 URL 数组作为参数，然后会检索这些 URL，将它们的响应添加到指定的缓存中。`waitUntil()` 方法可以告诉浏览器在 Promise 被敲定前，工作仍在进行中，浏览器如果想让工作能够完成就不应该终止 service worker。浏览器负责执行并在必要时终止 service worker，`waitUntil` 方法可用于请求浏览器在任务执行时不要终止 service worker。

```js
self.addEventListener("install", (e) => {
  e.waitUntil((async () => {
      const cache = await caches.open("cacheName\_identifier");
      cache.addAll([
        "/",
        "/index.html"
        "/styles.css"
        "/app.js"
      ]);
    })()
  );
});
```

#### 任务

添加一个安装事件监听器，用于检索并存储 `APP_STATIC_RESOURCES` 列表中的文件到名为 `CACHE_NAME` 的缓存中。

#### 示例方案

```js
self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE\_NAME);
      cache.addAll(APP\_STATIC\_RESOURCES);
    })(),
  );
});
```

### 更新 PWA 并删除旧缓存

正如上文提到的，当现有的 service worker 将要被新的替换时，现有的 service worker 仍然会作为 PWA 的 service worker，直到新的 service worker 被激活。我们使用 `activate` 事件来删除旧的缓存以避免空间被耗尽。我们对被命名的 `Cache` 对象进行迭代，删除除了目前在使用外的全部缓存，然后将 service worker 设为 PWA 的 `controller`。

我们监听当前 service worker 全局作用域的 `activate` (en-US) 事件。

我们获取现有的命名缓存，使用 `CacheStorage.keys()` 方法（重新通过全局 `caches` 属性访问 `CacheStorage`），它会返回一个 `Promise` 对象，可兑现一个包含对应到按创建顺序排列的命名 `Cache` 对象的字符串的数组。

我们使用 `Promise.all()` 方法来迭代命名缓存 Promise 列表。`all()` 方法接收一个可迭代的 Promise 列表，并返回单个 `Promise`。对于列表中的每个命名缓存，检查其是否是当前活动的缓存。如果不是，用 `Cache` 的 `delete()` 方法删除它。

最后一行 `await clients.claim()` 使用 `Clients` 接口的 `claim()` 方法来启用我们的 service worker 以将其设为我们客户端的控制器，“客户端”指正在运行的一个 PWA 实例。`claim()` 方法用于使 service worker 接管控制其作用域内的所有客户端。这样，作用域内已加载的客户端就不需要重新加载。

```js
self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      const names = await caches.keys();
      await Promise.all(
        names.map((name) => {
          if (name !== CACHE\_NAME) {
            return caches.delete(name);
          }
        }),
      );
      await clients.claim();
    })(),
  );
});
```

#### 任务

将上述 `activate` 事件监听器添加至你的 `sw.js` 文件。

### fetch 事件

我们可以利用 `fetch` (en-US) 事件的优势，在用户处于在线状态时阻止已安装的 PWA 发起请求。监听 fetch 事件使得拦截所有请求并使用缓存的响应内容进行响应而不流经网络变得可能。大多数的应用不需要这样的行为。事实上，许多商业模式出于跟踪和营销目的都希望用户定期发送服务器请求。所以，尽管拦截请求对于某些应用来说是一种反面模式，但为了提高我们的经期跟踪器应用的隐私性，我们不希望应用发出不必要的网络请求。

鉴于我们的 PWA 只由单一的页面构成，对于页面导航请求，我们返回至 `index.html` 主页面，因为没有其他页面，我们也不想请求总是发送到服务器。如果 Fetch API 的 `Request` 的只读属性 `mode` 值为 `navigate`，意味着它在查找一个 Web 页面，我们使用 FetchEvent 的 `respondWith()` 方法来阻止浏览器默认的 fetch 处理，使用 `caches.match()` 方法来提供我们自己的响应 Promise。

对于其他所有的请求模式，我们则按照在 install 事件响应里的那样打开缓存，向同样的 `match()` 方法传递事件请求。它会检查请求是否是已存储的 `Response` 的键。如果是，我们返回缓存的响应，如果不是，我们返回一个 404 状态码作为响应。

使用 `Response()` 构造器，传递为 `null` 的主体以及一个 `404` 的状态码作为选项，并不意味着我们的 PWA 出错了。相反，我们需要的所有东西应该都已经在缓存里了，如果没有，我们也不用去服务器解决这个小问题。

```js
self.addEventListener("fetch", (event) => {
  // 当在查找 HTML 页面时
  if (event.request.mode === "navigate") {
    // 返回到 index.html 页面
    event.respondWith(caches.match("/"));
    return;
  }

  // 对于其他所有请求模式
  event.respondWith(
    (async () => {
      const cache = await caches.open(CACHE\_NAME);
      const cachedResponse = await cache.match(event.request.url);
      if (cachedResponse) {
        // 如果有缓存的响应可用就将其返回
        return cachedResponse;
      } else {
        // 响应 HTTP 404 状态码
        return new Response(null, { status: 404 });
      }
    })(),
  );
});
```

完善 service worker 文件
--------------------

你的 `sw.js` 文件现在应该和下面的 JavaScript 脚本相似。要注意，当 `APP_STATIC_RESOURCES` 数组中的资源列表发生了任何改动，我们唯一需要在这个 service worker 中更新的常量或功能就是 `VERSION` 的值。

```js
// 缓存的版本
const VERSION = "v1";

// 缓存的名称
const CACHE\_NAME = `period-tracker-${VERSION}`;

// 使应用运作所需的静态资源
const APP\_STATIC\_RESOURCES = [
  "/",
  "/index.html",
  "/app.js",
  "/styles.css",
  "/icons/wheel.svg",
];

// 在安装时缓存缓存静态资源
self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE\_NAME);
      cache.addAll(APP\_STATIC\_RESOURCES);
    })(),
  );
});

// 在被激活时删除旧的缓存
self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      const names = await caches.keys();
      await Promise.all(
        names.map((name) => {
          if (name !== CACHE\_NAME) {
            return caches.delete(name);
          }
        }),
      );
      await clients.claim();
    })(),
  );
});

// 在 fetch 时，拦截服务器请求并用缓存的响应内容进行响应而不流经网络
self.addEventListener("fetch", (event) => {
  // 作为一个单页应用，总是将应用定向到缓存的主页面
  if (event.request.mode === "navigate") {
    event.respondWith(caches.match("/"));
    return;
  }

  // 对于其他所有请求，先找缓存，再去网络
  event.respondWith(
    (async () => {
      const cache = await caches.open(CACHE\_NAME);
      const cachedResponse = await cache.match(event.request.url);
      if (cachedResponse) {
        // 如果有缓存的响应可用就将其返回
        return cachedResponse;
      } else {
        // 如果资源不在缓存中，返回 404
        return new Response(null, { status: 404 });
      }
    })(),
  );
});
```

当更新 service worker 本身时，不需要更新 VERSION 常量，因为 service worker 脚本内容的任何更改都会触发浏览器去安装新的 service worker。但不论如何，更新版本号是方便开发人员（也包括你自己）的好习惯。可以在浏览器中通过在应用程序工具中检查缓存的名称（或在源代码工具）来查看当前正在运行的是哪一个版本的 service worker。

**备注**：在对应用的任何资源（包括 CSS、HTML、JS 代码和图像素材）做出更改后更新版本号是非常重要的。版本号或者对 service worker 文件所做的任何更改是唯一为你的用户强制更新应用的方式。

注册 service worker
-----------------

现在我们的 service worker 脚本已经完成了，我们需要注册 service worker。

我们先从使用检查全局 `navigator` 对象上是否存在 `serviceWorker` 属性的特性检测手段来检查浏览器是否支持 Service Worker API 开始：

```html
<script>
 // “serviceWorker”是否存在
 if ("serviceWorker" in navigator) {
 // 如果存在，我们就注册 service worker
 }
</script>
```

如果这个属性受支持，我们就可以使用 service worker API 的 `ServiceWorkerContainer` 接口的 `register()` 方法。

```html
<script>
 if ("serviceWorker" in navigator) {
 // 注册应用的 service worker
 // 传递定义 worker 的文件的文件名
 navigator.serviceWorker.register("sw.js");
 }
</script>
```

虽然上述内容已经满足了经期跟踪器应用的需要，但是 `register()` 方法返回的是一个兑现 `ServiceWorkerRegistration` (en-US) 对象的 `Promise`。为了使应用更加健壮，最好对注册进行错误检查：

```js
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("sw.js").then(
    (registration) => {
      console.log("Service worker registration successful:", registration);
    },
    (error) => {
      console.error(`Service worker registration failed: ${error}`);
    },
  );
} else {
  console.error("Service workers are not supported.");
}
```

### 任务

打开 `index.html`，在 `app.js` 脚本之后、被 `</body>` 标签闭合之前添加以下 `<script>` 元素。

```html
<!-- 注册应用的 service worker -->
<script>
 if ("serviceWorker" in navigator) {
 navigator.serviceWorker.register("sw.js");
 }
</script>
```

你可以体验功能完整的 CycleTracker 月经周期跟踪 Web 应用，并可以在 GitHub 上查看该 Web 应用的源代码。是的，它能运行，并且现在正式成为了一个 PWA！

调试 service worker
-----------------

出于我们安装 service worker 的方式的因素，一旦其被注册，所有的请求都将会从缓存中拉取，而不是加载新内容。在开发的时候，你会频繁地编辑你的代码，你可能会希望定期甚至是在每一次保存后在浏览器中测试你的编辑。

### 通过更新版本号并进行强重置

要获得新的缓存，你可以更改版本号然后进行一次浏览器强刷新。进行强刷新的方式取决于你的浏览器和操作系统：

* Windows：Ctrl+F5，Shift+F5 或 Ctrl+Shift+R。
* MacOS：Shift+Command+R。
* MacOS 上的 Safari：按 Option+Command+E 清空缓存，然后按 Option+Command+R。
* 移动设备：前往浏览器（Android）或操作系统（Samsung、iOS）设置，在高级设置下找到浏览器（iOS）或站点数据（Android、Samsung）网站设置，然后删除经期跟踪器的数据，再重新加载页面。
### 使用开发者工具

你可能会不想每次保存都要更新版本号。在你准备好将你的 PWA 的新版本投入生产并为所有人提供你的 PWA 的新版本前，你可以用注销 service worker 的方法来代替在每次保存时更改版本号。

你可以通过在浏览器开发者工具中点击 `unregister` 按钮来注销一个 service worker。强刷新页面将会重新注册 service worker 并建立新的缓存。

![带有停止和注销 service worker 按钮的 Firefox 开发者工具应用程序面板](/zh-CN/docs/Web/Progressive_web_apps/Tutorials/CycleTracker/Service_workers/firefox_sw.jpg)

在某些开发者工具里，你可以手动注销一个 service worker，或者你可以选择 service worker 的“重新加载时更新”选项来设置当开发者工具打开时，每次重新加载都重置并重新激活 service worker。还有一个选项可以绕过 service worker 从网络加载资源。这个面板包含了本教程中我们未涵盖的特性，但会对你在创建包含同步和推送消息等特性的更高级的 PWA 时有所帮助，这些内容都涵盖在了离线和后台操作中。

![Edge 开发者工具显示针对 service worker 的应用程序面板集](/zh-CN/docs/Web/Progressive_web_apps/Tutorials/CycleTracker/Service_workers/edge_sw.jpg)

开发者工具的应用程序面板中的 service worker 窗口，提供了访问包含浏览器所有已注册的 service worker 的列表的弹出窗口的链接，不只是在当前标签页中打开的应用的 service worker。列表中的每个 service worker 都有单独的停止、启动或注销按钮。

![localhost:8080 存在两个 service worker，它们能在列表中被注销](/zh-CN/docs/Web/Progressive_web_apps/Tutorials/CycleTracker/Service_workers/edge_sw_list.jpg)

换句话说，当你在处理你的 PWA 时，你不需要更新每个应用视图的版本号。但是，当你做完了所有的改动后，记得在分发你的 PWA 的新版本前更新 VERSION 的值。如果你忘了，那些已经安装了你的应用又或者只是访问了你的线上 PWA 但是没有安装的人，是永远看不到你的改动的！

我们完成了！
------

PWA 的核心是一种可以被安装并可以被逐步增强至可以离线运行的 Web 应用。我们创建了一个功能完整的 Web 应用，然后添加了两项特性——一个清单文件和一个 service worker——将其转化为 PWA 所需要的东西。如果你想和其他人分享你的应用，请将其通过安全连接提供。或者，如果你只想自己使用经期跟踪器，创建一个本地部署环境，安装 PWA，然后尽情享受吧！一经安装，你就不需要再运行 localhost 了。

恭喜！

* 上一页
* Overview: CycleTracker


经期跟踪器：安全连接 - 渐进式 Web 应用（PWA）

经期跟踪器：安全连接
==========

* 上一页
* Overview: CycleTracker
* 下一页

Service worker 以及 PWA，都被限制在安全上下文中。安全上下文包括使用 `https://` 协议提供的 SSL 上下文和本地提供的资源，包括使用 `http://` 协议提供的 `127.0.0.1` 和 `localhost` URL。在本节中，我们将讨论如何在本地和远程用安全连接提供应用程序。

在上一节中，我们使用 HTML 和 CSS 创建了我们的经期追踪应用程序的壳。在这一节中，我们将在浏览器中打开经期跟踪器的静态内容，查看从本地启动的开发环境中的内容，以及在远程安全服务器上查看内容。

使用 files:// 协议查看
----------------

任何浏览器都会渲染你的 HTML。要查看你在上一节中创建的应用了 CSS 文件的 HTML 文件，可以通过你的计算机的文件结构导航到 `index.html` 文件，或者从你的浏览器使用“打开文件”菜单选项。

在更新了 `index.html`，并且将 `style.css` 存放在同一目录中之后，以窄浏览器窗口查看页面时它应该看起来类似于此截图：

![带有大标题，一个带有说明的表单，两个日期选择器和一个按钮的浅绿色网页。底部显示两个作用是占位符的月经周期和一个标题。](/zh-CN/docs/Web/Progressive_web_apps/Tutorials/CycleTracker/Secure_connection/filefile.jpg)

我们正在使用 `file://` 协议查看我们的页面。这适用于我们代码库的当前状态，并且在我们添加 JavaScript 功能之后将继续适用。然而，就像许多其他 API 一样，清单文件和 service worker（都是 PWA 的要求）需要安全连接。PWA 需要通过基于 `https` 的 web 服务器或使用 `localhost`、`127.0.0.1`（无论是否带有端口号）的本地开发环境来提供。如果我们使用 `files://` 协议查看我们完成的应用，我们的清单文件将被忽略，任何我们添加的 service worker 都将失败。

**备注：** 通过 `https` 提供你的应用不仅对 PWA 有益，而且对所有网站有益，因为它确保了在你的 web 服务器和用户的浏览器之间传输的信息是端到端加密的。许多 Web API 需要安全上下文。即使你没有创建可安装的 PWA，当你向任何 web 应用添加特性时，你都可能会遇到需要安全上下文的情况。

我们需要一个本地开发环境来完成教程。使 PWA 可安装的一部分是安全服务器。文件需要在 web 上通过安全连接提供，以获取 PWA 提供的好处，并将我们的应用程序作为 PWA 分发。

localhost
---------

根据操作系统的不同，设置本地开发环境的默认方法也不同。虽然默认的索引和配置文件的位置在不同的操作系统上可能不同，但大多数桌面操作系统都启用了你作为开发者可以接触的服务器配置。

例如，在 MacOS 上，至少在 Sierra 和 Monterey 上，输入 `sudo apachectl start` 可以启用 apache 服务器。启动服务器后，在浏览器中输入 `http://localhost` 会展示一个显示“It works!”的基本网页。默认情况下，显示的 HTML 文件是 `Library/WebServer/Documents/index.html.en`。要启用除 `.html.en` 以外的文件扩展名或将根目录从 `Library/WebServer/Documents/` 更改为其他位置，你必须编辑位于 `/etc/apache2/httpd.conf` 的 apache http 配置文件。可以使用 `sudo apachectl stop` 停止服务器。

操作系统默认的 `localhost` 有一个易记的 URL，但服务器根位置难以记忆，且配置困难。它还只允许一次在一个位置使用一个本地服务器。幸运的是，有更直观的服务器设置方法，可以在多个端口上创建一个或多个本地开发环境。

使用端口号的 localhost
----------------

有几个 IDE 扩展和特定编程语言的包可以通过单击或终端命令启动开发环境。你甚至可以启动多个本地服务器，每个使用不同的端口号。

你可以使用 VSCode 插件运行本地 HTTP 服务器，它可以在单个或不同端口上运行本地服务器。VSCode IDE 的 Preview on Web Server 扩展在编辑器当前打开的目录的根目录下创建一个服务器，默认端口为 `8080`。VS Code 扩展是可配置的。`previewServer.port` 设置项是 web 服务器的端口号。可以编辑和更改扩展的默认设置 `8080`。默认情况下，在浏览器的 URL 栏中输入 `localhost:8080` 将加载该页面。

请注意，Preview on Web Server 扩展使用 browserSync。当你的开发环境由此扩展启动时，`localhost:3001` 提供当前服务器环境的概述。

你还可以使用 IntelliJ IDE 创建本地服务器，它搭载了一个可配置的内置 PHP Web 服务器。

了解如何使用 Python 或本地服务器端语言（如 PHP）来设置本地测试服务器。

使用 npx 的 localhost
------------------

如果你已经安装了 node, 那么你可能也已经安装了 npm 和 npx。在命令行中输入 `npx -v`。如果返回了一个版本号，那么你可以使用 http-server（一个不可配置的静态 HTTP 服务器）而不需要安装任何依赖。在命令行中输入 `npx http-server [path]`，其中 `[path]` 是你的存储 index 文件的文件夹。

默认情况下，在浏览器的 URL 栏中输入 `localhost:8080` 将加载该页面。如果你已经在端口 `8080` 上启动了一个服务器，它将自动更改端口号，使用可用端口（如 `8081`）启动开发环境。

你可以选择一个不同的端口号。输入 `npx http-server /user/yourName/CycleTracker -p 8787` 将在端口 `8787` 上启动本地服务器（如果可用）。如果不可用，或者你输入的端口号已经在使用中，你将收到 `address already in use` 或类似的错误。如果成功的话，在浏览器的 URL 栏中输入 `localhost:8787` 将渲染存储在 `~/user/yourName/CycleTracker/index.html` 中的 index 文件，如果没有 index 文件，将显示 `~/user/yourName/CycleTracker/` 目录的内容。

这个不可配置静态 HTTP 服务器足以满足我们的基本应用程序。通过 `localhost` 和 `127.0.0.1` 服务的应用程序不需要 https 并始终被认为是安全的。如果出现浏览器不安全警告，可以绕过。虽然不是必需的，但是要将你的本地 Web 服务器配置为通过 HTTPS 进行服务，你可以添加内置 SSL 证书。有了证书，你将能够安装并运行 local-web-server 从命令行为你的项目提供本地 `https` 服务，以防止任何安全警告。

```bash
npm install -g local-web-server
cd ~/user/yourName/CycleTracker/
ws --https
```

在上面的命令中，你可能需要在 install 前加上 `sudo`。

**备注：** 如果你正在追求隐私，请意识到你正在自己构建此 PWA，它可以从自己的开发环境中安装在自己的机器上，而无需访问互联网。这个应用程序没有跟踪。这是一个尽可能私密的应用程序。

安全的外部服务器
--------

无论你是在学习这个 PWA 教程，还是进行其他 web 开发项目，前面的选项都是很好的，也是必不可少的，它们可以帮助你测试你的应用程序。你可以选择在自己的设备上部署你的 web 应用程序，并通过互联网向所有人开放，但这样做并不是一个好主意。

为了获得 PWA 的额外特性，包括单击安装、独立 UI 和应用商店许可，它需要成为一个 PWA，这意味着它需要一个 service worker（这意味这我们需要安全连接）。为了分发你的应用程序，使别人能够查看、使用和安装你的 PWA，你最好在安全的*远程*服务器上托管你的内容并使其可访问。

正式发布 PWA 时，你可能希望投资域名和 Web 托管。对于开源项目，开发人员可以从代码库中学习，甚至为项目做出贡献，你可以在 GitHub Pages 上托管你的进度。

GitHub pages
------------

你可以在 GitHub 上查看经期跟踪器应用程序的最新状态，它安全地托管在 https://mdn.github.io/pwa-examples/cycletracker/html\_and\_css 这个网址上。我们将文件发布到 MDN GitHub 帐户。同样，如果你有一个 GitHub 帐户，你也可以发布到你的帐户。只需注意，虽然通过 SSL 安全地提供服务，但 GitHub 上的操作不一定是私人的，所有 GitHub 页面都是公共的。如果你生活在一个跟踪月经周期的压迫政府掌控的地区，请考虑复制粘贴代码而不是分叉它。

要创建公开可用的安全站点，请创建 GitHub pages 站点。创建一个名为 `<username>.github.io` 的仓库，其中 `<username>` 是你的 GitHub 用户名。创建一个 `gh-pages` 分支。你的应用程序的这个分支将可在 `https://<username>.github.io` 访问。

如前所述，所有 GitHub 页面都可以通过互联网公开访问，即使你将仓库设置为私有。由于数据使用 localStorage 保存，因此通过 GitHub URL 可以使用该应用程序，但用户的数据只能在输入数据的那台设备的那个浏览器上访问。直接删除 localStorage（这是在浏览器中完成的）将删除所有存储的数据。

如果你不希望你的 PWA 成为顶级应用，那么你可以让你的应用程序看起来像它驻留在子目录中。你可以在 `<username>.github.io` 仓库中创建子目录，或者从你的 PWA 的单独仓库发布。通过在 PWA 仓库中配置发布源, 你的应用程序将可在 `https://<username>.github.io/<repository>` 可见，其中 `<repository>` 是仓库的名称。你可以设置 GitHub 在将更改发布到该仓库内的特定分支（包括 `main`）时自动发布你的网站。

就经期跟踪器演示应用程序在各个开发阶段而言，`<username>` 是 `mdn`, 仓库是 `pwa-examples`。因为这个仓库有多个示例 PWA, 每个示例都有几个开发过程中的进度，文件和 PWA 嵌套了几层。

请注意，你可以为 GitHub 页面站点配置自定义域名。

接下来
---

我们能够查看我们将要构建的经期跟踪器应用程序外壳的样式化静态版本。现在我们知道了如何查看我们即将构建的应用程序，那么让我们开始构建它。接下来，我们创建 `app.js`，这是将我们的静态设计转换为功能完备 Web 应用程序的 JavaScript, 该应用程序在用户的机器上本地存储数据。

* 上一页
* Overview: CycleTracker
* 下一页


经期跟踪器：JavaScript 功能 - 渐进式 Web 应用（PWA）

经期跟踪器：JavaScript 功能
===================

* 上一页
* Overview: CycleTracker
* 下一页

在上一节中，我们编写了经期跟踪器（CycleTracker）的 HTML 和 CSS，创建了我们的 Web 应用的静态版本。在本节中，我们将编写将静态 HTML 转换为功能完全 Web 应用所需的 JavaScript。

如果你还没有这么做，复制 HTML 和 CSS 并将它们保存到名为 `index.html` 和 `styles.css` 的文件中。

HTML 文件的最后一行调用了 `app.js` JavaScript 文件。这就是我们在本节中要创建的脚本。在这节课中，我们将编写客户端 JavaScript 代码来捕获表单提交，local storage 提交的数据，以及填充过去经期部分。

在本课结束时，你将拥有一个功能完全的应用程序。在未来的课程中，我们将逐步增强应用程序，创建一个即使用户离线也可以工作的完全可安装的 PWA。

JavaScript 任务
-------------

当用户访问页面时，我们检查他们是否在 local storage 中保存有现有数据。用户首次访问页面时，不会有任何数据。当新用户选择两个日期并提交表单时，我们需要：

1. 创建一个“`<h2>Past perious</h2>`”标题
2. 创建一个 `<ul>`
3. 用关于该经期的信息填充一个 `<li>` 到 `<ul>` 中
4. 将数据保存到 local storage 中

对于每个后续的表单提交，我们需要：

1. 将新的经期添加到当前列表中
2. 按日期顺序对列表进行排序
3. 用新列表重新填充 `<ul>`，每个经期一个 `<li>`
4. 将数据追加到我们保存的 local storage 中

现有用户在 local storage 中将有现有的数据。当用户使用相同的浏览器在相同的设备上返回我们的网页时，我们需要：

1. 从 local storage 中检索数据
2. 创建一个“`<h2>Past periods</h2>`”标题
3. 创建一个 `<ul>`
4. 用在 local storage 中保存的每个经期的 `<li>` 填充 `<ul>`。

这是一个初级级别的演示应用程序。目标是教授将 web 应用程序转换为 PWA 的基础知识。该应用程序不包含表单验证、错误检查、编辑或删除功能等必要特性。你可以自由扩展所涵盖的特性，并根据你的学习目标和应用程序需求定制课程和应用程序。

表单提交
----

页面包含一个 `<form>`，用于选择每个经期的开始日期和结束日期。日期选择器是类型为 日期 的 `<input>`，其 `id` 分别为 `start-date` 和 `end-date`。

表单没有方法或动作。取而代之的是，我们使用 `addEventListener()` 为表单添加了一个事件监听器。当用户尝试提交表单时，我们阻止表单提交，存储新的经期，渲染这个经期以及之前的经期，然后重置表单。

```js
// 创建表单和表单控件的常量
const newPeriodFormEl = document.getElementsByTagName("form")[0];
const startDateInputEl = document.getElementById("start-date");
const endDateInputEl = document.getElementById("end-date");

// 监听表单提交。
newPeriodFormEl.addEventListener("submit", (event) => {
  // 阻止表单向服务器提交
  // 因为所有操作都在客户端进行。
  event.preventDefault();

  // 从表单中获取开始日期和结束日期。
  const startDate = startDateInputEl.value;
  const endDate = endDateInputEl.value;

  // 检查日期是否无效
  if (checkDatesInvalid(startDate, endDate)) {
    // 如果日期无效，退出。
    return;
  }

  // 将新的经期存储在我们的客户端存储中。
  storeNewPeriod(startDate, endDate);

  // 刷新 UI。
  renderPastPeriods();

  // 重置表单。
  newPeriodFormEl.reset();
});
```

在使用 `preventDefault()` 阻止表单提交后，我们：

1. 验证用户输入；如果无效则退出，
2. 通过检索、解析、追加、排序、字符串化和重新存储数据在 localStorage 中存储新的经期，
3. 渲染表单数据以及过去月经经期的数据和标题部分，并且
4. 使用 HTMLFormElement 的 `reset()` 方法重置表单。
### 验证用户输入

我们检查日期是否无效。我们做了最小的错误检查。我们确保两个日期都不是空值，`required` 属性应该可以防止这种情况发生。我们还检查开始日期是否大于结束日期。如果有错误，我们会清除表单。

```javascript
function checkDatesInvalid(startDate, endDate) {
  // 检查结束日期是否在开始日期之后，两个日期都不是空值
  if (!startDate || !endDate || startDate > endDate) {
    // 为了使验证健壮，我们可以：
    // 1. 根据错误类型添加错误消息
    // 2. 通知无障碍技术用户错误信息
    // 3. 将焦点移动到错误位置
    // 但是，为了简单起见，如果两个日期中一个
    // 或两个无效，我们清除日期
    newPeriodFormEl.reset();
    // 由于日期无效，我们返回 true
    return true;
  }
  // 否则
  return false;
}
```

在这个应用程序的一个更健壮的版本中，我们至少要包含错误消息来通知用户存在错误。一个好的应用程序会通知用户错误是什么，将焦点放在有问题的表单控件上，并使用 ARIA 实时区域来通知无障碍技术用户错误信息。

Local storage
-------------

我们使用 Web 存储 API，具体来说是 window.localStorage，以字符串化的 JSON 对象形式存储开始日期和结束日期对。

LocalStorage 有一些局限性，但对我们的应用需求足够了。我们使用 localStorage 是为了让它简单且仅存储在客户端上。这意味着数据将只存储在单个设备上的单个浏览器上。清除浏览器数据也会失去所有存储在 local storage 的经期。对许多应用程序来说这可能看起来是一个局限，但对于这个应用程序来说，月经经期数据是个人的，并且使用此类应用程序的用户可能非常关心隐私。

对于更健壮的应用程序，其他客户端存储选项比如 IndexDB（IDB）和后面讨论的 service worker 具有更好的性能。

`localStorage` 的局限包括：

* 有限的数据存储：每个源的 `localStorage` 限制为 5MB 数据。我们的存储需求远远少于这个数字。
* 仅存储字符串：`localStorage` 将数据存储为字符串键和字符串值对。我们的开始和结束日期将存储为被解析为字符串的 JSON 对象。对于更复杂的数据，需要一个更健壮的存储机制，比如 IDB。
* 可能导致性能问题：获取和设置 local storage 是在主线程上同步完成的。当主线程被占用时，应用程序不会响应，就跟被冻住了一样。考虑到这个应用程序的简单性质，这丁点不好的用户体验可以忽略。
* 只能被主线程访问：除了占用主线程带来的性能问题之外，service worker 无法访问主线程，这意味着 service worker 不能直接设置或获取 local storage 数据。

#### 检索、追加、排序和重新存储数据

因为我们使用包含单个字符串的 localStorage，所以我们从 local storage 检索 JSON 字符串数据，解析 JSON 数据（如果有的话），将新日期对推入现有数组，对日期排序，再次将 JSON 对象解析为字符串，并将该字符串保存回 `localStorage`。

这个过程需要创建一些函数：

```javascript
// 将存储键添加为应用范围内的常量
const STORAGE\_KEY = "period-tracker";

function storeNewPeriod(startDate, endDate) {
  // 从存储中获取数据
  const periods = getAllStoredPeriods();

  // 将新的经期对象添加到经期对象数组的末尾
  periods.push({ startDate, endDate });

  // 对数组排序，以便经期按开始日期排序，从最新到最旧
  periods.sort((a, b) => {
    return new Date(b.startDate) - new Date(a.startDate);
  });

  // 将更新后的数组存储回存储区
  window.localStorage.setItem(STORAGE\_KEY, JSON.stringify(periods));
}

function getAllStoredPeriods() {
  // 从 localStorage 获取经期数据字符串
  const data = window.localStorage.getItem(STORAGE\_KEY);

  // 如果没有存储经期，默认为空数组
  // 否则，将存储的数据作为解析后的 JSON 返回
  const periods = data ? JSON.parse(data) : [];

  return periods;
}
```

### 将数据渲染到屏幕上

我们应用程序的最后一步是将过去经期列表渲染到屏幕上，并添加标题。

在我们的 HTML 中，我们添加了一个 `<section id="past-periods">` 占位符来容纳标题和过去经期列表。

将容器元素添加到你的脚本顶部的内容列表中。

```js
const pastPeriodContainer = document.getElementById("past-periods");
```

接着，我们需要检索解析后的过去经期字符串或空数组。如果为空，则退出。如果过去经期存在，会清除 past period 容器的当前内容。然后，创建一个标题和一个无序列表，并循环遍历过去的经期，添加包含格式化的起始日期和终止日期的列表项。

```javascript
function renderPastPeriods() {
  // 获取解析后的经期字符串，或空数组
  const periods = getAllStoredPeriods();

  // 如果没有经期，退出
  if (periods.length === 0) {
    return;
  }

  // 清除过去经期列表，因为我们要重新渲染它
  pastPeriodContainer.innerHTML = "";

  const pastPeriodHeader = document.createElement("h2");
  pastPeriodHeader.textContent = "Past periods";

  const pastPeriodList = document.createElement("ul");

  // 循环渲染所有经期
  periods.forEach((period) => {
    const periodEl = document.createElement("li");
    periodEl.textContent = `from ${formatDate(
 period.startDate,
 )} to ${formatDate(period.endDate)}`;
    pastPeriodList.appendChild(periodEl);
  });

  pastPeriodContainer.appendChild(pastPeriodHeader);
  pastPeriodContainer.appendChild(pastPeriodList);
}

function formatDate(dateString) {
  // 将日期字符串转换为 Date 对象
  const date = new Date(dateString);

  // 将日期格式化为特定于语言环境的字符串
  // 包括你的语言环境以获得更好的用户体验
  return date.toLocaleDateString("en-US", { timeZone: "UTC" });
}
```

加载时渲染过去的经期
----------

当延迟的 JavaScript 在页面加载时运行时，如果有过去的经期的话，渲染它们。

```javascript
// 通过渲染过去的经期启动应用程序
renderPastPeriods();
```

完整的 JavaScript
--------------

你的 `app.js` 文件中的 JavaScript 代码应该类似如下：

```javascript
const newPeriodFormEl = document.getElementsByTagName("form")[0];
const startDateInputEl = document.getElementById("start-date");
const endDateInputEl = document.getElementById("end-date");
const pastPeriodContainer = document.getElementById("past-periods");

// 将存储键添加为应用范围内的常量
const STORAGE\_KEY = "period-tracker";

// 监听表单提交
newPeriodFormEl.addEventListener("submit", (event) => {
  event.preventDefault();
  const startDate = startDateInputEl.value;
  const endDate = endDateInputEl.value;
  if (checkDatesInvalid(startDate, endDate)) {
    return;
  }
  storeNewPeriod(startDate, endDate);
  renderPastPeriods();
  newPeriodFormEl.reset();
});

function checkDatesInvalid(startDate, endDate) {
  if (!startDate || !endDate || startDate > endDate) {
    newPeriodFormEl.reset();
    return true;
  }
  return false;
}

function storeNewPeriod(startDate, endDate) {
  const periods = getAllStoredPeriods();
  periods.push({ startDate, endDate });
  periods.sort((a, b) => {
    return new Date(b.startDate) - new Date(a.startDate);
  });
  window.localStorage.setItem(STORAGE\_KEY, JSON.stringify(periods));
}

function getAllStoredPeriods() {
  const data = window.localStorage.getItem(STORAGE\_KEY);
  const periods = data ? JSON.parse(data) : [];
  console.dir(periods);
  console.log(periods);
  return periods;
}

function renderPastPeriods() {
  const pastPeriodHeader = document.createElement("h2");
  const pastPeriodList = document.createElement("ul");
  const periods = getAllStoredPeriods();
  if (periods.length === 0) {
    return;
  }
  pastPeriodContainer.innerHTML = "";
  pastPeriodHeader.textContent = "Past periods";
  periods.forEach((period) => {
    const periodEl = document.createElement("li");
    periodEl.textContent = `from ${formatDate(
 period.startDate,
 )} to ${formatDate(period.endDate)}`;
    pastPeriodList.appendChild(periodEl);
  });

  pastPeriodContainer.appendChild(pastPeriodHeader);
  pastPeriodContainer.appendChild(pastPeriodList);
}

function formatDate(dateString) {
  const date = new Date(dateString);
  return date.toLocaleDateString("en-US", { timeZone: "UTC" });
}

renderPastPeriods();
```

你可以试试完全可用的 CycleTracker 经期跟踪 Web 应用，并在 GitHub 上查看 Web 应用源代码。是的，它可用，但还不是 PWA。

接下来
---

从本质上讲，PWA 是一个可以安装的 Web 应用程序，可以渐进增强以离线工作。现在我们已经有了一个完全可用的 Web 应用程序，我们添加将其转换为 PWA 所需的功能，包括清单文件、安全连接 和 service worker。

首先，我们为经期跟踪器创建清单文件，包括我们的经期跟踪器 PWA 的标识、外观和图标。

* 上一页
* Overview: CycleTracker
* 下一页


经期跟踪器：基础 HTML 和 CSS - 渐进式 Web 应用（PWA）

经期跟踪器：基础 HTML 和 CSS
===================

* 上一页
* Overview: CycleTracker
* 下一页

要构建渐进式 Web 应用程序（PWA），我们需要创建一个功能完备的 Web 应用程序。在本节中，我们将为静态网页标记 HTML，并使用 CSS 来增强其外观。

我们的项目是创建经期跟踪器（CycleTracker），一个月经周期跟踪器。在这个入门级的 PWA 教程 (en-US)的第一步，我们将编写 HTML 和 CSS。页面的上部分是一个表单，供用户输入每个月经周期的开始和结束日期。下部分是以前月经周期的列表。

我们创建一个 HTML 文件，其头部包含元数据，静态网页包含一个表单和一个用于显示用户输入数据的占位符。然后我们将添加一个外部 CSS 样式表来提升网站的外观。

要完成本教程，最好对 HTML、CSS 和 JavaScript 有基本的了解。如果你对这些不熟悉，MDN 提供了入门指南入门指南——一套介绍 Web 开发的系列教程。

在接下来的章节中，我们将设置一个本地开发环境，然后在添加 JavaScript 功能来将本节中创建的静态内容转换为功能性的 Web 应用程序之前，我们会看看我们的进展。一旦我们有了一个功能性的应用，我们就会有可以渐进增强为可安装的、离线工作的 PWA 的东西。

静态网页内容
------

我们的静态站点 HTML，用 `<link>` 和 `<script>` 元素作为尚未创建的外部 CSS 和 JavaScript 文件的占位符，如下：

```html
<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>Cycle Tracker</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <h1>Period tracker</h1>
    <form>
      <fieldset>
        <legend>Enter your period start and end date</legend>
        <p>
          <label for="start-date">Start date</label>
          <input type="date" id="start-date" required />
        </p>
        <p>
          <label for="end-date">End date</label>
          <input type="date" id="end-date" required />
        </p>
      </fieldset>
      <p>
        <button type="submit">Add Period</button>
      </p>
    </form>
    <section id="past-periods"></section>
    <script src="app.js" defer></script>
  </body>
</html>
```

复制此 HTML 并保存在名为 `index.html` 的文件中。

HTML 内容
-------

即使 `index.html` 中的 HTML 对你来说已经很熟悉，在添加一些临时的硬编码数据、将 CSS 添加到 `styles.css` 外部样式表和创建 `app.js`（应用程序的 JavaScript，使这个网页起作用）之前，我们还是建议你通读一遍这一节。

HTML 的第一行是一个 doctype 弁言（preamble），可以确保内容的正确行为。

```html
<!doctype html>
```

根 `<html>` 标签将所有内容包裹在具有 `lang` 属性的标签中，该属性定义了页面的主要语言。

```html
<!doctype html>
<html lang="en-US">
  <!-- <head> 和 <body> 将在这里 -->
</html>
```

### 文档头部

`<head>` 包含了关于网页应用程序的不能被读者看到的机器可读信息，除了 `<title>` 之外，它显示在浏览器标签的标题中。

`<head>` 包含了所有的元数据。你的 `<head>` 中的前两项信息应该总是字符集定义，它定义了字符编码，和 viewport `<meta>` 标签，它可以确保页面以视口的宽度渲染，而不会在非常小的屏幕上被缩小。

```html
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width" />
</head>
```

我们使用 `<title>` 元素将页面标题设置为“Cycle Tracker”。尽管 `<head>` 的内容不会在页面中显示，但是 `<title>` 的内容会显示！当页面加载时，`<title>` 元素的内部文本会出现在浏览器标签页中，还会出现在搜索引擎结果中，并且当用户收藏网页进书签时，它是默认的标题。对于依赖标题来了解他们当前所在标签页的屏幕阅读器用户来说，标题也提供了一个无障碍的名称。

虽然标题可以是“月经周期追踪应用程序”，但我们选择了一个更谨慎的缩写名称。

```html
<title>Cycle Tracker</title>
```

尽管这两个 `<meta>` 标签和 `<title>` 在技术上是可选的，但为了更好的用户体验，它们应该被视为 HTML 文档所必需的 `<head>` 的三个组成部分。

现在，我们在 `<head>` 中包含的最后一个组件是一个 `<link>` 元素，它将 `styles.css`（我们尚未编写的样式表）链接到我们的 HTML。

```html
<link rel="stylesheet" href="style.css" />
```

HTML `<link>` 元素用于指定当前文档与外部资源之间的关系。`rel` 属性有超过 25 个定义的值——还有许多规范中没有定义的值。最常见的值 `rel="stylesheet"` 导入外部资源作为样式表。

在后面的小节中，当我们包括 manifest 文件的链接 时，我们将重新回顾 `<link>` 元素及其 `rel` 属性。

### 文档主体

`<body>` 元素包含了我们想要在用户访问该网站时显示的所有内容。

在 `<body>` 中，我们包含了以应用程序的名称为一级标题的 `<h1>`以及一个 `<form>`。

```html
<body>
  <h1>Period tracker</h1>
  <form></form>
</body>
```

表单将包含说明、表单控件、每个表单控件的标签和一个提交按钮。就表单控件而言，我们需要用户为每次提交的月经周期输入起始日期和结束日期。

在 `<form>` 中，我们包含一个带有 `<legend>` 的 `<fieldset>`，用来标记那一组表单字段的用途。

```html
<form>
  <fieldset>
    <legend>Enter your period start and end date</legend>
  </fieldset>
</form>
```

日期选择器是类型为 date 的 `<input>` 元素。我们包含 `required` (en-US) 属性，通过防止用户意外提交不完整的表单，来减少用户错误。

为了将 `<label>` 与表单控件相关联，每个 `<input>` 都有一个 `id` 属性，它与相关联的 `<label>` 的 `for` (en-US) 属性匹配。相关的标签为每个 `<input>` 提供了一个无障碍 (en-US)。

```html
<label for="start-date">Start date</label>
<input type="date" id="start-date" required />
```

把它组合在一起，在 `<fieldset>` 中，我们包含了两个段落（`<p>` 元素），每个段落都有一个日期选择器用于输入当前月经周期的开始和结束日期，以及日期选择器的相关 `<label>`。我们还包含了一个 `<button>` 元素来提交表单；通过在开始标签（tag）和结束标签之间包括“Add period”文本为其添加标签（label）。`type="submit"` 是可选的，因为 `submit` 是 `<button>` 的默认类型。

```html
<form>
  <fieldset>
    <legend>Enter your period start and end date</legend>
    <p>
      <label for="start-date">Start date</label>
      <input type="date" id="start-date" required />
    </p>
    <p>
      <label for="end-date">End date</label>
      <input type="date" id="end-date" required />
    </p>
  </fieldset>
  <p>
    <button type="submit">Add Period</button>
  </p>
</form>
```

我们鼓励你学习更多关于制作无障碍 web 表单的知识。

### 临时硬编码结果文本

然后，我们包含一个空的 `<section>`。这个容器将使用 JavaScript 填充。

```html
<section id="past-periods"></section>
```

当用户提交表单时，我们将使用 JavaScript 来获取数据，并呈现过去月经周期的列表以及该部分的标题。

就目前而言，我们在这个 `<section>` 中临时硬编码一些内容，包括一个 `<h2>` 标题和几个过去的月经周期，以便在编写页面 CSS 时有一些内容进行样式设置。

```html
<section id="past-periods">
  <h2>Past periods</h2>
  <ul>
    <li>From 01/01/2024 to 01/06/2024</li>
    <li>From 01/29/2024 to 02/04/2024</li>
  </ul>
</section>
```

除了容器 `<section id="past-periods"></section>` 之外，这里的内容都是临时的。一旦我们完成 CSS 并对应用程序的外观感到满意后，我们将移除或注释掉这里的临时数据。

### JavaScript 链接

在关闭 `</body>` 之前，我们包含一个指向将要编写的 `app.js` JavaScript 文件的链接。我们包含 `defer` 属性来延迟加载这个脚本，并确保在文档的 HTML 被解析后执行 JavaScript。

```html
<script src="app.js" defer></script>
```

`app.js` 文件将包含我们应用程序的所有工作内容，包括 `<button>` 的事件处理程序、将提交的数据保存到 local storage 以及在页面内容中显示周期。

这个步骤的 HTML 文件现在完成了！你现在可以在浏览器中打开该文件，但是你会注意到它非常简单。我们将在下一节中解决这个问题。

CSS 内容
------

我们现在可以使用 CSS 来设计静态的 HTML 的样式。我们的最终 CSS 是：

```css
body {
  margin: 1vh 1vw;
  background-color: #efe;
}
ul,
fieldset,
legend {
  border: 1px solid;
  background-color: #fff;
}
ul {
  padding: 0;
  font-family: monospace;
}
li,
legend {
  list-style-type: none;
  padding: 0.2em 0.5em;
  background-color: #cfc;
}
li:nth-of-type(even) {
  background-color: inherit;
}
```

如果每一行你都很熟悉，你可以复制上面的 CSS，或者编写你自己的 CSS，并将文件保存为 `style.css`，然后完成静态 HTML 和 CSS。如果上面的 CSS 中有任何你不了解的内容，请继续阅读以获得解释。

![浅绿色的网页，有一个大标题、一个带有说明（legend）的表单、两个日期选择器和一个按钮。底部显示了两个月经周期的假数据和一个标题。](/zh-CN/docs/Web/Progressive_web_apps/Tutorials/CycleTracker/HTML_and_CSS/html.jpg)

### CSS 解释

我们使用 `background-color` 属性在 `body` 上设置了浅绿色（`#efe`）的背景颜色。然后在无序列表、fieldset 和 legend 上，我们使用白色（`#fff`）作为背景颜色，并使用 `border` 属性添加细实线边框。我们覆盖 legend 的 `background-color`，使 legend 和列表项变成深绿色（`#cfc`）。

我们使用 `:nth-of-type(even)` 伪类选择器将每个偶数列表项设置为 `inherit` 其父元素的背景颜色；在这种情况下，继承来自无序列表的 `#fff` 背景颜色。

```css
body {
  background-color: #efe;
}
ul,
fieldset,
legend {
  border: 1px solid;
  background-color: #fff;
}
li,
legend {
  background-color: #cfc;
}
li:nth-of-type(even) {
  background-color: inherit;
}
```

为了使无序列表和列表项看起来不像一个列表，我们通过在 `ul` 上设置 `padding: 0` 来删除内边距，并通过在列表项本身上设置 `list-style-type: none` 来删除列表标记。

```css
ul {
  padding: 0;
}
li {
  list-style-type: none;
}
```

我们通过使用 `vw` 和 `vh` 视口单位来设置 `body` 的 `margin`，使我们应用程序外部的空白与视口大小成比例。我们还在 `li` 和 `legend` 添加了一点内边距。最后，为了改善（但不是修复）过去周期数据的对齐情况，我们将结果部分的 `ul` 的 `font-family` 设置为 `monospace`，使每个字形具有相同的固定宽度。

```css
body {
  margin: 1vh 1vw;
}
ul {
  font-family: monospace;
}
li,
legend {
  padding: 0.2em 0.5em;
}
```

我们可以把上面的内容组合在一起，在每个选择器声明块中放置多个属性。我们甚至可以把 `li` 和 `legend` 的样式放在一起；不相关的样式，比如在 `legend` 上的 `list-style-type` 声明，则会被忽略。

```css
body {
  margin: 1vh 1vw;
  background-color: #efe;
}
ul,
fieldset,
legend {
  border: 1px solid;
  background-color: #fff;
}
ul {
  padding: 0;
  font-family: monospace;
}
li,
legend {
  list-style-type: none;
  padding: 0.2em 0.5em;
  background-color: #cfc;
}
li:nth-of-type(even) {
  background-color: inherit;
}
```

如果你仍然不太熟悉上面的 CSS，你可以查看 CSS 属性和选择器，或者学习 CSS 入门教程。

无论你是逐字使用上面的 CSS，根据自己的偏好编辑上述样式，还是从头开始编写自己的 CSS，请将所有 CSS 放在一个新文件中，并将其保存为与 `index.html` 文件位于同一目录下的 `style.css`。

### 完成我们 PWA 的静态 HTML 和 CSS

在继续之前，注释或者删除假的过去月经周期数据和标题：

```html
<section id="past-periods">
  <!--
 <h2>Past periods</h2>
 <ul>
 <li>From 01/01/2024 to 01/06/2024</li>
 <li>From 01/29/2024 to 02/04/2024</li>
 </ul>
 -->
</section>
```

接下来
---

在添加 JavaScript 功能将这个静态内容转换成网页应用程序，然后通过 manifest 文件和 service worker 增强成为渐进式网页应用之前，我们将创建本地开发环境来查看我们的进度。

在那之前，你可以查看静态 CycleTracker 外壳，并从 GitHub 下载 CycleTracker HTML 和 CSS 源代码。

* 上一页
* Overview: CycleTracker
* 下一页


渐进式 Web 应用的最佳实践 - 渐进式 Web 应用（PWA）

渐进式 Web 应用的最佳实践
===============

渐进式 Web 应用（PWA）可以安装在设备上，并能像传统网站一样在 Web 浏览器中使用，因此 PWA 需要能够适应不同的环境和用户期望。

本文提供了一系列最佳实践，以帮助你确保你的 PWA 尽可能完善。

适配所有浏览器
-------

你的 PWA 基于 Web 技术。这意味着除了可以安装在设备上之外，PWA 也可以在 Web 浏览器中运行。为了确保兼容性，非常重要的是在各种浏览器和操作系统上测试你的应用。

考虑到用户可能使用的各种浏览器，满足广泛的潜在用户需求。使用特性检测可以让你为最广泛的受众提供可用的体验。

功能检测还有助于实现渐进增强，这是一种设计理念，可以让尽可能多的用户获得出色的体验。

通过渐进增强，你首先专注于使用最简单的技术使应用的核心功能可以在所有浏览器上运行，然后增强支持的设备上的体验。

例如，使用 HTML `<form>` 元素处理表单提交意味着该表单将在所有浏览器上工作，包括不支持 JavaScript 的浏览器。然后，你可以通过添加客户端验证和基于 JavaScript 的提交处理来逐步增强表单，以在兼容设备上获得更好的体验。

适配所有设备
------

类似于在各种浏览器上测试你的应用程序的重要性，跨设备测试也能确保你的应用能覆盖到更广泛的用户群体。

对于 PWA 来说，响应式设计对于确保任何屏幕大小的设备都能访问内容至关重要。用户都应能不受其设备屏幕大小的限制而访问所有的功能和内容。通过对不同视口大小重新排列内容，你可以优先显示重要的数据和操作。

确保用户可以通过任何访问你的内容的方式与你的应用程序进行交互。支持键盘和鼠标，以及触摸或触控笔输入方法。确保所有应用程序功能都可以通过任何输入方式访问。

最后，使用语义化的 HTML 元素而不是重新创建自己的按钮或表单元素，因为语义化的 HTML 元素可以直接支持所有用户输入方式。

提供离线体验
------

安装的应用程序的用户希望它们始终可用，即使在连接速度较慢或不可靠的网络或设备完全离线时也是如此。

### 自定义离线页面

至少，你的 PWA 应该提供一个自定义离线页面，告知用户他们处于离线状态，而不是显示通用的浏览器错误页面。自定义离线页面在不同的浏览器和设备上提供更一致的体验，并保持用户与你的应用程序的互动。

你可以通过使用 service worker 拦截网络请求，并在用户离线时响应并提供自定义离线页面。

### 离线操作

为了进一步提供类似应用程序的体验，你的 PWA 应该在用户离线时也能正常工作。这意味着用户可以在离线状态下继续使用你的应用程序的部分或全部功能。

考虑以下场景：用户撰写了一封长电子邮件并按下“发送”按钮，却没有意识到他们失去了网络连接。由于你的应用程序支持离线工作，该电子邮件将在本地保存，并在设备恢复在线状态时自动发送。

了解更多关于离线和后台操作的信息。

### 支持深链接

深链接是指指向应用程序域内特定页面的超链接。例如，你的应用程序的主页可以在 `https://example.com/` 上访问，但你也可以链接到特定产品页面，如 `https://example.com/products/123`。

能够通过唯一的 URL 引用任何资源是 Web 的最强大的特性之一。由于它们构建在 Web 技术上，PWA 可以并且应该利用这个特性。

通过唯一的 URL 使应用程序的不同部分可用，允许用户收藏夹、直接导航和在应用程序中共享特定内容。它还允许搜索引擎索引你的应用程序内容，并使其可以通过 web 搜索被发现。

### 提供快速的体验

用户对安装的应用程序和网站有不同的期望。用户预期网站在加载和导航上需要时间，特别是在网络连接较差的情况下。然而，他们期望安装的应用程序始终快速并能够提供响应。

应用程序加载和执行核心功能的速度在用户参与和留存方面起着关键作用。应用程序响应时间越长，用户放弃的可能性就越大。

有工具、API 和最佳实践可帮助衡量和提高性能。了解更多信息，请参阅 web 性能。

### 提供无障碍体验

无障碍对于确保每个人都可以使用你的应用程序至关重要，无论个人能力或其用于访问你的应用程序的设备如何。无障碍确保尽可能多的人可以使用你的应用程序。无障碍也是法律所要求的。此外，无障碍通常会为所有人（而不仅仅是那些有永久或临时残疾的人）带来更好的用户体验。

参见无障碍文档，以了解如何使你的应用程序提供无障碍体验。

### 提供类似应用的体验

#### 与操作系统集成

用户期望安装的 PWA 与任何安装的特定平台应用程序一样运行。为了提供用户期望的类似应用的体验，你可以以以下方式将你的应用程序与操作系统集成：

* 使用通知 API 向用户设备发送通知。
* 使用 `file_handlers` (en-US) Web 应用清单成员处理文件。
* 在应用图标上显示标记（badge） (en-US)。
* 启用应用程序之间的数据共享 (en-US)。

许多 Web 应用程序清单成员可用于自定义应用程序在用户设备上的显示方式，并在操作系统中更深入地集成。

#### 应用的外观和感觉

用户安装应用程序是为了获得比网站更专注的体验，并更高效地完成任务。他们期望应用程序更简洁，减少杂乱，并专注于最重要的任务。

通过考虑以下指南，确保你的 PWA 提供类似应用的体验：

* 使用独立显示模式 (en-US)为你的应用程序提供自己的独立窗口。
* 定义应用程序图标 (en-US)。
* 使用 `prefers-color-scheme` 媒体特性检测用户偏好的主题，并相应地调整应用的主题。
* 自定义应用的主题和背景颜色 (en-US)，以提供更精细的体验，使其更像一个特定于平台的应用程序。
* 简化内容，专注于应用程序允许用户完成的最重要的任务。这可能意味着删除传统网站上常见的大标题和页脚，而改用菜单隐喻来代替。
* 使用 `system-ui` `font-family`，使你的内容感觉更像是原生于平台，并且在无需用户下载自定义字体的情况下加载速度更快。
参见
--

* 如何创建一个良好的渐进式 Web 应用，来自 web.dev（2022）。
* 渐进式 Web 应用的最佳实践，来自 learn.microsoft.com（2023）。


什么是渐进式 web 应用？ - 渐进式 Web 应用（PWA）

什么是渐进式 web 应用？
==============

渐进式 web 应用（PWA）是一种使用 web 平台技术构建的应用，但它提供了类似于平台特定应用的用户体验。

平台特定应用
------

*平台特定应用*是针对特定操作系统（OS）和/或设备类别开发的，例如 iOS 或 Android 设备，通常使用平台供应商提供的 SDK。它们通常通过供应商的应用商店分发，用户可以在那里找到和安装它们，它们随后对用户来说就像是他们设备的一个永久附加特性，以某种方式扩展了其功能。

平台特定应用的好处包括：

* **用户易于访问**：它们在设备上有自己的图标，使用户容易找到和打开它们。
* **离线和后台操作**：它们能够在用户没有与之交互和设备离线时运行。这使得例如聊天应用可以在没有打开时接收消息，并向用户显示通知。它还使得新闻应用可以在后台更新，以便即使设备离线也可以显示新鲜的内容。
* **专用 UI**：它们可以实现自己独特的、沉浸式的 UI。
* **操作系统集成**：它们可以与主机操作系统集成：例如，一个消息应用可以注册为一个分享目标，使用户可以在照片应用中选择一张图片并使用消息应用发送。它们还可以访问设备特性，如相机、GPS 或加速度计。
* **应用商店集成**：它们通过应用商店分发，给用户一个单一的地方来找到它们，并且提供一种一致的方式来决定是否要安装它们。
传统网站
----

传统上，网站更像是“用户访问的地方”，而不是“用户拥有的东西”。通常，一个网站：在用户不访问它时，在用户的设备上没有存在感，只能通过用户打开浏览器并导航到该网站来访问，而且高度依赖于网络连接。

然而，网站相比平台特定应用有一些好处，包括：

* **单一代码库**：因为 web 本身就是跨平台的，一个网站可以从一个代码库运行在不同的操作系统和设备类别上。
* **通过 web 分发**：web 是一个很好的分发平台。网站被搜索引擎索引，并且可以通过 URL 来分享和访问。一个网站可以分发而无需注册任何供应商的应用商店。
渐进式 web 应用
----------

渐进式 web 应用结合了传统网站和平台特定应用的最佳特性。

PWA 具有网站的优势，包括：

* PWA 是使用标准的 web 平台技术开发的，所以它们可以从单一代码库在多个操作系统和设备类上运行。
* PWA 可以直接通过 web 访问。

PWA 也具有平台特定应用程序的许多优势，包括：

* **PWA 可以安装在设备上**。这意味着：
    + PWA 可以从平台的应用商店安装，也可以直接从 web 安装。
    + PWA 可以像平台特定的应用程序一样安装，并可以自定义安装过程。
    + 一旦安装，PWA 会在设备上得到一个应用图标，与平台特定应用程序一样。
    + 一旦安装，PWA 可以作为独立的应用程序启动，而不是作为浏览器中的网站。
* **PWA 可以在后台与离线操作**：一个典型的网站只在被浏览器加载后才处于活动状态。PWA 可以：
    + 在设备没有网络连接时工作。
    + 在后台更新内容。
    + 对来自服务器的推送消息做出响应。
    + 使用操作系统的通知系统显示通知。
* PWA 可以使用整个屏幕 (en-US)，而不是在浏览器 UI 中运行。
* PWA 可以与设备集成，注册为共享目标和来源，并访问设备特性。
* PWA 可以在应用商店以及通过 web 公开发布。
### PWA 和浏览器

当你在浏览器中访问一个网站时，从视觉上可以明显看出网站是“运行在浏览器中的”。浏览器 UI 为网站提供了一个可见的框架，包括后退/前进按钮等 UI 功能和页面的标题。你的网站调用的 Web API 是由浏览器引擎实现的。

PWA 通常看起来像平台特定应用程序——它们通常是在没有浏览器 UI 环绕的情况下显示的——但从技术上讲，它们仍然是网站。这意味着它们需要一个浏览器引擎，如 Chrome 或 Firefox 中的引擎，来管理和运行它们。对于平台特定应用程序，平台操作系统管理这个应用程序，提供其运行的环境。对于 PWA，浏览器引擎执行这一后台角色，就像它对普通网站所做的一样。

![比较传统网站、PWA 和平台特定应用程序的运行时环境的图表](/zh-CN/docs/Web/Progressive_web_apps/Guides/What_is_a_progressive_web_app/pwa-environment.svg)

在我们的 PWA 文档中，我们有时会提到浏览器在后台发挥这种作用。例如，我们可能会说，“当收到推送通知时，浏览器会启动 PWA 的 service worker。”在这种情况下，浏览器的活动完全在后台。从 PWA 的角度来看，它也可以由操作系统启动。对于某些系统，如 Chromebook，“浏览器”和“操作系统”之间可能根本没有区别。

### PWA 的技术特性

因为 PWA 是网站，所以它们具有与任何其他网站相同的基本特性：至少有一个 HTML 页面，其可能会加载一些 CSS 和 JavaScript。与普通网站一样，页面加载的 JavaScript 具有一个全局的 `Window` 对象，并且可以通过该对象访问所有可用的 Web API。

除此之外，PWA 还具有一些额外的特性：

* 一个 web 应用程序清单文件，至少提供浏览器需要的信息来安装 PWA，如应用程序名称和图标。
* 一个 service worker，至少提供基本的脱机体验。

#### Web 应用程序清单

PWA 必须有一个 web 应用程序清单，并且清单必须包含足够的信息以便浏览器安装 PWA。

清单可以定义 PWA 外观的许多其他方面，比如主题颜色 (en-US)和背景颜色，以及其行为，包括其充当其他应用程序数据的共享目标 (en-US)的能力或处理特定文件类型 (en-US)的能力。

#### Service worker

PWA 必须有一个 service worker，并且 service worker 必须实现至少最小程度的脱机体验。

service worker 鼓励一种架构，其中应用程序的页面（即网站的传统部分）实现用户界面，service worker 实现可以支持脱机和后台操作的后端，使 PWA 的行为更像应用程序而不是网站。这是因为 service worker 可以在需要时（例如，处理推送通知）由浏览器在后台启动。

### PWA 和单页应用

传统上，网站是作为相互链接的页面集合构建的。当用户从站点的一个页面点击链接到同一站点的另一个页面时，浏览器会将新页面作为一个完全新的实体加载，包括 HTML 和 HTML 加载的子资源，如 CSS 和 JavaScript。在单页应用中，站点由一个 HTML 页面组成，当用户点击内部链接时，由 JavaScript 从服务器获取新内容并更新页面的相关部分来处理。

单页应用可以提供更接近平台特定应用程序的用户体验，所以 PWA 通常以单页应用实现。特别是，单页应用使实现无缝的用户界面变得更加容易，在该用户界面中，用户被呈现一个单一、一致的页面，只更新页面相关部分以响应用户与应用程序的交互。

然而，PWA 不必是单页应用，单页应用也不必是 PWA。

### 渐进增强

虽然渐进增强是大多数网站的一个理想的属性，但对于 PWA 来说尤其重要，因为它们期望在广泛的设备上运行，并且通常使用可能不被所有浏览器支持的高级 Web API。

渐进增强的一个基本组成部分是，如果用户通过在浏览器中输入其 URL 的方式在网络上访问你的 PWA，则用户可以像访问普通网站一样与应用程序交互。但是如果浏览器可以安装它，则会提示用户安装它，并且该应用程序将作为设备上的新功能出现。

PWA 应该对高级 API 执行特性检测并提供可接受的回退体验。

例如，后台同步 API (en-US) 使 PWA 可以要求服务工作线程在设备连接时尽快发出网络请求。这个经典的用例是消息传递。假设用户编写了一条消息，但当用户尝试发送消息时，设备处于离线状态。后台同步 API 使设备能够在连上网后在后台发送消息。在不支持后台同步的设备上，该应用程序应该让用户知道消息无法发送，并给他们再次重试的机会。


缓存 - 渐进式 Web 应用（PWA）

缓存
==

当用户打开并与网站交互时，网站所需的所有资源，包括 HTML、JavaScript、CSS、图片、字体以及应用程序明确请求的任何数据，都是通过发出 HTTP(S) 请求来获取的。PWA 的一个基本特性就是能够明确地将一些应用程序的资源缓存在设备上，这意味着它们可以在不需要向网络发送请求的情况下被检索。

本地缓存资源有两个主要好处：**离线操作**和**响应速度**。

* **离线操作**：缓存使 PWA 能够在设备没有网络连接时进行工作。
* **响应速度**：即使设备在线，如果其用户界面是从缓存中获取的，PWA 通常会更加响应迅速。

当然，主要的缺点是**新鲜度（freshness）**：对于需要保持最新的资源，缓存是不太适合的。此外，对于一些类型的请求，如 POST 请求，缓存从来都不适合。

这意味着你是否以及何时应该缓存一个资源非常依赖于该资源本身，一个 PWA 通常会对不同的资源采取不同的策略。在本指南中，我们将研究一些 PWA 常用的缓存策略，看看哪些策略适用于哪些资源。

缓存技术概述
------

PWA 可以构建缓存策略的主要技术包括 Fetch API、Service Worker API 和 Cache API。

### Fetch API

Fetch API 定义了用于获取网络资源的全局函数 `fetch()`，以及代表网络请求和响应的 `Request` 和 `Response` 接口。`fetch()` 函数接受一个 `Request` 或一个 URL 作为参数，并返回一个兑现为 `Response` 的 `Promise`。

`fetch()` 函数对 service worker 以及主应用线程都可用。

### Service Worker API

service worker 是 PWA 的一部分：它是在其自己的线程中运行的单独脚本，与应用的主线程分离。

一旦 service worker 激活，当应用请求一个由 service worker 控制的网络资源时，浏览器会在 service worker 的全局作用域内触发一个称为 `fetch` (en-US) 的事件。这个事件不仅会在主线程显式调用 `fetch()` 时触发，而且在浏览器因页面导航而隐式请求加载页面和子资源（如 JavaScript、CSS 和图片）时也会触发。

通过监听 `fetch` 事件，service worker 可以拦截请求并返回一个定制的 `Response`。特别是，它可以返回一个本地缓存的响应，而不是总是访问网络，或者在设备离线时返回一个本地缓存的响应。

### Cache API

`Cache` 接口为 `Request`/`Response` 对提供了持久性存储。它提供了添加和删除 `Request`/`Response` 对的方法，以及查找匹配给定 `Request` 的缓存 `Response` 的方法。缓存在主应用线程和 service worker 中都可用：所以一个线程可以添加一个响应，另一个线程可以检索它。

通常情况下，service Worker 会在其 `install` 或 `fetch` 事件处理程序中将资源添加到缓存中。

何时缓存资源
------

PWA 可以在任何时候缓存资源，但在实践中，大多数 PWA 选择在以下几个时间点缓存它们：

* **在 service worker 的 `install` 事件处理程序中（预缓存）**：当 service worker 被安装时，浏览器会在 service worker 的全局作用域中触发一个名为 `install` (en-US) 的事件。此时，service worker 可以*预缓存*资源，从网络获取它们并将它们存储在缓存中。

**备注：** service worker 的安装时间与 PWA 的安装时间不同。一个 service worker 的 `install` 事件会在 service worker 被下载和执行后立即触发，这通常会发生在用户首次访问你的网站时。

即使用户从未将你的网站安装为 PWA，其 service worker 也会被安装和激活。
* **在 service worker 的 `fetch` 事件处理程序中**：当 service worker 的 `fetch` 事件被触发时，service worker 可以将请求转发到网络，并缓存结果响应，无论缓存中是否已经包含了响应，亦或是用较新的响应来更新缓存中的响应。
* **响应用户请求时**：PWA 可能会明确邀请用户下载稍后使用的资源。例如，音乐播放器可能会邀请用户下载曲目以稍后离线播放。在这种情况下，主应用线程可以获取资源并将响应添加到缓存中。特别是如果请求的资源很大，PWA 可能会使用 Background Fetch API (en-US)，在这种情况下，响应将由 service worker 处理，它将添加响应到缓存中。
* **定期**：使用定期后台同步 API (en-US)，service worker 可以定期获取资源并缓存响应，以确保即使在设备离线时，PWA 也可以提供合理新鲜的响应。
缓存策略
----

缓存策略是何时缓存资源、何时提供缓存资源以及何时从网络获取资源的算法。在本节中，我们将总结一些常用的策略。

这不是一个详尽的列表：它只是旨在说明 PWA 可以采用的策略类型。

缓存策略在离线操作、响应速度和新鲜度之间寻求平衡。不同的资源在此方面有不同的要求：例如，应用的基本 UI 可能相对稳定，而显示产品列表时获取最新数据则可能至关重要。这意味着 PWA 通常会对不同的资源采用不同的策略，单个 PWA 可能会使用此处描述的所有策略。

### 缓存优先

在这个策略中，我们会预缓存一些资源，然后仅对这些资源实现“缓存优先”策略。也就是：

* 对于预缓存的资源，我们将：
    + 在缓存中查找该资源，如果找到则返回该资源。
    + 否则，进行一次网络请求。如果网络请求成功，则缓存该资源以备下次使用。
* 对于所有其他资源，我们将始终进行网络请求。

预缓存适用于 PWA 确定需要的、在此版本的应用中不会改变的资源，以及需要尽快获取的资源。例如，这包括应用程序的基本用户界面。如果这些界面预缓存了，那么应用的 UI 就可以在启动时不需要任何网络请求地渲染。

首先，service worker 在其 `install` 事件处理程序中预缓存静态资源：

```js
const cacheName = "MyCache\_1";
const precachedResources = ["/", "/app.js", "/style.css"];

async function precache() {
  const cache = await caches.open(cacheName);
  return cache.addAll(precachedResources);
}

self.addEventListener("install", (event) => {
  event.waitUntil(precache());
});
```

在 `install` 事件处理程序中，我们将缓存操作的结果传递给事件的 `waitUntil()` 方法。这意味着如果由于任何原因缓存失败，service worker 的安装就会失败：反过来，如果安装成功，service worker 就可以确定资源已添加到缓存中。

`fetch` 事件处理程序如下：

```js
async function cacheFirst(request) {
  const cachedResponse = await caches.match(request);
  if (cachedResponse) {
    return cachedResponse;
  }
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      const cache = await caches.open("MyCache\_1");
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    return Response.error();
  }
}

self.addEventListener("fetch", (event) => {
  if (precachedResources.includes(url.pathname)) {
    event.respondWith(cacheFirst(event.request));
  }
});
```

我们通过调用事件的 `respondWith()` 方法来返回资源。如果我们没有为某个请求调用 `respondWith()`，那么该请求将像 service worker 没有拦截它一样发送到网络。因此，如果一个请求没有预缓存，它就直接从网络获取。

当我们将 `networkResponse` 添加到缓存时，我们必须克隆响应并将副本添加到缓存中，同时返回原始响应。这是因为 `Response` 对象是可以流传输的，所以只能读取一次。

你可能会疑惑为什么对预缓存的资源会回退到网络请求。如果它们已预缓存，难道我们就不能确定它们一定在缓存中吗？原因是缓存可能会被浏览器或用户清除。虽然这不太可能发生，但如果不能回退到网络请求，会导致 PWA 不可用。参见删除缓存数据。

### 带缓存刷新的数据缓存优先

“缓存优先”的缺点是一旦响应进入缓存，它就永远不会刷新，直到安装新的 service worker 版本。

“带缓存刷新的数据缓存优先”策略，也称为“用旧并换新（stale while revalidate）”，与“缓存优先”类似，不同之处在于即使缓存命中后，我们也总是将请求发送到网络，并使用响应来刷新缓存。这意味着我们可以得到“缓存优先”的响应速度，但可以获取相当新鲜的响应（只要请求频繁足够）。

当响应速度很重要，新鲜程度也比较重要但不是至关重要时，这是一个不错的选择。

在这个版本中，我们对所有资源（JSON 除外）实现“带缓存刷新的缓存优先”。

```js
function isCacheable(request) {
  const url = new URL(request.url);
  return !url.pathname.endsWith(".json");
}

async function cacheFirstWithRefresh(request) {
  const fetchResponsePromise = fetch(request).then(async (networkResponse) => {
    if (networkResponse.ok) {
      const cache = await caches.open("MyCache\_1");
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  });

  return (await caches.match(request)) || (await fetchResponsePromise);
}

self.addEventListener("fetch", (event) => {
  if (isCacheable(event.request)) {
    event.respondWith(cacheFirstWithRefresh(event.request));
  }
});
```

注意，我们异步更新缓存（在 `then()` 处理程序中），所以应用不必等待网络响应被接收就可以使用缓存的响应。

### 网络优先

我们要了解的最后一个策略是“网络优先”，它与缓存优先的逻辑相反：我们先尝试从网络获取资源。如果网络请求成功，我们返回响应并更新缓存。如果失败，我们尝试获取缓存。

这对于获取最新响应非常重要，但缓存的资源比没有好的请求很有用。一个消息应用的最近消息列表可能属于此类别。

在下面的示例中，我们对获取位于应用“inbox”路径下的所有资源的请求使用“网络优先”策略。

```js
async function networkFirst(request) {
  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      const cache = await caches.open("MyCache\_1");
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    const cachedResponse = await caches.match(request);
    return cachedResponse || Response.error();
  }
}

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (url.pathname.match(/^\/inbox/)) {
    event.respondWith(networkFirst(event.request));
  }
});
```

仍然存在一些请求，没有响应比可能过时的响应更好，且仅“网络优先”策略才合适。例如，如果一个应用正在显示可用产品列表，列表过时对用户来说将是令人沮丧的。

删除缓存数据
------

缓存具有有限的存储空间，如果超出限制，浏览器可能会驱逐应用缓存的数据。具体的限制和行为因浏览器而异：参见存储配额和驱逐标准了解详细信息。实际上，驱逐缓存数据的情况非常罕见。用户也可以随时清除应用程序的缓存。

PWA 应该在 service worker 的 `activate` (en-US) 事件中清除旧版本的任何缓存：当此事件触发时，service worker 可以确定没有之前版本的 service worker 正在运行，因此不再需要旧的缓存数据。

参见
--

* Service Worker API
* Fetch API
* 存储配额和驱逐标准
* developer.chrome.com 上的 service worker 缓存策略（2021）
* web.dev 上的离线应用程序开发指南（2020）


制作可安装的 PWA - 渐进式 Web 应用（PWA）

制作可安装的 PWA
==========

PWA 定义的一个方面就是它可以由浏览器提示以将其安装在设备上。安装后，PWA 对用户而言就是特定于平台的应用程序，是用户设备的一个永久特性，用户可以像启动其他任何应用程序一样直接从操作系统启动它。

我们可以将其总结如下：

* 支持的浏览器会提示用户将 PWA 安装到设备上。
* PWA 可以像特定平台的应用一样安装，并可以自定义安装过程。
* 一旦安装，PWA 就会在设备上获得一个应用图标，与特定平台的应用程序一起。
* 一旦安装，PWA 可以作为一个独立的应用程序启动，而不是在浏览器中的一个网站。

我们将在本指南中讨论每个特性。首先，我们将讨论 web 应用程序要想可以被提示安装所必须满足的条件。

可安装性
----

对于 web 应用程序来说，要想被支持的浏览器提示安装，它需要满足一些技术要求。我们可以将这些看作是一个 web 应用程序成为 PWA 的最低要求。

### web 应用清单

web 应用清单是一个 JSON 文件，它告诉浏览器 PWA 应该如何在设备上显示和表现。对于一个 web 应用程序来说，要成为一个 PWA，它必须是可安装的，而要成为可安装的，它必须包含一个清单。

这个清单是通过应用程序的 HTML 中的 `<link>` 元素引入的：

```html
<!doctype html>
<html lang="zh">
  <head>
    <link rel="manifest" href="manifest.json" />
    <!-- ... -->
  </head>
  <body></body>
</html>
```

如果 PWA 有多个页面，每个页面都必须以以下方式引用清单。

清单包含一个单独的 JSON 对象，其中包含一些定义 PWA 外观或行为的成员集合。以下一个相当简单的清单，只包含两个成员：`"name"` 和 `"icons"`。

```json
{
  "name": "我的 PWA",
  "icons": [
    {
      "src": "icons/512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ]
}
```

#### 必需的清单成员

基于 Chromium 的浏览器，包括 Google Chrome、Samsung Internet 和 Microsoft Edge，要求清单包含以下成员：

* `name` (en-US)
* `icons` (en-US)
* `start_url` (en-US)
* `display` (en-US) 和/或 `display_override` (en-US)

有关每个成员的完整描述，请参阅 Web 应用清单参考文档。

### 安全上下文

要使 Web 应用程序可安装，它必须在安全上下文中提供。通常意味着它必须通过 HTTPS 提供。本地资源，如 localhost、`127.0.0.1` 和 `file://` 也被视为安全。

### Service worker

要使 Web 应用程序可安装，它必须包括一个 service worker，其中包含一个提供基本离线体验的 `fetch` 事件处理程序 (en-US)。

从应用商店安装
-------

用户希望在其平台的应用商店中找到应用程序，例如 Google Play 商店或 Apple Store。

如果你的应用符合可安装的先决条件，你可以将其打包并通过应用商店进行分发。该过程针对每个应用商店有所不同：

* 如何将 PWA 发布到 Google Play 商店
* 如何将 PWA 发布到 Microsoft Store
* 如何将 PWA 发布到 Meta Quest Store

PWABuilder 是一个工具，可简化将 PWA 打包和发布到各种应用商店的过程。它支持 Google Play 商店、Microsoft Store、Meta Quest Store 和 iOS App Store。

如果你已将应用添加到应用商店，用户可以像使用特定于平台的应用一样从商店安装它。

通过 Web 进行安装
-----------

当支持的浏览器确定 Web 应用程序满足前述的可安装性要求时，它将提示用户安装当前应用。用户将有机会安装该应用程序。这意味着你可以将你的 PWA 通过网站进行分发，使其可以通过 Web 搜索找到，也可以在应用商店中分发，以便用户可以在那里找到它。

这是 PWA 可以同时提供两种最佳体验的绝佳示例。这也是渐进增强与 PWA 结合的良好示例：如果用户在不能安装它的浏览器中在 Web 上遇到你的 PWA，他们可以像使用普通网站一样使用它。

从 Web 安装 PWA 的用户界面在不同的浏览器和平台上会有所不同。例如，当用户导航到网页时，浏览器可能在 URL 栏中包含一个“安装”图标：

![Chrome URL 栏显示 PWA 安装图标](/zh-CN/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable/pwa-install.png)

当用户选择该图标时，浏览器会显示一个提示框询问他们是否要安装 PWA，如果他们接受，PWA 将被安装。

该提示框显示的是 PWA 的名称和图标，取自 渐进式 Web 应用清单的 `name` (en-US) 和 `icons` (en-US) 成员。

### 浏览器支持

通过 Web 提示安装 PWA 的支持因浏览器和平台而异。

在桌面上：

* Firefox 和 Safari 不支持在任何桌面操作系统上安装 PWA。见下方的将网站安装为应用程序。
* Chrome 和 Edge 支持在 Linux、Windows、macOS 和 Chromebook 上安装 PWA。

在移动设备上：

* 在 Android、Firefox、Chrome、Edge、Opera 和 Samsung Internet Browser 上都支持安装 PWA。
* 在 iOS 16.3 及更早版本上，PWA 只能通过 Safari 安装。
* 在 iOS 16.4 及更高版本上，PWA 可以通过 Safari、Chrome、Edge、Firefox 和 Orion 的分享菜单安装。
### 将网站安装为应用程序

桌面版和移动版 Safari 以及桌面版 Edge 也支持将任何网站安装为应用程序。但是，这并不是 PWA 所特有的，因为网站不需要满足本指南中描述的可安装性要求，且浏览器不会主动提示安装网站。

### 触发安装提示

PWA 可以提供自己的页面内用户界面，供用户打开安装提示，而不是依赖浏览器默认提供的用户界面。这使得 PWA 可以提供一些上下文和用户安装 PWA 的理由，并有助于使安装用户流程更易于发现。

这种技术依赖于 `beforeinstallprompt` 事件，该事件在全局的 `Window` 对象上触发，一旦浏览器确定 PWA 可以安装。此事件具有一个 `prompt()` 方法，用于显示安装提示。因此，PWA 可以：

* 添加自己的“安装”按钮
* 监听 `beforeinstallprompt` 事件
* 通过调用 `preventDefault()` 取消事件的默认行为
* 在自己的“安装”按钮的事件处理程序中，调用 `prompt()`。

这在 iOS 上不受支持。

### 自定义安装提示

默认情况下，安装提示中包含 PWA 的名称和图标。如果你为 `description` (en-US) 和 `screenshots` (en-US) 这两个渐进式 Web 应用清单的成员提供值，则仅限于 Android，在安装提示中将显示这些值，为用户提供额外的上下文和安装 PWA 的动机。

下面的屏幕截图显示了在运行在 Android 上的 Google Chrome 上的 PWAmp 演示的安装提示的外观：

![Android 上的 PWAmp 安装提示](/zh-CN/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable/pwamp-install-prompt-android.png)

启动应用
----

一旦安装了 PWA，它的图标将显示在设备上，与用户安装的其他应用程序一起显示，并且点击图标将启动应用程序。

你可以使用应用清单的成员 `display` (en-US) 来控制 PWA 的*显示模式*，即 PWA 启动时的外观。具体而言：

* `"standalone"` 表示 PWA 应该看起来和感觉上像特定于平台的应用程序，没有浏览器的用户界面元素。
* `"browser"` 表示 PWA 应该作为一个新的浏览器标签页或窗口打开，就像普通网站一样。

如果浏览器不支持给定的显示模式，`display` 将会根据预定义的顺序回退到支持的显示模式。`display_override` (en-US) 允许你重新定义回退顺序。


离线和后台操作 - 渐进式 Web 应用（PWA）

离线和后台操作
=======

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

然而，考虑以下场景：

* 音乐应用程序允许用户在线播放音乐，但可以在后台下载音乐，然后在用户离线时继续播放。
* 用户撰写一封长邮件，按下“发送”，然后失去网络连接。设备在网络再次可用时在后台发送邮件。
* 用户的聊天应用程序收到其中一个联系人的消息，尽管应用程序没有打开，但它在应用程序图标上显示一个标记，让用户知道他们有新的消息。

这些都是用户期待从已安装的应用程序中获得的功能。在这份指南中，我们将介绍一套技术使 PWA 能够：

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

本指南介绍的技术包括：

* service worker API
* 后台同步 API (en-US)
* 后台获取 API (en-US)
* 周期性后台同步 API (en-US)
* 推送 API
* 通知 API
网站和 worker
----------

我们在本指南中讨论的所有技术的基础是 *service worker*。在这一部分，我们将提供一些关于 worker 的背景信息以及它们如何改变 Web 应用程序的架构。

通常，整个网站在一个线程中运行。这包括网站自己的 JavaScript 和所有渲染网站 UI 的工作。这样做的一个结果是，如果你的 JavaScript 运行了一些长时间的操作，网站的主 UI 会被阻塞，网站对用户来说似乎无响应。

service worker 是一种特别类型的用来实现 PWA 的 Web worker。像所有 web worker 一样，service worker 在与主 JavaScript 代码不同的另一个线程中运行。主代码创建 worker，向 worker 脚本传入 URL。worker 和主代码不能直接访问彼此的状态，但可以通过互发消息来进行通信。worker 可以用于在后台运行计算密集型任务：因为它们在一个单独的线程中运行，所以在应用程序中的主 JavaScript 代码（即实现应用程序 UI 的部分），可以保持对用户的响应。

所以 PWA 总是有一个高级别的架构，可分为：

* *主应用*，包含 HTML、CSS 和实现应用程序 UI 的 JavaScript 部分（例如处理用户事件）
* *service worker*，处理离线和后台任务

在本指南中，当我们展示代码样本时，我们将用像 `// main.js` 或 `// service-worker.js` 这样的注释来指出代码属于应用的哪个部分。

离线操作
----

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

service worker *控制*应用的部分或全部页面。在安装 service worker 后，它可以从服务器获取它控制的页面的资源（包括页面、样式、脚本和图片等），并将它们添加到本地缓存中。使用 `Cache` 接口将资源添加到缓存中。在 service worker 全局作用域内，可以通过 `caches` 属性访问 `Cache` 实例。

然后，每当应用程序请求一个资源（例如，因为用户打开了应用程序或点击了内部链接），浏览器在 service worker 的全局作用域内触发一个名为 `fetch` (en-US) 的事件。通过监听此事件，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 }) => {
  // 首先尝试从缓存中获取资源。
  const responseFromCache = await caches.match(request);
  if (responseFromCache) {
    return responseFromCache;
  }

  // 如果在缓存中找不到响应，则尝试通过网络获取资源。
  try {
    const responseFromNetwork = await fetch(request);
    // 如果网络请求成功，将响应克隆一份：
    // - 将副本放入缓存，供下一次使用
    // - 将原始响应返回给应用程序
    // 需要克隆响应，因为响应只能使用一次。
    putInCache(request, responseFromNetwork.clone());
    return responseFromNetwork;
  } catch (error) {
    // 如果网络请求失败，则从缓存中获取备用响应。
    const fallbackResponse = await caches.match(fallbackUrl);
    if (fallbackResponse) {
      return fallbackResponse;
    }
    // 当连备用响应也不可用时，我们无能为力，
    // 但我们必须返回一个响应对象。
    return new Response("网络错误", {
      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。这使得 PWA 能够以以下方式实现后台操作：

* 在主应用程序中，注册一个请求，要求 service worker 执行某个操作
* 在适当的时机，如果有必要，浏览器将重新启动 service worker，并在 service worker 的作用域中触发一个事件
* service worker 执行操作

在接下来的几节中，我们将讨论一些使用这种模式的功能，它们可以使 PWA 在主应用程序未打开时正常工作。

后台同步
----

假设用户撰写了一封电子邮件并按下“发送”。在传统的网站中，他们必须保持标签页打开，直到应用程序发送了电子邮件：如果他们关闭标签页，或者设备失去连接，那么消息将不会被发送。定义在后台同步 API (en-US) 中的后台同步，是 PWA 解决这个问题的方案。

后台同步使应用程序能够请求其 service worker 代表其执行任务。一旦设备有网络连接，浏览器将重新启动 service worker（如果需要），并在 service worker 的作用域内触发名为 `sync` (en-US) 的事件。然后 service worker 可以尝试执行任务。如果任务无法完成，那么浏览器可能会通过再次触发事件来重试有限的次数。

### 注册同步事件

要请求 service worker 执行任务，主应用程序可以访问 `navigator.serviceWorker.ready` (en-US)，它会兑现为一个 `ServiceWorkerRegistration` (en-US) 对象。然后应用程序在 `ServiceWorkerRegistration` 对象上调用 `sync.register()` (en-US)，如下所示：

```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` 作为参数，并要求浏览器不要停止 service worker，直到 promise 已敲定。这也是浏览器知道操作是否成功的方式：如果 promise 被拒绝，那么浏览器可能会通过再次触发 `sync` 事件来重试。

`waitUntil()` 方法是不能保证浏览器不会停止 service worker 的：如果操作花费太长时间，service worker 仍然会被停止。如果发生这种情况，那么操作将被中止，并且下次触发 `sync` 事件时，处理程序将再次从头开始运行——它不会从它停止的地方继续。

“太长时间”是多长是由浏览器决定的。对于 Chrome，如果满足以下条件之一，则可能关闭 service worker：

* 它已经空闲了 30 秒
* 它已经运行同步 JavaScript 30 秒
* 传递给 `waitUntil()` 的 promise 等待敲定的时间超过了 5 分钟
后台获取
----

后台同步对于相对短的后台操作很有用，但正如我们刚刚看到的：如果 service worker 没有在相对短的时间内完成处理同步事件，浏览器将停止 service worker。这是一种有意的措施，通过最小化应用程序在后台时用户的 IP 地址暴露给服务器的时间以节省电池寿命并保护用户的隐私。

这使得后台同步不适合更长的操作（例如下载一部电影）。对于这种场景，你需要后台获取 API (en-US)。使用后台获取，即使主应用程序 UI 和 service worker 都关闭了，也可以执行网络请求。

使用后台获取：

* 请求是从主应用程序 UI 发起的
* 无论主应用程序是否打开，浏览器都会显示一个持久的 UI 元素，通知用户正在进行的请求，并使他们能够取消它或检查其进度
* 当请求成功或失败，或者用户要求检查请求的进度时，浏览器将启动应用程序的 service worker（如果需要），并在 service worker 的作用域内触发适当的事件。
### 发起后台获取请求

后台获取请求是在主应用程序代码中发起的，通过在 `ServiceWorkerRegistration` 对象上调用 `backgroundFetch.fetch()` (en-US) 完成，如下所示：

```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()` 调用返回一个兑现为 `BackgroundFetchRegistration` (en-US) 对象的 `Promise`。这使得主应用程序能够随着请求的进度更新其自己的 UI。但是，如果主应用程序关闭了，获取将在后台继续。

浏览器将显示一个持久的 UI 元素，提醒用户请求正在进行中，让他们有机会了解更多关于请求的信息或者取消它（如果他们希望）。UI 将包括从 `icons` 和 `title` 参数中取得的图标和标题，并使用 `downloadTotal` 作为下载总大小的估计值，以显示请求的进度。

### 处理请求结果

当获取成功或失败，或者用户点击了进度 UI 元素时，浏览器将启动应用程序的 service worker（如果需要），并在 service worker 的范围内触发事件。可以触发以下事件：

* `backgroundfetchsuccess`：所有请求都成功
* `backgroundfetchfail`：至少有一个请求失败
* `backgroundfetchabort`：获取被用户或主应用程序取消
* `backgroundfetchclick`：用户点击了浏览器显示的进度 UI 元素

#### 获取响应数据

在 `backgroundfetchsuccess`、`backgroundfetchfail` 和 `backgroundfetchabort` 事件的处理器中，service worker 可以获取请求和响应的数据。

可以在事件处理器中访问事件的 `registration` (en-US) 属性以获取响应。这是一个 `BackgroundFetchRegistration` (en-US) 对象，它有 `matchAll()` (en-US) 和 `match()` (en-US) 方法，它们返回与给定 URL 匹配的 `BackgroundFetchRecord` (en-US) 对象（或者，在 `matchAll()` 的情况下，如果没有给定 URL，则返回所有记录）。

每个 `BackgroundFetchRecord` 都有一个 `responseReady` (en-US) 属性，它是一个 `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);
    // 使用响应数据做一些事情
  });
});
```

由于响应数据在处理器退出后不会可用，所以如果应用程序仍然需要它，处理程序应该存储数据（例如，在 `Cache` 中）。

#### 更新浏览器的 UI

传递给 `backgroundfetchsuccess` 和 `backgroundfetchfail` 的事件对象也有一个 `updateUI()` (en-US) 方法，它可以用来更新浏览器显示的 UI，以便让用户了解获取操作的情况。使用 `updateUI()`，处理器可以更新 UI 元素的标题和图标：

```js
// service-worker.js

self.addEventListener("backgroundfetchsuccess", (event) => {
  // 获取并存储响应数据
  // ...

  event.updateUI({ title: "完成了你的下载！" });
});

self.addEventListener("backgroundfetchfail", (event) => {
  event.updateUI({ title: "无法完成下载" });
});
```

#### 响应用户交互

当用户点击浏览器在获取期间显示的 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 (en-US) 使得 PWA 能够在主应用程序处于关闭状态的同时，在后台周期性地更新其数据。

这可以极大地改善 PWA 提供的离线体验。考虑一个依赖于相当新鲜内容的应用程序，比如新闻应用程序。如果设备在用户打开应用程序时离线，则即使使用基于 service worker 的缓存，文章也只会和上次打开应用程序时一样。通过周期性后台同步，应用程序可以在设备联网时在后台刷新其内容，因此能够向用户显示相对较新鲜的内容。

这利用了一个事实（特别是对于移动设备），即网络连接的好坏是*间歇性*的：通过利用设备具有网络连接的时段，应用程序可以消除连接间隙。

### 注册周期性同步事件

注册周期性同步事件的代码遵循与注册同步事件相同的模式。`ServiceWorkerRegistration` (en-US) 有一个 `periodicSync` (en-US) 属性，它有一个 `register()` (en-US) 方法，以周期性同步的名称作为参数。

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

```js
// main.js

async function registerPeriodicSync() {
  const swRegistration = await navigator.serviceWorker.ready;
  swRegistration.periodicSync.register("update-news", {
    // 尝试每 24 小时更新一次
    minInterval: 24 \* 60 \* 60 \* 1000,
  });
}
```

### 处理周期性同步事件

尽管 PWA 在 `register()` 调用中请求特定间隔，但由浏览器决定周期性同步事件的生成频率。用户经常打开和交互的应用程序将更有可能收到周期性同步事件，并且会收到更频繁的事件，而用户很少或根本不与之交互的应用程序则不然。

当浏览器决定生成周期性同步事件时，模式如下：它启动 service worker（如果需要），并在 service worker 的全局作用域中触发 `periodicSync` (en-US) 事件。

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 应该要求浏览器停止生成周期性同步事件，方法是调用 `periodicSync` (en-US) 的 `unregister()` (en-US) 方法：

```js
// main.js

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

推送消息
----

推送 API 使 PWA 能够接收从服务器推送的消息，无论应用程序是否正在运行。当设备接收到消息时，应用程序的 service worker 将启动并处理消息，并向用户显示通知。规范允许“静默推送”，在这种情况下不显示通知，但由于隐私问题（例如，推送随后可用于跟踪用户的位置），没有浏览器支持这一点。

向用户显示通知会分散他们的注意力，并可能非常恼人，因此请谨慎使用推送消息。通常，当你需要提醒用户某些事情，并且不能等待他们下次打开你的应用程序时，它们才适合。

推送通知的一个常见用例是聊天应用：当用户从其联系人之一收到消息时，它会以推送消息的形式交付，并且该应用程序会显示通知。

推送消息不是直接从应用服务器发送到设备的。相反，你的应用服务器将消息发送到推送服务，然后设备可以从中检索它们并将它们交付给应用程序。

这也意味着你的服务器向推送服务发送的消息需要被加密（这样推送服务无法读取它们）和签名（这样推送服务知道消息确实来自你的服务器，而不是某人伪装你的服务器）。

推送服务由浏览器供应商或第三方操作，应用服务器使用 HTTP 推送协议与其通信。应用服务器可以使用第三方库（如 web-push）来处理协议详细信息。

### 订阅推送消息

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

![显示推送消息订阅步骤的图表](/zh-CN/docs/Web/Progressive_web_apps/Guides/Offline_and_background_operation/push-messaging-1.svg)

1. 作为先决条件，应用服务器需要使用公钥/私钥对 (en-US)来设置，以便它可以签名推送消息。签名消息需要遵循 VAPID 规范。
2. 在设备上，应用程序使用 `PushManager.subscribe()` 方法订阅来自服务器的消息。`subscribe()` 方法：
    * 将应用服务器的公钥作为参数：推送服务将用此来验证来自应用服务器的消息签名。
    * 返回一个会兑现为一个 `PushSubscription` (en-US) 对象的 `Promise`。这个对象包括：
        + 推送服务的端点 (en-US): 这是应用服务器了解在何处发送推送消息的方式。
        + 你的服务器将用来加密发向推送服务的消息的公开加密密钥 (en-US)。
3. 应用程序将端点和公开加密密钥发送到你的服务器（例如，使用 `fetch()`）。

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

### 发送、交付和处理推送消息

当服务器上发生应用程序要处理的事件时，服务器可以发送消息，步骤如下所示：

![显示推送消息发送和交付步骤的图表](/zh-CN/docs/Web/Progressive_web_apps/Guides/Offline_and_background_operation/push-messaging-2.svg)

1. 应用服务器使用其私有签名密钥对消息进行签名，并使用推送服务的公开加密密钥对消息进行加密。应用服务器可以使用诸如 web-push 之类的库来简化此操作。
2. 应用服务器使用 HTTP 推送协议将消息发送到推送服务的端点，再次可以选择使用诸如 web-push 之类的库。
3. 推送服务检查消息上的签名，如果签名有效，推送服务将消息排队以便交付。
4. 当设备有网络连接时，推送服务会将加密消息交付到浏览器。
5. 当浏览器接收到加密消息时，它会解密消息。
6. 浏览器会启动 service worker（如果需要）, 并在 service worker 的全局作用域中触发一个名为 `push` (en-US) 的事件。事件处理程序被传递一个 `PushEvent` (en-US) 对象，其中包含消息数据。
7. 在其事件处理程序中，service worker 执行对消息的任何处理。如往常一样，事件处理程序调用 `event.waitUntil()` 来要求浏览器使 service worker 继续运行。
8. 在其事件处理程序中，service worker 使用 `registration.showNotification()` (en-US) 创建一个通知。
9. 如果用户点击通知或关闭它，会分别在 service worker 的全局作用域中触发 `notificationclick` (en-US) 和 `notificationclose` (en-US) 事件。这使应用程序能够处理用户对通知的响应。
权限和限制
-----

浏览器必须在为 Web 开发者提供强大 API 的同时保护用户免受恶意、剥削性或编写低劣的网站的侵害。他们提供的主要保护措施之一是用户可以关闭网站的页面，然后它就不再活跃在他们的设备上了。本文描述的 API 倾向于违反这一保证，因此浏览器必须采取额外步骤来帮助确保用户意识到这一点，并且这些 API 是以符合用户利益的方式使用的。

在本节中，我们将概述这些步骤。其中几个 API 需要显式用户权限 (en-US)，以及各种其他限制和设计选择，以帮助保护用户。

* 后台同步 API 不需要显式用户权限，但是只有在主应用程序打开时才能发出后台同步请求，并且浏览器会限制重试次数和后台同步操作可以花费的时间长度。
* 后台获取 API 需要 `"background-fetch"` 用户权限，浏览器会显示正在进行的获取操作的进度，允许用户取消它。
* 周期性后台同步 API 需要 `"periodic-background-sync"` 用户权限，浏览器应当允许用户完全禁用周期性后台同步。此外，浏览器可能会将同步事件的频率与用户选择与应用程序交互的范围联系在一起：因此，用户很少使用的应用程序可能会收到很少的事件（甚至根本不会收到事件）。
* 推送 API 需要 `"push"` 用户权限，所有浏览器都需要推送事件对用户可见，这意味着它们会生成用户可见通知。
参见
--

### 参考

* Service Worker API
* 后台同步 API (en-US)
* 后台获取 API (en-US)
* 周期性后台同步 API (en-US)
* 推送 API
* 通知 API
### 指南

* web.dev 上的介绍后台同步（2017）
* web.dev 上的介绍后台获取（2022）
* web.dev 上的周期性后台同步 API（2020）
* web.dev 上的通知
* web.dev 上的具有离线流媒体的 PWA（2021）


安装和卸载 Web 应用 - 渐进式 Web 应用（PWA）

安装和卸载 Web 应用
============

本指南介绍用户如何在其设备上安装和卸载渐进式 Web 应用（PWA）。如果你想了解如何将 Web 应用程序作为 PWA 安装，请参阅使 PWA 可安装。

Web 应用安装的历史
-----------

浏览器一直支持保存网站的快捷方式，称为“书签”。这些书签只是指向网站的链接。

某些操作系统（OS）增强了书签功能，使用户可以将书签保存到常见位置，比如主屏幕或任务栏，并使用一个图标在操作系统的默认浏览器中启动该网站。对于许多网站而言，这也只是一个链接。如果网站是渐进式 Web 应用程序（PWA），将其保存到主屏幕上会在用户设备上安装 PWA，将其完全集成到操作系统中，就像大多数设备上的原生应用程序一样。PWA 可以被安装，也可以被卸载。

我们首先介绍前身——保存网站链接。

### 保存网站链接

所有浏览器都具有添加到收藏夹的功能。书签是一个可点击的网页快捷方式。书签使用户可以快速访问网站，而无需输入 URL 或搜索内容。对于长 URL 和不是网站首页的访问频率较高的内容，书签特别有用。

所有浏览器都允许用户查看和管理书签，包括重命名和删除收藏夹。默认情况下，书签的显示包括书签页面的 `<title>` 元素的文本内容，以及由网站的 favicon (en-US) 构成的图标。

浏览器允许保存、编辑、移动、删除和管理书签。书签管理的用户界面因浏览器而异。

### 添加到主屏幕

2007 年的 iPhone 开始，智能手机增加了“保存到主屏幕”的功能。对于普通（非 PWA）网站，此功能类似于书签，但它不是将页面的 favicon 和标题添加到书签菜单（这是浏览器功能），而是通过这种方式添加图标到操作系统的主屏幕。

将非 PWA 网站添加到主屏幕不会在设备上安装该网站。相反，它会将开发者定义的图标添加到主屏幕，当点击图标时，会在默认浏览器中打开该书签链接。

![iPhone 添加到主屏幕、安装提示、图标和删除功能。](/zh-CN/docs/Web/Progressive_web_apps/Guides/Installing/iphone_pwa.jpg)

如果添加到主屏幕的站点是 PWA，则会在设备上安装该 PWA。

从主屏幕中删除图标会删除书签。删除确认会提供有关删除图标是删除书签还是整个应用程序的信息。

安装和卸载 PWA
---------

安装 PWA 只需几次点击，具体取决于 Web 应用程序的功能，安装 PWA 的结果通常远远超出了创建一个指向互联网页面的链接；安装 PWA 还会更深入地将 Web 应用程序集成到用户设备中。

根据 PWA、设备，以及操作系统和浏览器的功能，安装 PWA 可能会启用类似本机应用程序的功能，例如为应用程序提供独立的窗口或将其注册为文件处理程序。这也意味着卸载 PWA，同样只需要几次点击，不仅仅是删除 PWA 的图标。

### 安装 PWA

从 Web 上安装 PWA 的用户界面因浏览器和平台的不同而异。

安装 PWA 的用户界面因设备和操作系统的组合而异。在 iOS 的 Safari 上，用户界面“添加到主屏幕”会安装 PWA。其他浏览器，包括 Android 上的 Chrome，在浏览器设置菜单中包含应用程序安装命令。在桌面上的 Chrome 和 Edge 中，当用户导航到页面时，如果页面是 PWA，并且浏览器当前未安装该 PWA，URL 地址栏中将显示一个安装图标：

![URL 地址栏中的 PWA 安装提示](/zh-CN/docs/Web/Progressive_web_apps/Guides/Installing/pwa-install.png)

当用户选择该图标时，浏览器会显示一个提示，询问是否要安装 PWA，如果用户接受，PWA 将被安装。

![PWA 安装确认提示](/zh-CN/docs/Web/Progressive_web_apps/Guides/Installing/installconfirm.jpg)

一旦安装，PWA 将像操作系统上的其他应用程序一样运行。例如，在 macOS 上，图标将显示在 Dock 中，并具有与其他应用程序相同的图标选项：

![PWA 在 MacOS 上的 Dock 中的图标](/zh-CN/docs/Web/Progressive_web_apps/Guides/Installing/dock.jpg)

在大多数桌面浏览器上，安装提示位于 URL 栏中。在移动设备上，安装提示通常位于浏览器选项菜单中。无论是哪种浏览器还是操作系统，都需要确认安装。

![在 Android 上安装 PWA 的示例，包括确认、主屏幕图标和离线体验](/zh-CN/docs/Web/Progressive_web_apps/Guides/Installing/android_pwa.jpg)

一旦安装，PWA 的行为就像其他已安装的应用程序一样：单击应用程序图标即可打开 PWA，即使用户处于离线状态也可以打开。

所有现代桌面和移动设备都支持安装。PWA 是否可以由浏览器在操作系统上安装取决于浏览器/操作系统的组合。大多数浏览器直接或在安装扩展程序后支持在所有操作系统（如 ChromeOS、MacOS、Windows、Android、Linux 等）上安装 PWA。

Firefox 需要 PWA 扩展。

对于 PWA 来说，苹果是独特的：在 macOS 上，**除了** Safari 之外，可以从任何浏览器安装 PWA。对于 iOS 16.4 之前的版本，情况恰好相反，PWA **只能**在 Safari 中安装。在 iOS/iPadOS 16.4 或更高版本上，可以从任何支持的浏览器上安装 PWA。

安装的 PWA 启动时，它可以显示在其自己的独立窗口中（没有完整的浏览器界面），但它仍然在浏览器窗口中运行，即使通常的浏览器界面元素（如地址栏或返回按钮）不可见。应用程序将出现在操作系统保存其他应用程序的位置，位于特定于浏览器的文件夹中。

由浏览器安装的 PWA 仅针对该浏览器。这意味着用于安装 PWA 的浏览器是运行该 PWA 的浏览器。这还意味着你可以使用不同的浏览器安装相同的 PWA，并且这两个应用程序将作为两个不同的实例运行，并且不共享任何数据。

用于安装 PWA 的浏览器将知道 PWA 已安装，但其他浏览器将无法访问安装状态。例如，如果你使用 MS Edge 安装一个 PWA，在访问该站点时，Edge 会提示你打开 PWA，而 Chrome 将继续提示你安装该应用程序。如果你也使用 Chrome 安装 PWA，你将拥有两个副本的 PWA。当打开来自不同浏览器安装的多个 PWA 实例时，实例之间不共享数据。

当你点击 Web 应用的图标时，它会在安装 PWA 的浏览器环境中打开，通常没有浏览器的用户界面，尽管这取决于开发者配置的 Web 应用清单。类似的，卸载 PWA 的方法取决于用于安装它的浏览器。

### 卸载

在大多数移动操作系统上，卸载 PWA 的方法与卸载其他应用程序相同。在某些移动操作系统上，PWA 显示在管理从应用商店下载的应用程序的同一控制面板中，可以在那里卸载它们。

在 iOS 上，从 Safari 安装的 PWA 在“应用库”屏幕上列出并可搜索，但不会在“设置”下的其他已安装应用程序中列出。在 iOS 上，长按图标会显示删除书签的用户界面；从主屏幕删除图标会删除 PWA。

在某些桌面操作系统上，可以直接在已打开的 PWA 中卸载 PWA。要卸载，打开 PWA。在打开的应用程序的右上角，有一个图标，必须展开以查看更多工具。根据用于安装 PWA 的浏览器，要么有一个卸载 PWA 的链接，要么有一个打开浏览器设置页面的链接，其中包含一个卸载链接。如果有的话，可以单击下拉菜单中的卸载选项，或在浏览器选项卡中导航到应用程序设置，并点击卸载。

![MS Edge 中的应用程序设置，包含卸载链接](/zh-CN/docs/Web/Progressive_web_apps/Guides/Installing/remove.jpg)

在 Edge 中，从打开的下拉菜单中选择应用设置，会打开 MS Edge 浏览器的 `edge://apps` 标签页。在那里，我们会看到已安装应用程序的列表，每个应用程序都有各种选项，包括“🗑 卸载”。确认卸载操作。就这么简单！

在 Edge 中，已安装的 PWA 会列在一个列表中，可以通过在 Edge 浏览器中访问 `edge://apps` 来管理它们。在 Chrome 中，Google 应用和已安装的 PWA 的列表可以通过在 Chrome 浏览器中访问 `chrome://apps` 来查看和管理。

参见
--

* 在 Chrome 中使用 PWA：计算机和 Android
* 在 Microsoft Edge 中安装、管理或卸载应用程序