6 min read

PWA 完全指南:打造原生体验的 Web 应用

Table of Contents

什么是 PWA?

Progressive Web App(渐进式 Web 应用) 是一种使用 Web 技术构建的应用,它能提供接近原生应用的体验。PWA 不是某种特定技术,而是一组最佳实践的集合。

PWA 的核心特征

特征说明
可安装用户可将应用添加到主屏幕,无需应用商店
离线工作无网络时仍能访问核心功能
推送通知可向用户发送消息提醒
响应式适配任何设备和屏幕尺寸
安全必须通过 HTTPS 提供服务
可发现搜索引擎可以索引内容

为什么需要 PWA?

  • 跨平台:一套代码,运行在所有设备上
  • 无需安装:降低用户使用门槛
  • 更新自动:无需用户手动更新版本
  • 体积小:相比原生应用,占用空间极小
  • 分享方便:一个 URL 即可分享

PWA 的三大支柱

1. Service Worker

Service Worker 是运行在后台的脚本,独立于网页,是 PWA 的核心引擎

// service-worker.js
const CACHE_NAME = 'my-pwa-v1';
const ASSETS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/images/logo.png'
];

// 安装阶段:缓存核心资源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(ASSETS);
    })
  );
});

// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
      );
    })
  );
});

// 拦截请求:缓存优先策略
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request);
    })
  );
});

2. Web App Manifest

Manifest 是一个 JSON 文件,定义应用的元数据,让浏览器知道如何安装和显示应用。

// manifest.json
{
  "name": "我的 PWA 应用",
  "short_name": "PWA Demo",
  "description": "一个渐进式 Web 应用示例",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#42b983",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

在 HTML 中引用:

<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#42b983">
<link rel="apple-touch-icon" href="/icons/icon-152x152.png">

3. HTTPS

PWA 必须通过 HTTPS 提供服务(本地开发除外)。这是为了保护用户数据和防止中间人攻击。


缓存策略

不同的场景需要不同的缓存策略:

1. Cache First(缓存优先)

适合静态资源(CSS、JS、图片):

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request).then((response) => {
        return caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    })
  );
});

2. Network First(网络优先)

适合需要最新内容的场景(新闻、数据):

self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request).catch(() => {
      return caches.match(event.request);
    })
  );
});

3. Stale While Revalidate

适合不频繁变化的内容(头像、配置):

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cached) => {
        const fetchPromise = fetch(event.request).then((response) => {
          cache.put(event.request, response.clone());
          return response;
        });
        return cached || fetchPromise;
      });
    })
  );
});

实现离线页面

当用户离线且访问未缓存的页面时,显示友好的离线提示:

// 创建离线页面
// offline.html
<!DOCTYPE html>
<html>
<head>
  <title>离线了</title>
  <style>
    body {
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      font-family: sans-serif;
      text-align: center;
    }
  </style>
</head>
<body>
  <div>
    <h1>📡 你已离线</h1>
    <p>请检查网络连接后重试</p>
    <button onclick="location.reload()">重试</button>
  </div>
</body>
</html>
// 在 Service Worker 中
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request).catch(() => {
      if (event.request.headers.get('accept').includes('text/html')) {
        return caches.match('/offline.html');
      }
    })
  );
});

推送通知

1. 请求用户授权

async function requestNotificationPermission() {
  if (!('Notification' in window)) {
    console.log('此浏览器不支持通知');
    return;
  }

  const permission = await Notification.requestPermission();
  
  if (permission === 'granted') {
    console.log('通知权限已授予');
    // 注册推送
    registerPushSubscription();
  }
}

2. 注册推送订阅

async function registerPushSubscription() {
  const registration = await navigator.serviceWorker.ready;
  
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array('YOUR_VAPID_PUBLIC_KEY')
  });

  // 将订阅信息发送到你的服务器
  await fetch('/api/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });
}

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

3. 处理推送消息

