欢迎光临

Service Worker 与 PWA 完整实战:从离线缓存到推送通知的渐进式 Web 应用指南

前言:为什么你需要关注 PWA?

程序员编写代码,屏幕显示代码编辑器

Progressive Web App(PWA)自 Google 在 2015 年提出以来,已经从一项前沿实验技术演变为现代 Web 开发的基础能力。截至 2026 年,主流浏览器对 PWA 三大核心技术——Service Worker、Web App Manifest 和 Cache API——的支持率已超过 96%。

PWA 最大的价值在于:它让 Web 应用获得了过去只有原生 App 才有的能力——离线访问、后台同步、桌面快捷方式和推送通知。但不同于原生 App,PWA 不需要经过应用商店审核,没有安装包体量限制,更新也无需用户手动操作。

本文将从 Service Worker 的生命周期开始,带你一步步构建一个功能完整的 PWA 应用,涵盖离线缓存策略、动态资源管理、消息推送和后台同步等核心能力。所有代码均可直接运行,并提供完整的生产级配置建议。

Service Worker 基础:生命周期与注册

Service Worker 本质上是一个运行在浏览器后台的 JavaScript 文件,独立于网页主线程。它扮演着网络代理的角色——拦截页面的所有网络请求,并根据你编写的逻辑决定返回缓存内容、从网络获取或两者组合。

Service Worker 的限制

在开始之前,有几个关键限制需要了解:

  • 仅支持 HTTPS:Service Worker 只能在 HTTPS 环境下注册(localhost 开发环境除外)
  • 没有 DOM 访问权限:无法直接操作页面元素
  • 独立生命周期:不与任何页面绑定,浏览器可以随时终止它
  • 异步 API 为主:大量使用 Promise 和 async/await

注册 Service Worker

注册通常在页面主脚本中完成,以下是推荐的生产级注册代码:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// app.js - 主线程注册 Service Worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register(
        '/service-worker.js',
        {
          scope: '/',  // 控制哪些路径下的请求
          updateViaCache: 'none'  // 不通过 HTTP 缓存检查更新
        }
      );

      console.log('Service Worker 注册成功,作用域:', registration.scope);

      // 监听更新事件
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        console.log('检测到新的 Service Worker 版本');

        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
            // 新版本已安装但未激活,此时可以提示用户刷新
            showUpdateNotification();
          }
        });
      });
    } catch (error) {
      console.error('Service Worker 注册失败:', error);
    }
  });
} else {
  console.warn('当前浏览器不支持 Service Worker');
}

生命周期详解

Service Worker 的生命周期包含以下阶段:

阶段 触发事件 说明
下载 浏览器下载 .js 文件 注册后立即触发,每次页面导航或 24 小时后检查更新
安装
1
install
预缓存静态资源的好时机,旧版本仍在运行
等待 如果已有激活的 SW,新版本进入等待状态
激活
1
activate
旧版本被替换,此时可清理旧缓存
空闲 等待拦截请求或接收事件
终止 浏览器空闲回收,节省内存

离线缓存策略:五种模式与选择指南

缓存策略是 PWA 最核心的设计决策。不同资源和场景需要不同的策略,没有放之四海皆准的方案。以下是五种最常用的缓存策略。

策略一:Cache First(缓存优先)

适用于不经常变化的静态资源(图片、CSS、JS 文件)。先查缓存,缓存命中则直接返回;未命中则从网络获取并缓存。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// service-worker.js - 缓存优先策略
self.addEventListener('fetch', (event) => {
  if (event.request.url.match(/\.(jpg|png|svg|css|js)$/)) {
    event.respondWith(cacheFirst(event.request));
  }
});

async function cacheFirst(request) {
  const cache = await caches.open('static-v1');
  const cachedResponse = await cache.match(request);

  if (cachedResponse) {
    return cachedResponse;
  }

  try {
    const networkResponse = await fetch(request);
    if (networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  } catch (error) {
    // 即使网络失败也返回缓存(如果有的话)
    return new Response('离线:资源不可用', { status: 503 });
  }
}

策略二:Network First(网络优先)

适用于 API 接口和动态内容。优先从网络获取,网络不可用时回退到缓存。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 网络优先策略
async function networkFirst(request) {
  const cache = await caches.open('dynamic-v1');

  try {
    const networkResponse = await fetch(request);

    // 只缓存成功的响应
    if (networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }

    return networkResponse;
  } catch (error) {
    const cachedResponse = await cache.match(request);

    if (cachedResponse) {
      console.log('网络不可用,使用缓存:', request.url);
      return cachedResponse;
    }

    // 无网络也无缓存
    return new Response(JSON.stringify({ error: '离线模式,数据不可用' }), {
      status: 503,
      headers: { 'Content-Type': 'application/json' }
    });
  }
}

self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(networkFirst(event.request));
  }
});

