第21.2章:如何编写 PWA
1. PWA 是什么
渐进式网络应用(PWA)是一个网站加上三样东西:
- 一个Web 应用清单(manifest),描述名称、图标、启动 URL、显示模式和主题色;
- 一个Service Worker,运行在页面和网络之间的 JavaScript worker,可以缓存资源以供离线使用;
- HTTPS,让 Service Worker 可以被信任。
这就是全部的平台要求。没有 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;
});
})
);
});
三条规则决定它是演示还是能用的应用:
-
给缓存加版本。壳一变就增加
VERSION,否则回访用户一直拿旧文件。 - 按资源选择策略。应用壳用缓存优先;API 和必须新鲜的内容用网络优先; 可以稍微旧一点的内容用 stale-while-revalidate。
- 不要什么都缓存。第三方响应、跨源的不透明响应和大媒体不要盲目塞进缓存。
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),把缓存当作代码的副本。
测试行为,不是测试源码:
- DevTools → Application → Manifest:清单有效吗?可安装吗?
- Application → Service Workers:它激活了吗?当前是哪个缓存?
- Network → Offline:应用还能启动和渲染吗?
- 改一个壳文件、增加版本、重载:更新到了吗?
- 在 Android、iOS 和桌面上安装:它是否以独立窗口打开?
跑一次 Lighthouse 的 PWA 检查,但不要把分数当成目标。目标是:用户可以安装应用、在没有网络时打开它、 并且在不清理站点数据的情况下收到更新。
7. 本教程就是一个 PWA
你正在读的这个站点本身就是一个 PWA。它的 manifest.json 提供名称、图标和独立显示;
它的 sw.js 预缓存页面;它的 sw-register.js 检测新的 worker,并在重载前询问。
缓存名带版本(fast_io-docs-v80.x),内容一变就必须增加。
最后这个细节最容易咬人。一个激进缓存的 Service Worker 加上一个永不改变的缓存版本,会让回访用户 永远看到旧站点。缓存不是部署细节,它是发布的一部分。
8. 检查清单
- HTTPS,或者开发时的 localhost。
- 清单包含名称、短名称、启动 URL、scope、display、192 和 512 图标。
- Service Worker 以正确的 scope 注册。
- 应用壳已预缓存;离线启动可用。
- 每次改壳都增加缓存版本。
- 更新流程测试过;不会永久停留在旧状态。
- 在 Android、iOS 和桌面上测试过安装。
- 没有混合内容;客户端没有密钥。