起因
做了一个工具站,用户反馈"每次都要打开浏览器 → 输 URL,能不能像 app
一样固定在桌面?" PWA(Progressive Web App)让任何网站能被
"添加到主屏幕"作为独立 app 启动,而不需要写 React Native / Flutter。
满足 PWA 安装条件:
- HTTPS(localhost 例外)
- valid
manifest.webmanifest - service worker(至少注册)
- 192×192 + 512×512 PNG icon
最小步骤
1. icons
用 Real Favicon Generator 或者
imagemagick 自己生成:
convert source.png -resize 192x192 icon-192.png
convert source.png -resize 512x512 icon-512.png
convert source.png -resize 192x192 -background none -gravity center \
-extent 192x192 icon-192-maskable.png # maskable 留 padding
放 public/icons/。
2. manifest.webmanifest
public/manifest.webmanifest:
{
"name": "My Tool App",
"short_name": "MyTool",
"description": "在线小工具",
"start_url": "/?source=pwa",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#2563eb",
"background_color": "#ffffff",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "新建",
"url": "/new",
"icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
}
]
}
要点:
display: standalone让安装后启动是无浏览器 UI 的独立窗口start_url: /?source=pwa加 source 参数便于分析 PWA 启动量maskableicon:Android 自适应图标,让系统裁圆角不丢内容shortcuts:长按 app 图标显示快捷菜单(如"新建文档")
3. 链接到 HTML
<head>
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/icons/icon-192.png"> <!-- iOS -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="MyTool">
</head>
iOS Safari 不完全遵循 web manifest,需要 apple-* meta 兼容。
4. Service Worker(让"可安装"条件满足)
最简版:
// public/sw.js
self.addEventListener('install', e => {
self.skipWaiting()
})
self.addEventListener('activate', e => {
e.waitUntil(self.clients.claim())
})
self.addEventListener('fetch', e => {
// 不做任何拦截,纯透传
})
// main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
})
}
这就够 Chrome 把"安装"图标显示在地址栏。
5. 加离线兜底页(推荐)
// public/sw.js
const CACHE = 'v1'
const OFFLINE_URL = '/offline.html'
self.addEventListener('install', e => {
e.waitUntil(
caches.open(CACHE).then(c => c.addAll(['/', OFFLINE_URL, '/icons/icon-192.png']))
)
self.skipWaiting()
})
self.addEventListener('activate', e => {
e.waitUntil(self.clients.claim())
})
self.addEventListener('fetch', e => {
if (e.request.mode === 'navigate') {
e.respondWith(
fetch(e.request).catch(() => caches.match(OFFLINE_URL))
)
}
})
public/offline.html:
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>离线</title></head>
<body>
<h1>当前没有网络</h1>
<p>请检查网络连接后重试。</p>
</body>
</html>
断网时用户打开 PWA 看到"离线"页而不是浏览器报 ERR_INTERNET_DISCONNECTED。
6. 自定义安装按钮
let deferredPrompt = null
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredPrompt = e
document.getElementById('install-btn').style.display = 'block'
})
document.getElementById('install-btn').addEventListener('click', async () => {
if (!deferredPrompt) return
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
console.log('user choice:', outcome)
deferredPrompt = null
document.getElementById('install-btn').style.display = 'none'
})
控制安装提示的时机(如用户用了 5 分钟后才显示),比浏览器默认弹的
体验好。
配合 Vite
npm i -D vite-plugin-pwa
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'My Tool',
short_name: 'MyTool',
theme_color: '#2563eb',
icons: [/* ... */],
},
workbox: {
globPatterns: ['**/*.{js,css,html,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\./,
handler: 'NetworkFirst',
options: { cacheName: 'api', expiration: { maxAgeSeconds: 60*60*24 }},
},
],
},
}),
],
})
vite-plugin-pwa 用 Workbox 自动生成 manifest + service worker,
含 precache / runtime cache / 更新策略。
效果
- 用户打开后 Chrome 地址栏显示 ⊕ 安装按钮
- 安装后桌面 / 应用列表里有 app icon
- 启动 app 是独立窗口(无浏览器地址栏 / 标签)
- 断网时显示"离线页"而不是浏览器错误
- iOS / Android / 桌面 Chrome / Edge 都支持
- 不需要 App Store 审核
验证
Chrome DevTools → Application → Manifest:
- 看 manifest 是否被正确解析
- icon 预览
- "Installability" 一栏告诉你不满足哪些条件
Lighthouse → PWA 类别也可以跑评分。
推送通知(可选)
// 申请权限
const perm = await Notification.requestPermission()
if (perm === 'granted') {
const sub = await navigator.serviceWorker.ready
.then(reg => reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey,
}))
// 把 sub 发给后端,后端用 web-push 库发送
}
iOS Safari 16.4+ 才支持 web push(且要求"添加到主屏幕"后)。
踩过的坑
-
manifest URL 写错:
<link rel="manifest" href="/manifest.json">
但文件叫manifest.webmanifest。404 没声音,PWA 静默不可安装。
DevTools → Application 检查。 -
HTTP:service worker 不工作,安装条件不满足。localhost 是例外,
生产必须 HTTPS。 -
icon 路径错:相对路径 vs 绝对路径混乱。manifest 里推荐绝对
路径/icons/...。 -
iOS 安装后地址栏没了,分享 / 复制 URL 不便:iOS PWA 没"分享
到浏览器"按钮。给 app 内自己加分享按钮(navigator.share())。 -
service worker cache 把新版本卡住:用户装了 PWA 后总用老版本。
registerType: 'autoUpdate'+ 检测到新版后提示用户刷新。
登录后参与评论。