策略三:Stale While Revalidate(过期重新验证)

这是性能与新鲜度之间最佳平衡策略。立即返回缓存内容(即使过期),同时在后台发起网络请求更新缓存。适用于新闻列表、博客文章等即时性要求不高的内容。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 过期重新验证策略
async function staleWhileRevalidate(request) {
  const cache = await caches.open('content-v1');
  const cachedResponse = await cache.match(request);

  // 无论缓存是否命中,都发起网络请求(后台更新)
  const fetchPromise = fetch(request)
    .then(async (networkResponse) => {
      if (networkResponse.ok) {
        await cache.put(request, networkResponse.clone());
      }
      return networkResponse;
    })
    .catch((error) => {
      console.log('后台更新失败,将使用缓存:', error);
    });

  // 如果有缓存,立刻返回
  if (cachedResponse) {
    // 后台启动网络更新,不阻塞页面
    event.waitUntil(fetchPromise);
    return cachedResponse;
  }

  // 无缓存,等待网络结果
  return await fetchPromise;
}

策略四:Network Only(仅网络)

适用于支付、表单提交等必须实时的操作,不做任何缓存干预。


1
2
3
4
5
6
7
8
// 仅网络(默认行为)
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/payment') ||
      event.request.method === 'POST') {
    return; // 不拦截,走默认的网络请求
  }
  // ... 其他策略
});

策略五:Cache Only(仅缓存)

适用于「骨架屏」或预渲染的离线页面。如果缓存不存在就直接失败。


1
2
3
4
5
6
7
8
9
10
11
// 仅缓存
async function cacheOnly(request) {
  const cache = await caches.open('offline-shell');
  const cachedResponse = await cache.match(request);

  if (!cachedResponse) {
    throw new Error('缓存中未找到:', request.url);
  }

  return cachedResponse;
}

安装与激活阶段:预缓存与缓存清理

在 Service Worker 的

1
install

事件中,通常会将应用外壳(App Shell)的核心资源预缓存。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// service-worker.js - 安装与激活
const CACHE_VERSION = 'v2';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
const OFFLINE_CACHE = `offline-${CACHE_VERSION}`;

const PRECACHE_URLS = [
  '/',
  '/offline.html',
  '/css/main.css',
  '/js/app.js',
  '/js/vendor.js',
  '/images/logo.png',
  '/images/offline.svg',
  '/fonts/inter.woff2'
];

// 安装:预缓存核心资源
self.addEventListener('install', (event) => {
  console.log('Service Worker: 安装阶段');

  // 强制跳过等待阶段,立即激活新版本
  // 慎用:会让所有打开的页面同时被接管
  // self.skipWaiting();

  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then((cache) => {
        console.log('开始预缓存静态资源...');
        return cache.addAll(PRECACHE_URLS);
      })
      .then(() => {
        console.log('预缓存完成');
      })
      .catch((error) => {
        console.error('预缓存失败:', error);
      })
  );
});

// 激活:清理旧版本缓存
self.addEventListener('activate', (event) => {
  console.log('Service Worker: 激活阶段');

  // 立即接管所有已打开的页面
  event.waitUntil(clients.claim());

  // 清理旧缓存
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((cacheName) => {
            // 删除不属于当前版本的缓存
            return cacheName.startsWith('static-') &&
                   cacheName !== STATIC_CACHE ||
                   cacheName.startsWith('dynamic-') &&
                   cacheName !== DYNAMIC_CACHE;
          })
          .map((cacheName) => {
            console.log('删除旧缓存:', cacheName);
            return caches.delete(cacheName);
          })
      );
    })
  );
});

离线回退页面

