Service Worker 与 PWA 完整指南:从离线缓存到消息推送实战

By | 2026年10月2日

为什么你的下一个项目需要 PWA

在移动优先的时代,用户期望应用能像原生一样快速、可靠、可安装。PWA(渐进式 Web 应用)通过 Service Worker 赋予 Web 应用离线能力、后台同步和推送通知,而无需上架应用商店。

但很多开发者对 Service Worker 的理解停留在“缓存 HTML”的层面,实际落地时会遇到缓存更新不及时、推送权限被拒、后台同步失败等问题。本文将用一个可运行的 Demo(一个简单的笔记应用)带你完整走一遍 PWA 的实现路径,并分享我在生产环境中总结的 7 个最佳实践。

环境准备与项目初始化

我们使用原生 JavaScript + Vite 构建,避免框架干扰。先创建项目:


npm create vite@latest pwa-notes -- --template vanilla
cd pwa-notes
npm install
npm install workbox-window -D

项目结构如下:


pwa-notes/
├── index.html
├── main.js
├── sw.js          # Service Worker 入口
├── manifest.json  # PWA 清单文件
└── icons/         # 应用图标

第一步:编写 manifest.json 让应用可安装

PWA 可安装的前提是提供有效的 manifest。在项目根目录创建 manifest.json:


{
  "name": "PWA Notes",
  "short_name": "Notes",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4f46e5",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

在 index.html 的 中引入:


<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#4f46e5" />

注意:start_url 必须是相对路径或同源路径,否则安装提示不会出现。图标至少提供 192×192 和 512×512 两个尺寸,Chrome 才会触发安装横幅。

第二步:Service Worker 生命周期与注册

Service Worker 的生命周期是理解一切缓存行为的基础。它经历 install → waiting → activate → fetch 等阶段。关键点:新的 SW 不会立即接管页面,直到所有旧页面关闭。

在 main.js 中注册:


if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/'
      });
      console.log('SW 注册成功,作用域:', registration.scope);
    } catch (error) {
      console.error('SW 注册失败:', error);
    }
  });
}

💡 最佳实践:不要用 navigator.serviceWorker.ready 来等待激活,它永远不会在注册失败时 resolve。更好的做法是监听 updatefound 事件,提示用户刷新。

第三步:实现离线缓存策略

在 sw.js 中,我们采用 Stale-While-Revalidate 策略:优先返回缓存,同时后台更新缓存。这是平衡速度和新鲜度的最佳方案。


const CACHE_NAME = 'pwa-notes-v1';
const PRECACHE_URLS = ['/', '/index.html', '/main.js', '/manifest.json'];

// 安装时预缓存核心资源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
  );
  self.skipWaiting(); // 强制新 SW 立即激活
});

// 激活时清理旧缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
      )
    )
  );
  self.clients.claim(); // 立即接管所有页面
});

// 拦截请求
self.addEventListener('fetch', (event) => {
  const { request } = event;
  // 只处理 GET 请求
  if (request.method !== 'GET') return;

  event.respondWith(
    caches.match(request).then((cached) => {
      const fetchPromise = fetch(request)
        .then((networkResponse) => {
          // 只缓存成功的响应
          if (networkResponse && networkResponse.status === 200) {
            const responseClone = networkResponse.clone();
            caches.open(CACHE_NAME).then((cache) => {
              cache.put(request, responseClone);
            });
          }
          return networkResponse;
        })
        .catch(() => cached); // 网络失败时回退到缓存

      return cached || fetchPromise;
    })
  );
});

注意:skipWaiting() 和 clients.claim() 虽然方便,但可能导致新旧版本资源混用。生产环境建议配合版本号控制,或使用 Workbox 的 GenerateSW 插件自动处理。

第四步:后台同步(Background Sync)

当用户离线时提交笔记,我们希望网络恢复后自动重试。后台同步 API 可以做到:


