侧边栏壁纸
博主头像
踏浪而行生活圈

行动起来,活在当下

  • 累计撰写 26 篇文章
  • 累计创建 20 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

PWA 是什么,做一个能安装和离线打开的网页应用

有些网站可以安装到桌面,拥有独立图标和窗口,断网后仍能打开部分内容。这类体验通常来自 PWA,也就是渐进式 Web 应用。

PWA 没有一份必须全部实现的固定功能清单。它更像一组逐步增强网页体验的技术组合。浏览器支持时,用户可以安装、离线使用或接收更新;不支持时,页面仍然应该作为普通网站正常工作。

这篇教程做一个最小可用的待办清单。它包含网页应用清单、Service Worker、离线缓存和本地运行方法。

pwa-cover.png

一个可安装 PWA 需要什么

现代浏览器通常会检查下面几项。

  • 页面通过 HTTPS 提供,localhost 本地开发可以例外
  • 页面链接了一份 Web App Manifest
  • Manifest 包含名称、图标、启动地址和显示模式等必要信息
  • 应用在浏览器支持的环境中符合安装条件

Service Worker 主要负责离线缓存、请求拦截和后台能力。它对离线体验很重要,但安装提示的具体条件和界面由浏览器决定,不同桌面和手机系统并不完全一样。

先创建目录。

pwa-todo/
  index.html
  styles.css
  app.js
  sw.js
  manifest.webmanifest
  icons/
    icon-192.png
    icon-512.png

两个 PNG 图标需要真正存在。不要把同一张小图简单改文件名,至少导出 192×192 和 512×512 两个尺寸。

写页面和清单功能

index.html 负责页面结构,并在 head 中关联 Manifest。

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="theme-color" content="#176b5b">
    <link rel="manifest" href="/manifest.webmanifest">
    <link rel="stylesheet" href="/styles.css">
    <title>离线待办</title>
  </head>
  <body>
    <main>
      <h1>离线待办</h1>
      <form id="todo-form">
        <label for="todo-input">今天要做什么</label>
        <div class="input-row">
          <input id="todo-input" required autocomplete="off">
          <button type="submit">添加</button>
        </div>
      </form>
      <ul id="todo-list"></ul>
      <p id="network-state" role="status"></p>
    </main>
    <script src="/app.js" defer></script>
  </body>
</html>

给页面加一份简单样式。

:root {
  color-scheme: light dark;
  font-family: system-ui, sans-serif;
}

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: start center;
  background: #eef4f1;
  color: #17211e;
}

main {
  width: min(92vw, 640px);
  margin-top: 8vh;
}

.input-row {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 8px;
}

input,
button {
  min-height: 44px;
  font: inherit;
}

li {
  margin-block: 10px;
}

@media (prefers-color-scheme: dark) {
  body {
    background: #13201c;
    color: #f2f7f5;
  }
}

app.js 保存待办并显示网络状态。数据放在 localStorage 中,所以刷新和离线打开后仍会保留。

const form = document.querySelector("#todo-form");
const input = document.querySelector("#todo-input");
const list = document.querySelector("#todo-list");
const networkState = document.querySelector("#network-state");

let todos = JSON.parse(localStorage.getItem("todos") || "[]");

function save() {
  localStorage.setItem("todos", JSON.stringify(todos));
}

function render() {
  list.replaceChildren();

  for (const todo of todos) {
    const item = document.createElement("li");
    item.textContent = todo;
    list.append(item);
  }
}

function showNetworkState() {
  networkState.textContent = navigator.onLine ? "当前在线" : "当前离线";
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const value = input.value.trim();

  if (!value) return;

  todos.push(value);
  save();
  render();
  form.reset();
  input.focus();
});

window.addEventListener("online", showNetworkState);
window.addEventListener("offline", showNetworkState);

render();
showNetworkState();

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js").catch((error) => {
    console.error("Service Worker 注册失败", error);
  });
}