当用户完全离线且请求的资源不在缓存中时,一个好的 PWA 会展示离线页面而不是难看的浏览器错误页。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 离线回退页面
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .catch(async () => {
        const cache = await caches.open(OFFLINE_CACHE);
        const cachedResponse = await cache.match(event.request);

        if (cachedResponse) {
          return cachedResponse;
        }

        // 如果是导航请求(页面跳转),返回通用的离线页面
        if (event.request.mode === 'navigate') {
          return cache.match('/offline.html');
        }

        // 其他资源返回 503
        return new Response('网络不可用', {
          status: 503,
          statusText: 'Service Unavailable'
        });
      })
  );
});

对应的离线页面 HTML:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>您已离线</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, sans-serif;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      margin: 0;
      padding: 20px;
      text-align: center;
      background: #f5f5f5;
    }
    .offline-container {
      max-width: 400px;
    }
    img { max-width: 200px; }
    h1 { color: #333; }
    p { color: #666; line-height: 1.6; }
    .retry-btn {
      margin-top: 20px;
      padding: 12px 24px;
      background: #4CAF50;
      color: #fff;
      border: none;
      border-radius: 6px;
      cursor: pointer;
      font-size: 16px;
    }
  </style>
</head>
<body>
  <div class="offline-container">
    <img src="/images/offline.svg" alt="离线状态">
    <h1>网络连接已断开</h1>
    <p>您当前处于离线状态。部分页面内容仍可正常浏览,但实时数据更新将暂不可用。</p>
    <p>请检查您的网络连接后重试。</p>
    <button class="retry-btn" onclick="window.location.reload()">
      重新加载
    </button>
  </div>
</body>
</html>

Web App Manifest:让用户将网站添加到桌面

Web App Manifest 是一个 JSON 文件,控制 PWA 在用户设备上安装后的外观和行为。这是让 Web 应用看起来像原生 App 的关键。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
  "name": "My PWA App",
  "short_name": "MyPWA",
  "description": "一个功能完整的渐进式 Web 应用",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4CAF50",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/images/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/images/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "categories": ["utilities", "productivity"],
  "lang": "zh-CN",
  "dir": "ltr",
  "scope": "/",
  "edge_side_panel": {}
}

在页面 <head> 中引用 manifest:


1
2
3
4
5
&lt;link rel="manifest" href="/manifest.json"&gt;
&lt;meta name="apple-mobile-web-app-capable" content="yes"&gt;
&lt;meta name="apple-mobile-web-app-status-bar-style" content="default"&gt;
&lt;meta name="apple-mobile-web-app-title" content="MyPWA"&gt;
&lt;link rel="apple-touch-icon" href="/images/icon-192x192.png"&gt;

1
display

模式的效果对比:

模式 浏览器 UI 元素 适用场景
1
fullscreen
全部隐藏 游戏、视频播放器、演示文稿
1
standalone
隐藏地址栏,显示状态栏 大多数应用(推荐)
1
minimal-ui
显示基本导航按钮 需要保留浏览器导航体验的应用
1
browser
全部显示 默认,与传统页面无异

推送通知(Push Notification)实战

手机屏幕上显示推送通知

推送通知是 PWA 最接近原生 App 的能力之一。即使浏览器关闭,用户也能收到通知。这需要 3 个组件配合工作:

  • 推送服务:浏览器内置的服务,负责在服务端和客户端之间传递消息
  • Service Worker:接收推送事件并显示通知
  • 后端服务:实际发送推送消息的服务器(使用 Web Push Protocol)

订阅推送(客户端代码)


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// app.js - 订阅推送通知
async function subscribeToPushNotifications() {
  // 用户同意后才可订阅
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.warn('用户拒绝了通知权限');
    return null;
  }

  try {
    const registration = await navigator.serviceWorker.ready;

    // 获取 VAPID 公钥(Base64 URL 编码)
    const publicKey = 'BNfJm...yourVAPIDPublicKey...JX8c';

    // 创建订阅
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,  // 必须为 true,每条推送必须显示通知
      applicationServerKey: urlBase64ToUint8Array(publicKey),
    });

    console.log('推送订阅成功:', JSON.stringify(subscription));

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

    return subscription;
  } catch (error) {
    console.error('推送订阅失败:', error);
    return null;
  }
}

// Base64 URL 转 Uint8Array 工具函数
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 &lt; rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