// main.js 中提交表单
async function submitNote(note) {
  try {
    const response = await fetch('/api/notes', {
      method: 'POST',
      body: JSON.stringify(note),
      headers: { 'Content-Type': 'application/json' }
    });
    if (!response.ok) throw new Error('提交失败');
  } catch (error) {
    // 离线或失败,注册后台同步
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-notes');
    // 将数据存入 IndexedDB 以便后续重试
    await saveNoteToIndexedDB(note);
  }
}

在 sw.js 中监听同步事件:


self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-notes') {
    event.waitUntil(retryPendingNotes());
  }
});

async function retryPendingNotes() {
  const notes = await getPendingNotesFromIndexedDB();
  for (const note of notes) {
    try {
      await fetch('/api/notes', {
        method: 'POST',
        body: JSON.stringify(note),
        headers: { 'Content-Type': 'application/json' }
      });
      await deleteNoteFromIndexedDB(note.id);
    } catch (error) {
      // 仍然失败,等待下次同步
      throw error;
    }
  }
}

💡 最佳实践:后台同步只在用户有网络时触发,但 Chrome 要求页面在后台时才能触发同步。测试时可以通过 DevTools 的 Application → Background Services → Sync 手动触发。

第五步:消息推送(Push API)

推送需要后端配合,这里我们用 Node.js 演示一个极简的推送服务。先在前端订阅:


// main.js 中请求通知权限并订阅
async function subscribePush() {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return;

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

  // 将 subscription 发送到后端保存
  await fetch('/api/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription),
    headers: { 'Content-Type': 'application/json' }
  });
}

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = atob(base64);
  return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}

在 sw.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-192.png',
      badge: '/icons/badge.png',
      data: { url: data.url || '/' }
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(event.notification.data.url)
  );
});

注意:VAPID 密钥对必须通过 web-push 库生成,公钥在前端使用,私钥在后端签名。不要将私钥暴露在前端代码中。

常见坑与解决方案

1. 缓存了错误的响应

cache.put() 会缓存所有响应,包括 404 和 500。务必检查 response.status === 200 且 response.type === 'basic'(同源)。

2. 开发环境缓存导致热更新失效

在开发环境中,建议禁用 Service Worker 或使用 network-first 策略。Vite 项目可以通过环境变量判断:


if (import.meta.env.DEV) {
  // 开发环境不注册 SW
} else {
  navigator.serviceWorker.register('/sw.js');
}

3. 推送权限被拒绝后无法再次请求

一旦用户拒绝,Notification.requestPermission() 不会再弹出。必须引导用户手动在浏览器设置中开启。

4. iOS Safari 的兼容性

iOS 从 16.4 开始支持 Web Push,但要求 PWA 必须添加到主屏幕。且不支持 beforeinstallprompt 事件,需要手动引导用户通过分享菜单安装。

使用 Workbox 简化生产环境

手动管理缓存版本和策略容易出错,推荐使用 Workbox。安装:


npm install workbox-webpack-plugin -D

在 vite.config.js 中配置:


import { VitePWA } from 'vite-plugin-pwa';

export default {
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        globPatterns: ['**/*.{js,css,html,png,svg}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 50, maxAgeSeconds: 86400 }
            }
          }
        ]
      }
    })
  ]
};

Workbox 会自动生成 sw.js 并处理版本控制,你只需关注业务逻辑。

总结与下一步

通过本文,你完成了:

  • 配置 manifest 使应用可安装
  • 实现 Stale-While-Revalidate 缓存策略
  • 使用 Background Sync 处理离线提交
  • 集成 Push API 实现消息推送
  • 了解常见坑与 Workbox 最佳实践

下一步建议:

  1. 学习 Workbox 的 BroadcastUpdatePlugin 实现页面无刷新更新
  2. 探索 Periodic Background Sync 实现定时同步
  3. 使用 Lighthouse 审计 PWA 得分,优化性能指标

PWA 不是银弹,但它是目前最接近原生体验的 Web 方案。希望这篇指南能帮你少走弯路。