让网页可"添加到主屏幕":PWA manifest + 离线支持的最小路径

起因

做了一个工具站,用户反馈"每次都要打开浏览器 → 输 URL,能不能像 app
一样固定在桌面?" PWA(Progressive Web App)让任何网站能被
"添加到主屏幕"作为独立 app 启动,而不需要写 React Native / Flutter。

满足 PWA 安装条件:

  1. HTTPS(localhost 例外)
  2. valid manifest.webmanifest
  3. service worker(至少注册)
  4. 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 启动量
  • maskable icon: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(且要求"添加到主屏幕"后)。

踩过的坑

  1. manifest URL 写错<link rel="manifest" href="/manifest.json">
    但文件叫 manifest.webmanifest。404 没声音,PWA 静默不可安装。
    DevTools → Application 检查。

  2. HTTP:service worker 不工作,安装条件不满足。localhost 是例外,
    生产必须 HTTPS。

  3. icon 路径错:相对路径 vs 绝对路径混乱。manifest 里推荐绝对
    路径 /icons/...

  4. iOS 安装后地址栏没了,分享 / 复制 URL 不便:iOS PWA 没"分享
    到浏览器"按钮。给 app 内自己加分享按钮(navigator.share())。

  5. service worker cache 把新版本卡住:用户装了 PWA 后总用老版本。
    registerType: 'autoUpdate' + 检测到新版后提示用户刷新。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。