有些网站可以安装到桌面,拥有独立图标和窗口,断网后仍能打开部分内容。这类体验通常来自 PWA,也就是渐进式 Web 应用。
PWA 没有一份必须全部实现的固定功能清单。它更像一组逐步增强网页体验的技术组合。浏览器支持时,用户可以安装、离线使用或接收更新;不支持时,页面仍然应该作为普通网站正常工作。
这篇教程做一个最小可用的待办清单。它包含网页应用清单、Service Worker、离线缓存和本地运行方法。

一个可安装 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。实际端口以终端输出为准。
打开页面后,在浏览器开发者工具中检查。
- Application 或应用面板中的 Manifest 没有资源错误。
- Service Workers 中能看到 sw.js 已激活。
- Cache Storage 中出现 pwa-todo-v1。
- 添加一条待办,刷新后内容仍在。
- 开发者工具切换到 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 真正难的部分不在安装按钮,而在网络不稳定和版本变化时仍然让用户知道发生了什么。
评论区