第21.2章:如何编写 PWA

English

1. PWA 是什么

渐进式网络应用(PWA)是一个网站加上三样东西:

这就是全部的平台要求。没有 SDK,没有商店账号,没有原生工程。浏览器支持,应用就可安装; 浏览器不支持,同一个 URL 仍然作为普通网站工作。

2. 你需要会什么

你需要基本的 HTML、CSS、JavaScript, 以及 Web 应用清单。你不需要原生 UI 工具包、平台语言或跨平台框架。UI 是 HTML 和 CSS; 逻辑是 JavaScript 或 WebAssembly;清单和 Service Worker 是 JSON 和 JavaScript。

这比"学 Android 和 iOS"的前置清单小得多,也是 Web 成为实际跨平台目标的原因。如果你能读懂 一个网页的源码、理解一次 fetch 调用,你就能写 PWA。

它也是最容易交给 AI 助手的东西之一。各个部件都是标准的、文档齐全的、自包含的:一个清单、 一个 Service Worker、一个注册脚本、一个安装按钮、一个响应式页面。AI 非常擅长写 PWA, 一遍就能生成可用的脚手架。用它生成脚手架,但有两件事要自己验证:应用是否真的可安装, 以及是否真的能离线工作。这些是行为,不是文字。

3. 清单

清单是一个从页面链接的 JSON 文件:

<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#111111">
<link rel="apple-touch-icon" href="/icons/192.png">

一个满足可安装性的最小清单是这样的:

{
  "id": "/notes/",
  "name": "Notes",
  "short_name": "Notes",
  "start_url": "/notes/",
  "scope": "/notes/",
  "display": "standalone",
  "display_override": ["window-controls-overlay", "standalone"],
  "background_color": "#111111",
  "theme_color": "#111111",
  "description": "A minimal offline notes app.",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/maskable-512.png", "sizes": "512x512",
      "type": "image/png", "purpose": "maskable" }
  ]
}

对可安装性重要的字段是 name 或 short_name、start_url、 display(或 display_override),以及 192 和 512 像素的图标。 加上 scope,让应用不要声称拥有整个源;加上 id,这样以后改 start_url 时浏览器还能保持应用身份稳定。

display: standalone 隐藏浏览器外壳。display_override 让浏览器在桌面上 选择更丰富的模式,比如 window-controls-overlay,同时回退到 standalone。 想要更丰富的安装界面就加 screenshots;只有应用真的实现了,才加 shortcuts、share_target 或 file_handlers。

4. Service Worker

Service Worker 是让应用离线工作的部分。它有三个生命周期事件:install、 activate 和 fetch。一个小的缓存优先 worker:

const VERSION = "v3";
const CACHE = `notes-${VERSION}`;
const SHELL = [
  "/notes/",
  "/notes/app.js",
  "/notes/style.css",
  "/notes/manifest.webmanifest",
  "/notes/icons/192.png"
];

self.addEventListener("install", (event) => {
  event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((key) => key !== CACHE)
                      .map((key) => caches.delete(key)))
    )
  );
});

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.method !== "GET") return;

  event.respondWith(
    caches.match(request).then((cached) => {
      if (cached) return cached;
      return fetch(request).then((response) => {
        if (response.ok && new URL(request.url).origin === location.origin) {
          const copy = response.clone();
          caches.open(CACHE).then((cache) => cache.put(request, copy));
        }
        return response;
      });
    })
  );
});

三条规则决定它是演示还是能用的应用:

Service Worker 同时也是更新机制。新的 worker 在后台安装并等待。你可以调用 self.skipWaiting() 立即激活它,调用 self.clients.claim() 接管已打开的页面。 更安全的做法是在重载前询问用户,这也是本教程的 sw-register.js 做的事。

5. 注册和安装体验

从页面注册 worker:

if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker
      .register("/notes/sw.js", { scope: "/notes/" })
      .catch((error) => console.error("SW registration failed", error));
  });
}

在 Chromium 上,beforeinstallprompt 让你显示自己的安装按钮,而不是依赖浏览器 UI:

let deferredPrompt;

window.addEventListener("beforeinstallprompt", (event) => {
  event.preventDefault();
  deferredPrompt = event;
  document.getElementById("install").hidden = false;
});

document.getElementById("install").addEventListener("click", async () => {
  if (!deferredPrompt) return;
  deferredPrompt.prompt();
  await deferredPrompt.userChoice;
  deferredPrompt = null;
  document.getElementById("install").hidden = true;
});

iOS 不一样。Safari 不触发 beforeinstallprompt;用户通过 分享 → 添加到主屏幕 安装。提供 apple-touch-icon,如果想要独立窗口的外观, 再加上 apple-mobile-web-app-capable 元标签。不要写一个假设所有平台都有安装提示的 UI。

6. 离线、更新和测试

一个能离线的应用需要一个应用壳:让 UI 在没有网络时也能启动的 HTML、CSS、JavaScript、图标和 WebAssembly 二进制。在 install 时缓存这些。不要把用户数据缓存到和壳同一个缓存里;数据用 IndexedDB 或源私有文件系统(OPFS),把缓存当作代码的副本。

测试行为,不是测试源码:

跑一次 Lighthouse 的 PWA 检查,但不要把分数当成目标。目标是:用户可以安装应用、在没有网络时打开它、 并且在不清理站点数据的情况下收到更新。

7. 本教程就是一个 PWA

你正在读的这个站点本身就是一个 PWA。它的 manifest.json 提供名称、图标和独立显示; 它的 sw.js 预缓存页面;它的 sw-register.js 检测新的 worker,并在重载前询问。 缓存名带版本(fast_io-docs-v80.x),内容一变就必须增加。

最后这个细节最容易咬人。一个激进缓存的 Service Worker 加上一个永不改变的缓存版本,会让回访用户 永远看到旧站点。缓存不是部署细节,它是发布的一部分。

8. 检查清单

  1. HTTPS,或者开发时的 localhost。
  2. 清单包含名称、短名称、启动 URL、scope、display、192 和 512 图标。
  3. Service Worker 以正确的 scope 注册。
  4. 应用壳已预缓存;离线启动可用。
  5. 每次改壳都增加缓存版本。
  6. 更新流程测试过;不会永久停留在旧状态。
  7. 在 Android、iOS 和桌面上测试过安装。
  8. 没有混合内容;客户端没有密钥。