// service-worker.js
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};
  
  event.waitUntil(
    self.registration.showNotification(data.title || '新消息', {
      body: data.body || '你有新的通知',
      icon: '/icons/icon-192x192.png',
      badge: '/icons/badge-72x72.png',
      data: data.url || '/',
      actions: [
        { action: 'open', title: '打开' },
        { action: 'dismiss', title: '关闭' }
      ]
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  
  if (event.action === 'open') {
    event.waitUntil(
      clients.matchAll({ type: 'window' }).then((clientList) => {
        for (const client of clientList) {
          if (client.url === event.notification.data && 'focus' in client) {
            return client.focus();
          }
        }
        return clients.openWindow(event.notification.data);
      })
    );
  }
});

安装提示

当用户可以将应用添加到主屏幕时,显示安装提示:

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferredPrompt = e;
  showInstallButton();
});

async function installApp() {
  if (!deferredPrompt) {
    console.log('安装提示不可用');
    return;
  }

  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  
  if (outcome === 'accepted') {
    console.log('用户接受了安装');
  }
  
  deferredPrompt = null;
}
<button id="installBtn" style="display: none;">
  安装应用
</button>

<script>
  const installBtn = document.getElementById('installBtn');
  
  function showInstallButton() {
    installBtn.style.display = 'block';
  }
  
  installBtn.addEventListener('click', installApp);
</script>

检测 PWA 支持

function checkPWASupport() {
  const checks = {
    serviceWorker: 'serviceWorker' in navigator,
    pushManager: 'PushManager' in window,
    notification: 'Notification' in window,
    manifest: !!document.querySelector('link[rel="manifest"]'),
    https: window.location.protocol === 'https:' || 
           window.location.hostname === 'localhost'
  };

  checks.isPWA = Object.values(checks).every(v => v);
  
  console.log('PWA 支持情况:', checks);
  return checks;
}

使用 Vite Plugin PWA(推荐)

如果你使用 Vite 构建项目,可以用这个插件自动生成 Service Worker:

npm install vite-plugin-pwa -D
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';

export default {
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
      manifest: {
        name: '我的 PWA',
        short_name: 'PWA',
        description: '渐进式 Web 应用',
        theme_color: '#42b983',
        icons: [
          {
            src: 'pwa-192x192.png',
            sizes: '192x192',
            type: 'image/png'
          },
          {
            src: 'pwa-512x512.png',
            sizes: '512x512',
            type: 'image/png'
          }
        ]
      },
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: {
                maxEntries: 100,
                maxAgeSeconds: 60 * 60 * 24 // 1 天
              }
            }
          }
        ]
      }
    })
  ]
};

Lighthouse 测试

使用 Chrome DevTools 的 Lighthouse 检查 PWA 合规性:

  1. 打开 Chrome DevTools
  2. 切换到 Lighthouse 标签
  3. 选择 “Progressive Web App”
  4. 运行测试

关键检查项:

  • ✅ 使用了 HTTPS
  • ✅ 注册了 Service Worker
  • ✅ 有 Web App Manifest
  • ✅ 有 <meta name="viewport">
  • ✅ 有合适的图标尺寸
  • ✅ 支持离线访问
  • ✅ 响应式设计

常见陷阱

1. Service Worker 作用域

Service Worker 只能控制其所在路径及子路径:

/service-worker.js  → 控制整个站点
/scripts/sw.js      → 只控制 /scripts/ 及以下

建议: 放在根目录

2. 缓存版本管理

忘记更新缓存名会导致旧资源一直被使用:

// 每次更新时改变版本号
const CACHE_NAME = 'my-app-v2'; // 从 v1 升级到 v2

3. 推送通知滥用

不要频繁请求通知权限,只在用户完成某个操作后请求:

// ❌ 页面加载就请求
// ✅ 用户点击"订阅更新"按钮后请求

总结

PWA 让 Web 应用拥有原生体验:

  • 离线工作:Service Worker 缓存核心资源
  • 可安装:Manifest 定义应用元数据
  • 推送通知:保持用户参与
  • 跨平台:一套代码,多端运行
  • 易部署:无需应用商店审核

开始构建 PWA 的步骤:

  1. 确保使用 HTTPS
  2. 创建 Web App Manifest
  3. 注册 Service Worker
  4. 实现缓存策略
  5. 添加推送通知(可选)
  6. 通过 Lighthouse 测试

PWA 不是未来,而是现在。你的下一个 Web 项目,值得拥有 PWA 的能力。


参考资料: