关于 progressive-web-app
帮助开发者构建渐进式 Web 应用的领域技能,介绍清单、Service Worker、缓存策略和离线支持。适用于需要安装到主屏幕或应对不稳定网络的 Web 项目。
主要功能
- 01配置 Web App Manifest
- 02注册与管理 Service Worker
- 03实现应用外壳预缓存
- 04选择资源缓存策略
- 05处理离线页面与缓存更新
- 06说明浏览器安装差异
适用场景
- 01让 Web 应用支持离线访问
- 02为移动端网站添加安装体验
- 03设计静态与动态资源缓存
- 04排查 Service Worker 部署问题
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ sickn33/ agentic-awesome-skills --skill progressive-web-app安装后在 Claude Code 中输入/progressive-web-app确认已加载。
详细介绍
渐进式 Web 应用(PWA)
概述
渐进式 Web 应用是一种利用现代浏览器功能,提供快速、可靠且可安装体验的 Web 应用,即使在网络不稳定时也能如此。常见的构成要素(安装条件因浏览器而异)包括:
- HTTPS — 生产环境中,服务工作线程必须通过它才能注册(开发时 localhost 除外)。
- Web 应用清单(
manifest.json)— 让应用可安装,并定义应用在设备主屏幕上的外观。 - 服务工作线程(
sw.js)— 在后台运行的脚本,可拦截网络请求、管理缓存并启用离线功能。
何时使用此技能
- 用户希望其 Web 应用在离线或网络不稳定时也能使用时。
- 构建移动优先的 Web 项目,且用户应能将应用安装到主屏幕时。
- 用户询问缓存策略、服务工作线程,或如何提升 Web 应用性能和弹性时。
- 用户提到 Workbox、Web 应用清单、后台同步或 Web 推送通知时。
- 用户询问“我的网站能像应用一样安装吗?”或“如何让网站离线可用?”,即使他们没有使用 PWA 一词时。
交付物检查清单
此示例布局使用以下文件;这些确切的文件名并非所有 PWA 都必须采用:
-
index.html— 链接清单并注册服务工作线程 -
manifest.json— 完整的应用元数据和图标集 -
sw.js— 包含 install、activate 和 fetch 处理程序的服务工作线程 -
app.js— 包含 SW 注册和安装提示处理逻辑的应用主逻辑 -
offline.html— 导航在离线时失败后显示的备用页面(这是此示例的 cache.addAll 列表所需,并非 PWA 标准要求)
步骤 1:Web 应用清单(manifest.json)
定义应用安装后的外观。必须通过 <link rel="manifest"> 从 <head> 中链接。
{
"name": "My Awesome PWA",
"short_name": "MyPWA",
"description": "A fast, offline-capable Progressive Web App.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#0055ff",
"icons": [
{
"src": "/assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/assets/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}
关键字段:
display:standalone会隐藏浏览器 UI;minimal-ui会显示最少的控件;browser表示标准标签页。- 图标上的
purpose: "maskable"可在 Android 上启用自适应图标(安全区域很重要,请将内容保持在中央 80% 区域内)。 screenshots为可选项,但要在桌面版 Chrome 的增强型安装对话框中显示则必须提供。
步骤 2:HTML 外壳(index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Awesome PWA</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0055ff">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="MyPWA">
<link rel="apple-touch-icon" href="/assets/icons/icon-192x192.png">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div id="app">
<header><h1>My PWA</h1></header>
<main id="content">Loading...</main>
<button id="install-btn" hidden>Install App</button>
</div>
<script src="/app.js"></script>
</body>
</html>
步骤 3:服务工作线程注册与安装提示(app.js)
// ─── Service Worker Registration ───────────────────────────────────────────
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('[App] SW registered, scope:', registration.scope);
} catch (err) {
console.error('[App] SW registration failed:', err);
}
});
}
// ─── Install Prompt (Add to Home Screen) ───────────────────────────────────
let deferredPrompt;
const installBtn = document.getElementById('install-btn'); // may be null if omitted
// Capture the browser's install prompt — it fires before the browser's own UI
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // Stop automatic mini-infobar on mobile
deferredPrompt = e;
if (installBtn) installBtn.hidden = false; // Show your custom install button
});
if (installBtn) {
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log('[App] Install outcome:', outcome);
deferredPrompt = null;
installBtn.hidden = true;
});
}
// Fires when the app is installed (via browser or your button)
window.addEventListener('appinstalled', () => {
console.log('[App] PWA installed successfully');
if (installBtn) installBtn.hidden = true;
});
步骤 4:服务工作线程(sw.js)
缓存版本控制(至关重要 — 每次部署都要递增)
const CACHE_VERSION = 'v1';
const CACHE_PREFIX = 'my-pwa-'; // Unique to this app and deployment scope.
const STATIC_CACHE = `${CACHE_PREFIX}static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `${CACHE_PREFIX}dynamic-${CACHE_VERSION}`;
// Files to pre-cache during install (the "App Shell")
const APP_SHELL = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/assets/icons/icon-192x192.png',
'/offline.html', // Fallback page shown when network is unavailable
];
手写的策略只是用于教学的示例。在调用 skipWaiting/clients.claim 之前协调更新,以避免旧页面与新工作线程不兼容。限制缓存大小和保留时间,保留其他应用的缓存,并将后台重新验证与 event.waitUntil 关联,同时处理拒绝情况。
安装 — 预缓存应用外壳
self.addEventListener('install', (event) => {
console.log('[SW] Installing...');
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
console.log('[SW] Pre-caching app shell');
return cache.addAll(APP_SHELL);
})
);
// Activate immediately without waiting for old SW to die
self.skipWaiting();
});
激活 — 清理旧缓存
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name.startsWith(CACHE_PREFIX) && name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
);
// Take control of all pages immediately
self.clients.claim();
});
获取 — 缓存策略
根据资源类型选择合适的策略:
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Only handle GET requests from our own origin
if (request.method !== 'GET' || url.origin !== location.origin) return;
// Explicit public resources only. Never cache authenticated/user-specific
// HTML or API responses by URL; cache contents can survive logout/account changes.
if (!APP_SHELL.includes(url.pathname)) return;
// Strategy A: Cache-First (for static assets — fast, tolerates stale)
if (url.pathname.match(/\.(css|js|png|jpg|svg|woff2)$/)) {
event.respondWith(cacheFirst(request));
return;
}
// Strategy B: Network-First (for HTML pages — fresh, falls back to cache)
if (request.headers.get('Accept')?.includes('text/html')) {
event.respondWith(networkFirst(request));
return;
}
// API caching is intentionally omitted; default to network-only for private data.
});
// ─── Strategy Implementations ──────────────────────────────────────────────
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
const cache = await caches.open(STATIC_CACHE);
if (response.ok && response.type !== "opaque") await cache.put(request, response.clone());
return response;
} catch {
// Nothing useful to fall back to for assets
return new Response('Asset unavailable offline', { status: 503 });
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(DYNAMIC_CACHE);
if (response.ok && response.type !== "opaque") await cache.put(request, response.clone());
return response;
} catch {
const cached = await caches.match(request);
return cached || await caches.match('/offline.html') || new Response("Offline", { status: 503 });
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(DYNAMIC_CACHE);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then(async (response) => {
if (response.ok && response.type !== "opaque") await cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
}
边缘情况与平台说明
iOS / Safari 的特殊情况
- Safari 支持清单和服务工作线程,但不支持
beforeinstallprompt— 用户必须手动通过“分享”→“添加到主屏幕”菜单安装。 - 使用
apple-mobile-web-app-*元标签(如上方index.html所示)以正确集成 iOS。 - Safari 可能会在约 7 天无活动后清除服务工作线程缓存(智能跟踪预防)。
HTTPS 要求
- 服务工作线程只能在
https://来源上注册。开发时唯一的例外是http://localhost。 - 如果需要在本地使用自定义主机名配置 HTTPS,可使用
mkcert或ngrok等工具。
部署时清除缓存
- 部署新资源时,务必递增
sw.js中的CACHE_VERSION。这样 activate 会清除旧缓存,用户也能获取最新文件。 - 一种常见做法是通过构建工具(例如 Vite、Webpack)自动注入版本号。
不透明响应(跨源请求)
- 跨源
no-cors响应可能是不透明的,状态码为 0,且响应正文不可读;启用 CORS 的响应不一定是不透明的。不要将状态码 0 解读为成功。 - 对于跨源资源,优先使用
staleWhileRevalidate,或者使用 Workbox 等能够安全处理此类情况的库。
Workbox(可选:生产环境捷径)
对于生产环境应用,可以考虑使用 Workbox(Google 的 PWA 库),而不是手动实现各种策略。它会自动处理边缘情况、缓存过期和版本管理。
// With Workbox (via CDN for simplicity — use npm + bundler in production)
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
const { registerRoute } = workbox.routing;
const { CacheFirst, NetworkFirst, StaleWhileRevalidate } = workbox.strategies;
const { precacheAndRoute } = workbox.precaching;
precacheAndRoute(self.__WB_MANIFEST || []); // Injected by build plugin
registerRoute(({ request }) => request.destination === 'image', new CacheFirst());
registerRoute(({ request }) => request.mode === 'navigate', new NetworkFirst());
registerRoute(({ request }) => request.destination === 'script', new StaleWhileRevalidate());
发布前检查清单
- 网站通过 HTTPS 提供服务
-
manifest.json包含name、short_name、start_url、display、icons(192 + 512) - 图标包含
purpose: "any maskable" -
sw.js在 DevTools → Application → Service Workers 中注册成功,且无错误 - 在 DevTools 中将网络限制为 “Offline” 时,应用 shell 可从缓存加载
-
offline.html后备页面已缓存,并且在离线导航失败时提供 - 当前浏览器的可安装性、离线、无障碍和性能检查均通过;不要依赖已弃用的 Lighthouse PWA 类别
- 已在 iOS Safari(手动安装流程)和 Android Chrome(安装提示)上进行测试
限制
- 仅当任务明确符合上述范围时,才使用此技能。
- 不要将输出视为环境专属验证、测试或专家审查的替代品。
- 如果缺少必要的输入、权限、安全边界或成功标准,请暂停并请求澄清。
评论
支持 Markdown