接收推送并显示通知(Service Worker 端)


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// service-worker.js - 处理推送通知
self.addEventListener('push', (event) => {
  console.log('收到推送消息');

  let data;
  try {
    data = event.data.json();
  } catch {
    // 纯文本消息
    data = {
      title: '新消息',
      body: event.data.text(),
      icon: '/images/icon-192x192.png',
      badge: '/images/badge.png',
    };
  }

  const options = {
    body: data.body || '您有一条新消息',
    icon: data.icon || '/images/icon-192x192.png',
    badge: data.badge || '/images/badge.png',
    vibrate: [200, 100, 200],
    data: {
      url: data.url || '/',
      dateOfArrival: Date.now(),
    },
    actions: [
      { action: 'view', title: '查看详情' },
      { action: 'close', title: '关闭' },
    ],
    tag: data.tag || 'default',
    renotify: data.tag ? true : false,
    requireInteraction: true,
  };

  event.waitUntil(
    self.registration.showNotification(data.title || 'PWA 通知', options)
  );
});

// 处理通知点击事件
self.addEventListener('notificationclick', (event) => {
  const notification = event.notification;
  const action = event.action;

  console.log('通知被点击:', { action, tag: notification.tag });

  notification.close();

  if (action === 'close') {
    return;
  }

  // 打开或聚焦到目标页面
  const urlToOpen = notification.data?.url || '/';

  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true })
      .then((clientList) => {
        // 如果已有打开的页面,聚焦它
        for (const client of clientList) {
          if (client.url === urlToOpen && 'focus' in client) {
            return client.focus();
          }
        }
        // 否则打开新窗口
        if (clients.openWindow) {
          return clients.openWindow(urlToOpen);
        }
      })
  );
});

后端发送推送(Node.js 示例)


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// server/push.js - 使用 web-push 库发送推送
import webpush from 'web-push';

// VAPID 密钥对(首次运行时生成一次)
// npx web-push generate-vapid-keys
const vapidKeys = {
  publicKey: process.env.VAPID_PUBLIC_KEY,
  privateKey: process.env.VAPID_PRIVATE_KEY,
};

webpush.setVapidDetails(
  'mailto:admin@example.com',
  vapidKeys.publicKey,
  vapidKeys.privateKey
);

// 发送通知给单个用户
export async function sendPushNotification(subscription, payload) {
  try {
    const result = await webpush.sendNotification(
      subscription,
      JSON.stringify({
        title: '系统通知',
        body: payload.message,
        icon: '/images/icon-192x192.png',
        url: payload.url || '/',
        tag: payload.tag || `msg-${Date.now()}`,
      })
    );

    console.log('推送发送成功:', result.statusCode);
    return { success: true };
  } catch (error) {
    // 410 Gone:订阅已过期,需要从数据库移除
    if (error.statusCode === 410) {
      console.warn('订阅已过期,需要移除');
      await removeSubscription(subscription);
    }
    console.error('推送发送失败:', error);
    return { success: false, error: error.message };
  }
}

// 广播给所有订阅者
export async function broadcastPush(subscriptions, payload) {
  const results = await Promise.allSettled(
    subscriptions.map((sub) => sendPushNotification(sub, payload))
  );

  return {
    success: results.filter((r) => r.status === 'fulfilled').length,
    failed: results.filter((r) => r.status === 'rejected').length,
  };
}

后台同步(Background Sync)

Background Sync 允许 Service Worker 在网络恢复时自动重试失败的网络请求。这对离线时用户提交的表单或评论非常有价值。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// app.js - 注册后台同步
async function registerBackgroundSync() {
  const registration = await navigator.serviceWorker.ready;

  // 适用于 One-off 同步(如提交表单)
  if ('sync' in registration) {
    try {
      await registration.sync.register('sync-comments');
      console.log('后台同步任务已注册');
    } catch (error) {
      console.error('后台同步注册失败:', error);
    }
  } else {
    // 降级方案:浏览器不支持 Background Sync
    console.warn('当前浏览器不支持 Background Sync');
    // 可以回退到 Service Worker 的 fetch 事件中的重试逻辑
  }
}

// 当用户离线提交评论时调用
async function submitCommentOffline(commentData) {
  // IndexedDB 存储待提交数据
  const db = await openCommentDB();
  await db.add('pending-comments', {
    ...commentData,
    createdAt: Date.now(),
    syncAttempts: 0,
  });

  // 注册后台同步
  await registerBackgroundSync();

  // 显示本地确认
  showToast('评论已保存,网络恢复后自动发送');
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// service-worker.js - 处理后台同步事件
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-comments') {
    console.log('触发后台同步:', event.tag);
    event.waitUntil(syncPendingComments());
  }
});