添加 Web App Manifest

manifest.webmanifest 告诉浏览器应用叫什么、从哪里启动、安装后怎么显示。

{
  "id": "/",
  "name": "离线待办",
  "short_name": "待办",
  "description": "一个可以离线打开的简单待办清单",
  "lang": "zh-CN",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "background_color": "#eef4f1",
  "theme_color": "#176b5b",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

id 用来标识应用,start_url 决定从图标启动时打开的地址,scope 限定哪些页面属于这个应用。display 设为 standalone 后,安装版会在独立窗口中运行。

图标还可以增加 purpose 为 maskable 的版本,避免不同系统裁切后主体太小。入门项目先保证两个基础尺寸能正确加载。

用 Service Worker 提供离线页面

sw.js 在首次安装时缓存应用外壳。页面导航优先请求网络,断网时回退到缓存;静态资源则优先从缓存读取。

const CACHE_NAME = "pwa-todo-v1";
const APP_SHELL = [
  "/",
  "/index.html",
  "/styles.css",
  "/app.js",
  "/manifest.webmanifest",
  "/icons/icon-192.png",
  "/icons/icon-512.png"
];

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

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

self.addEventListener("fetch", (event) => {
  if (event.request.mode === "navigate") {
    event.respondWith(
      fetch(event.request).catch(() => caches.match("/index.html"))
    );
    return;
  }

  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) return cached;

      return fetch(event.request).then((response) => {
        const copy = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, copy);
        });
        return response;
      });
    })
  );
});

这个策略适合演示静态应用。真实项目要按数据特性选择缓存方式。新闻列表、接口响应和用户文件如果一律缓存优先,很容易长期显示旧内容,甚至缓存不该保留的敏感数据。

如果你想继续理解页面请求如何经过浏览器、网络和服务器,可以看站内的浏览器请求全过程

在本地运行和检查

不要直接双击 index.html。Service Worker 需要受信任的来源,localhost 可以用于本地开发。

如果电脑已安装 Node.js,可以在项目目录运行。

npx serve .

终端会显示本地地址,通常是 http://localhost:3000。实际端口以终端输出为准。

打开页面后,在浏览器开发者工具中检查。

  1. Application 或应用面板中的 Manifest 没有资源错误。
  2. Service Workers 中能看到 sw.js 已激活。
  3. Cache Storage 中出现 pwa-todo-v1。
  4. 添加一条待办,刷新后内容仍在。
  5. 开发者工具切换到 Offline,刷新后页面仍能打开。

浏览器满足安装条件后,可能在地址栏或菜单中显示安装入口。iOS 上通常通过 Safari 的分享菜单添加到主屏幕。不要把某个浏览器没有弹出安装提示直接判断为 PWA 失败,先检查 Manifest、图标和 Service Worker,再查该平台的安装方式。

更新缓存时别忘了改版本号

修改 app.js 或 styles.css 后,把 CACHE_NAME 改为新值。

const CACHE_NAME = "pwa-todo-v2";

浏览器会下载新的 Service Worker。旧页面仍可能由旧 worker 控制,关闭应用的所有标签页并重新打开后,新版本通常才会接管。

正式项目可以提供发现新版本后的刷新提示,也可以设计更主动的更新流程。直接强制新 worker 接管可能让旧页面和新资源混用,所以要结合应用状态认真处理。

如果希望页面切换更接近原生应用,可以继续阅读View Transitions API 入门

做完这个示例后

这个最小项目已经具备可安装、独立窗口、离线打开和本地数据保存。接下来可以逐步加入 IndexedDB、离线提交队列、推送通知和更新提示。

每增加一种能力,都要考虑失败路径。离线写入什么时候同步,重复提交如何处理,缓存里是否包含敏感信息,旧版本能否读取新数据结构。PWA 真正难的部分不在安装按钮,而在网络不稳定和版本变化时仍然让用户知道发生了什么。

参考资料

0

评论区