Ch21.2: How to Write a PWA

中文(简体)

1. What a PWA Is

A Progressive Web App is a website with three additions:

That is the whole platform requirement. There is no SDK, no store account and no native project. If the browser supports it, the app is installable; if it does not, the same URL still works as an ordinary website.

2. What You Need to Know

You need basic HTML, CSS and JavaScript, plus the web app manifest. You do not need a native UI toolkit, a platform language or a cross-platform framework. The UI is HTML and CSS; the logic is JavaScript or WebAssembly; the manifest and the service worker are JSON and JavaScript.

That is a much smaller prerequisite list than "learn Android and iOS", and it is the reason the web is the practical cross-platform target. If you can read a web page's source and understand a fetch call, you can build a PWA.

It is also one of the easiest things to delegate to an AI assistant. The pieces are standard, well documented and self-contained: a manifest, a service worker, a registration script, an install button and a responsive page. AI is extremely good at writing PWAs; it can produce a working scaffold in one pass. Use it for the scaffold, but verify the two things that matter yourself: that the app is actually installable, and that it actually works offline. Those are behaviors, not text.

3. The Manifest

The manifest is a JSON file linked from the page:

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

A minimal manifest that satisfies installability looks like this:

{
  "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" }
  ]
}

The fields that matter for installability are name or short_name, start_url, display (or display_override), and icons at 192 and 512 pixels. Add scope so the app does not claim the whole origin, and id so the browser can keep the app identity stable if start_url changes later.

display: standalone hides the browser chrome. display_override lets a browser choose a richer mode such as window-controls-overlay on desktop while falling back to standalone. Add screenshots if you want a richer install UI, and shortcuts, share_target or file_handlers only when the app actually implements them.

4. The Service Worker

The service worker is the part that makes the app work offline. It has three lifecycle events: install, activate and fetch. A small cache-first 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;
      });
    })
  );
});

Three rules make the difference between a demo and a working app:

The service worker is also the update mechanism. A new worker installs in the background and waits. You can call self.skipWaiting() to activate it immediately, and self.clients.claim() to take control of open pages. The safer pattern is to ask the user before reloading, which is what this tutorial's sw-register.js does.

5. Registration and Install UX

Register the worker from the page:

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

On Chromium, beforeinstallprompt lets you show your own install button instead of relying on the browser's 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 is different. Safari does not fire beforeinstallprompt; the user installs through Share → Add to Home Screen. Provide an apple-touch-icon, and if you want the standalone look, the apple-mobile-web-app-capable meta tag. Do not build a UI that assumes an install prompt exists on every platform.

6. Offline, Updates and Testing

An offline-capable app needs an app shell: the HTML, CSS, JavaScript, icons and WebAssembly binary that make the UI start without the network. Cache those at install. Do not cache the user's data in the same cache as the shell; use IndexedDB or the Origin Private File System for data, and treat the cache as a copy of the code.

Test the behaviors, not the source:

Run Lighthouse's PWA checks once, but do not treat the score as the goal. The goal is that a user can install the app, open it without a network, and receive an update without clearing site data.

7. This Tutorial Is a PWA

The site you are reading is itself a PWA. Its manifest.json supplies the name, icons and standalone display; its sw.js pre-caches the pages; its sw-register.js detects a new worker and asks before reloading. The cache name carries a version (fast_io-docs-v80.x) that must be bumped whenever the content changes.

That last detail is the one that bites. A service worker that caches aggressively and a cache version that never changes produce a site that is permanently stale for returning users. The cache is not a deployment detail; it is part of the release.

8. Checklist

  1. HTTPS, or localhost during development.
  2. Manifest with name, short name, start URL, scope, display, 192 and 512 icons.
  3. Service worker registered with the right scope.
  4. App shell pre-cached; offline start works.
  5. Cache version bumped on every shell change.
  6. Update flow tested; no permanent stale state.
  7. Install tested on Android, iOS and desktop.
  8. No mixed content; no secrets in the client.