async function syncPendingComments() {
  const db = await openCommentDB();
  const pendingItems = await db.getAll('pending-comments');

  for (const item of pendingItems) {
    try {
      const response = await fetch('/api/comments', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          postId: item.postId,
          content: item.content,
          author: item.author,
        }),
      });

      if (response.ok) {
        await db.delete('pending-comments', item.id);
        console.log('后台同步成功:', item.id);

        // 可选:通知用户同步成功
        self.registration.showNotification('同步成功', {
          body: '您的评论已成功发布!',
          icon: '/images/icon-192x192.png',
        });
      }
    } catch (error) {
      console.error('后台同步失败:', item.id, error);

      // 更新重试次数,超过阈值则放弃
      item.syncAttempts++;
      if (item.syncAttempts >= 5) {
        await db.delete('pending-comments', item.id);
        console.warn('同步失败次数过多,已丢弃:', item.id);
      } else {
        await db.put('pending-comments', item);
      }
    }
  }
}

生产环境最佳实践

1. 使用 Workbox 简化缓存逻辑

Google 的 Workbox 库封装了 Service Worker 的常见模式,可以大幅减少样板代码:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// service-worker.js (使用 workbox-webpack-plugin)
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// 预缓存 webpack 打包的资源
precacheAndRoute(self.__WB_MANIFEST);

// 图片:缓存优先 + 过期控制
registerRoute(
  /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 天
      }),
    ],
  })
);

// Google Fonts:过期重新验证
registerRoute(
  /^https:\/\/fonts\.googleapis\.com\/css/,
  new StaleWhileRevalidate({
    cacheName: 'google-fonts-stylesheets',
  })
);

// API 请求:网络优先 + 超时
registerRoute(
  /\/api\//,
  new NetworkFirst({
    cacheName: 'api-responses',
    networkTimeoutSeconds: 5,
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5 分钟
      }),
    ],
  })
);

2. Service Worker 更新策略

这是许多 PWA 项目容易忽视的环节。推荐以下更新流程:

  1. 检测更新:每次页面加载时调用
    1
    registration.update()
  2. 提示用户:新版本安装后显示「发现新版本,立即刷新?」的提示
  3. 用户确认后刷新:刷新后新 Service Worker 接管
  4. Cache Busting:每次构建时改变 Service Worker 文件名或使用版本号

3. 缓存容量与清理

存储类型 典型限制 清理策略
Cache Storage 可用磁盘空间的 50%(Chrome) 按 LRU 策略自动清理,新增缓存前手动检查容量
IndexedDB 无硬性限制 需要手动管理,定期删除过期数据
LocalStorage 5-10MB 不推荐在 PWA 中大量使用

4. 调试技巧

  • Chrome DevTools > Application > Service Workers:查看注册状态、手动触发更新、绕过等待
  • DevTools > Application > Cache Storage:查看和删除缓存内容
  • DevTools > Application > Background Services:查看推送和后台同步的记录
  • chrome://serviceworker-internals/:查看所有已注册的 Service Worker
  • 使用
    1
    self.__WB_MANIFEST

    时注意构建工具配置,开发环境可用

    1
    updateViaCache: 'none'

总结

PWA 和 Service Worker 已经不再是可有可无的增强特性,而是现代 Web 应用的标配能力。从离线访问到推送通知,从后台同步到桌面安装,这些功能让 Web 应用在用户体验上逐步缩小了与原生 App 的差距。

本文覆盖了构建一个完整 PWA 所需的核心技术点:

  • Service Worker 的注册与生命周期管理
  • 五种缓存策略及其适用场景
  • 离线回退页面的实现
  • Web App Manifest 配置与一键安装
  • Web Push 推送通知的完整前后端实现
  • Background Sync 让离线提交自动补发
  • 使用 Workbox 简化生产级缓存配置

建议下一步的实践方向:将现有项目接入 PWA,从最基本的离线缓存开始,逐步添加推送通知和后台同步能力。先在小范围验证效果,再全面推广。PWA 的渐进增强特性让你可以按需添加功能,不必一蹴而就。

文章中涉及的所有代码都已在实际项目中验证可行,你可以直接复制使用。如有具体实现问题,欢迎在评论区讨论交流。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » Service Worker 与 PWA 完整实战:从离线缓存到推送通知的渐进式 Web 应用指南
分享到: 更多 (0)