Service Worker 与 PWA:离线体验与推送通知

FreeGuideOnline 最新 2026-06-15

Service Worker 与 PWA 简介

渐进式 Web 应用(Progressive Web App,简称 PWA)是使用现代 Web 技术构建的、能够提供类似原生应用体验的网站。其核心依赖于 Service Worker——一个在浏览器后台独立运行的脚本,它赋予了网页离线访问、消息推送、后台同步等“超能力”。本教程将带你从零开始理解 Service Worker 的工作原理,并亲手实现一个具备离线缓存和推送通知功能的 PWA。

先决条件

  • 基础的 HTML、CSS 与 JavaScript 知识
  • 一个支持 HTTPS 的本地开发环境(Service Worker 强制要求安全上下文,localhost 同样适用)
  • 现代浏览器(如 Chrome、Edge、Firefox)

Service Worker 核心概念

什么是 Service Worker?

Service Worker 是浏览器与网络之间的可编程代理。它独立于网页运行,即使页面关闭,它仍然可以在后台激活。它不能直接操作 DOM,但可以通过 postMessage 与页面通信。

主要特性:

  • 事件驱动:完全基于 Promise,通过 installactivatefetch 等事件响应操作。
  • 生命周期独立:安装后长期存在,浏览器会在空闲时自动更新。
  • 作用域限制:一个 Service Worker 只能控制其所在路径及子路径下的页面。

生命周期

Service Worker 从注册到控制页面,经历以下阶段:

  1. 注册(Registration):页面调用 navigator.serviceWorker.register() 请求安装。
  2. 安装(Installing):触发 install 事件,通常在此阶段预缓存关键资源。
  3. 等待激活(Waiting):如果有旧版 Service Worker 仍在运行,新版本会进入等待状态,直到旧版本不再控制任何页面。
  4. 激活(Activating):触发 activate 事件,常用来清理旧缓存。
  5. 已激活(Activated):此刻起 Service Worker 可以拦截网络请求、响应 fetch 事件,并发送推送通知。

注册第一个 Service Worker

在 HTML 页面中使用以下脚本注册 Service Worker 文件(通常命名为 sw.js):

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

注意/sw.js 位于项目根目录,这样它就能控制整个站点的请求。


实现离线体验:缓存策略

PWA 最吸引人的特性之一就是离线可用。通过 Service Worker 拦截请求并返回缓存资源,用户可以断网时继续浏览内容。

预缓存关键资源(install 事件)

在 Service Worker 文件中监听 install 事件,提前缓存 App Shell(应用的骨架资源)。

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

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        console.log('打开缓存');
        return cache.addAll(urlsToCache);
      })
  );
});

event.waitUntil() 确保安装完成前不会终止 Service Worker。如果任何文件缓存失败,则整个安装过程失败。

拦截请求并返回缓存(fetch 事件)

使用“缓存优先,网络回退”策略:优先从缓存中响应,若缓存未命中则从网络获取,同时把新响应存入缓存。

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(cachedResponse => {
        // 有缓存直接返回,否则发起网络请求
        return cachedResponse || fetch(event.request).then(response => {
          // 检查响应的有效性
          if (!response || response.status !== 200 || response.type !== 'basic') {
            return response;
          }
          // 克隆响应,因为响应流只能读取一次
          const responseToCache = response.clone();
          caches.open(CACHE_NAME).then(cache => {
            cache.put(event.request, responseToCache);
          });
          return response;
        });
      })
  );
});

更新缓存与清理旧版(activate 事件)

当 Service Worker 更新时,旧缓存应该被清除,避免占用存储空间。

const expectedCaches = [CACHE_NAME]; // 当前期待的缓存名称列表

self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (!expectedCaches.includes(cacheName)) {
            console.log('删除旧缓存:', cacheName);
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

修改 CACHE_NAME 即可触发新的安装并清除旧缓存。


添加推送通知

推送通知需要服务端的配合,但我们可以先从浏览器端实现订阅与显示逻辑。

获取用户权限并订阅

在页面代码中,获取 PushManager 订阅并发送到服务器。

async function subscribeUserToPush() {
  const registration = await navigator.serviceWorker.ready;
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.warn('用户不允许通知');
    return;
  }
  // 需要服务器提供公钥,这里示例直接硬编码(实际应从后端获取)
  const applicationServerKey = urlBase64ToUint8Array('你的公钥字符串');
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: applicationServerKey
  });
  console.log('订阅对象:', JSON.stringify(subscription));
  // 将此 subscription 对象发送到你的服务器,用于后续推送
  await fetch('/save-subscription', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });
}

// 工具函数:将 Base64 公钥字符串转为 Uint8Array(VAPID 密钥)
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;
}

在 Service Worker 中监听推送事件

sw.js 中添加 pushnotificationclick 事件监听。

self.addEventListener('push', event => {
  let data = {};
  if (event.data) {
    data = event.data.json();
  }
  const options = {
    body: data.body || '你有一条新消息',
    icon: '/images/notification-icon.png',
    badge: '/images/badge-icon.png',
    data: {
      url: data.url || '/' // 点击通知后打开的地址
    }
  };
  event.waitUntil(
    self.registration.showNotification(data.title || '新通知', options)
  );
});

self.addEventListener('notificationclick', event => {
  event.notification.close();
  const targetUrl = event.notification.data.url;
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then(windowClients => {
      // 如果已有打开的页面且匹配目标 URL,则聚焦;否则打开新窗口
      for (let client of windowClients) {
        if (client.url === targetUrl && 'focus' in client) {
          return client.focus();
        }
      }
      if (clients.openWindow) {
        return clients.openWindow(targetUrl);
      }
    })
  );
});

生成 VAPID 密钥

推送通知需要 VAPID 密钥对(公钥用于前端订阅,私钥保存在服务器端用于签名)。你可以使用 web-push 库生成:

npx web-push generate-vapid-keys

输出示例:

Public Key: BEl62iU_YhXeJSxu...
Private Key: cbKJZkfEoDKc...

公钥填入前端订阅代码,私钥保存到后端环境变量。


让 PWA 可安装

为了让用户将站点添加到主屏幕,需要提供一个包含必要图标的 Web App Manifest。

创建 manifest.json

{
  "name": "我的 PWA 应用",
  "short_name": "PWA",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3f51b5",
  "icons": [
    {
      "src": "/images/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/images/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

在 HTML 中引入:

<link rel="manifest" href="/manifest.json">

当用户满足 Chrome 的安装启发式条件(如频繁访问、Service Worker 激活等)时,浏览器会自动提示安装。


调试与常见问题

  • 使用 Chrome DevTools:Application > Service Workers 面板可以查看状态、强制更新、跳过等待、模拟离线等。
  • 缓存检查:Application > Cache Storage 中可查看所有缓存键值。
  • 强制刷新:开发时勾选“Update on reload”可让页签刷新时立即激活新 Service Worker。
  • HTTPS 要求:本地开发 localhost 不受 HTTPS 限制,但公网部署必须使用 HTTPS。
  • Scope 错误:确保 Service Worker 文件位于你想控制的作用域根路径(通常是项目根目录)。

进阶学习方向

  • 高级缓存策略:Stale-while-revalidate,网络优先、仅缓存等。
  • 后台同步:使用 SyncManager 在恢复网络后重发失败请求。
  • Workbox 库:Google 提供的一套生产级 Service Worker 工具,简化缓存与路由管理。
  • 推送加密与服务器实现:学习如何使用 web-push 库从后端发送经过加密的推送消息。

现在,你已经掌握了 Service Worker 的基础,并亲手实现了一个具备离线访问和推送通知能力的 PWA。将这些代码添加到你的项目中,打开浏览器 DevTools 进行实验,迈出打造下一代 Web 体验的第一步吧!