Skip to content

Service Worker

什么是 Service Worker

Service Worker 是运行在浏览器后台的独立线程,充当浏览器与网络之间的代理服务器。它能拦截网络请求、管理缓存、实现离线访问、推送通知等功能,是构建 PWA(Progressive Web App)的核心技术。

浏览器主线程                    Service Worker 线程                    网络
─────────────                  ─────────────────────                  ─────
                               (独立线程,无 DOM 访问)
     |                                   |                              |
     |── fetch('/api/data') ──────→     |                              |
     |                                   |── 检查缓存                   |
     |                                   |   ├─ 命中 → 直接返回缓存     |
     |                                   |   └─ 未命中 ─────────────→  |
     |                                   |                              |
     |                                   |  ←──────── 网络响应 ─────── |
     |  ←── 返回响应 ──────────────     |── 写入缓存                   |
     |                                   |                              |

Service Worker vs Web Worker

对比项Service WorkerWeb Worker
生命周期独立于页面,浏览器管理随页面创建和销毁
作用域可控制多个页面仅服务于创建它的页面
网络拦截✅ 可拦截 fetch 请求❌ 不可拦截
缓存控制✅ Cache API❌ 无
离线支持✅ 核心能力❌ 不支持
推送通知✅ Push API❌ 不支持
DOM 访问
HTTPS 要求✅ 必须(localhost 除外)❌ 无要求
持久性浏览器空闲时会终止,需要时重新启动页面关闭即销毁
通信方式postMessage / MessageChannelpostMessage

运行环境约束

Service Worker 的运行限制:

1. 无 DOM 访问
   不能直接操作 document / window 对象
   只能通过 postMessage 与页面通信

2. HTTPS 限制
   必须在 HTTPS 环境下运行(localhost 开发时可豁免)
   原因:防止中间人攻击篡改 Service Worker 代码

3. 独立线程
   运行在独立的 Worker 上下文(ServiceWorkerGlobalScope)
   不阻塞主线程 JS 执行

4. 异步 API
   内部不能使用同步 API(如 localStorage / XHR 同步模式)
   基于 Promise 驱动(fetch / Cache API / IndexedDB)

5. 空闲终止
   浏览器可能在空闲时终止 Service Worker
   不能依赖全局变量存储状态(下次启动状态丢失)
   持久化状态应使用 IndexedDB / Cache API

浏览器支持

Chrome    40+    ✅ 完整支持
Firefox   44+    ✅ 完整支持
Safari    11.1+  ✅ 完整支持(iOS Safari 限制较多)
Edge      17+    ✅ 完整支持
IE        ─      ❌ 不支持

注意:
- iOS Safari 对 Service Worker 有存储限制(最多 50MB)
- iOS 上 Service Worker 在非活跃状态下可能被清除
- 可通过 'serviceWorker' in navigator 检测支持情况

生命周期

Service Worker 的生命周期独立于网页,是理解其工作原理的关键:

                         ┌──────────────────────────────────────────┐
                         │          Service Worker 生命周期          │
                         └──────────────────────────────────────────┘

 navigator.serviceWorker.register('/sw.js')
        |

  ┌──────────┐    下载并解析 sw.js
  │  Parsed   │─────────────────────┐
  └──────────┘                      │
        |                           │ 解析失败 → 注册失败
        ▼                           │           下次访问重试
  ┌──────────┐                      │
  │Installing │  ← install 事件      │
  │ (安装中)  │    预缓存关键资源     │
  └──────────┘                      │
        |         \                 │
        |          \─── 安装失败 ───→ 废弃(redundant)

  ┌──────────┐
  │ Installed │  等待旧 SW 释放控制权
  │ (已安装)  │  (或调用 skipWaiting 跳过等待)
  └──────────┘
        |

  ┌──────────┐
  │Activating│  ← activate 事件
  │ (激活中)  │    清理旧缓存
  └──────────┘
        |

  ┌──────────┐
  │ Activated │  ← 开始控制页面
  │ (已激活)  │    拦截 fetch / push / sync 事件
  └──────────┘
        |

  ┌──────────┐
  │  Idle     │  空闲状态,浏览器可能随时终止
  │ (空闲)    │  有事件时重新唤醒
  └──────────┘
        |
        ▼  (检测到新版本 sw.js)
  ┌──────────┐
  │ Redundant │  旧 SW 被新版本替换
  │ (废弃)    │  或安装/激活失败
  └──────────┘

各阶段详解

1. 注册(Register)
   触发时机:页面 JS 调用 navigator.serviceWorker.register()
   作用:通知浏览器下载并解析 Service Worker 脚本
   注意:注册不等于安装,浏览器会检查是否有更新

2. 安装(Install)
   触发事件:install
   作用:预缓存(precache)应用的核心静态资源
   关键 API:event.waitUntil() —— 延长安装阶段直到 Promise 完成
   失败处理:如果 precache 任何资源失败,整个安装失败,SW 不会激活

3. 等待(Waiting)
   触发条件:已有旧版 SW 正在控制页面
   行为:新 SW 安装完成后进入等待状态
   跳过等待:调用 self.skipWaiting() 立即进入激活阶段

4. 激活(Activate)
   触发事件:activate
   作用:清理旧版本缓存、执行数据库迁移等
   关键 API:event.waitUntil() + clients.claim()
   clients.claim():让新 SW 立即接管所有已打开的页面

5. 控制(Fetch / 空闲)
   触发事件:fetch / push / sync / message
   作用:拦截网络请求、处理推送、后台同步
   注意:第一次注册 SW 后,页面不会被控制(需要刷新或 clients.claim)

6. 更新 / 废弃(Redundant)
   触发条件:检测到新版本 sw.js / 安装失败 / 激活失败
   行为:旧 SW 被标记为 redundant 并最终被回收

注册与安装

注册 Service Worker

js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker
      .register('/sw.js', { scope: '/' })
      .then((registration) => {
        console.log('SW registered, scope:', registration.scope);

        registration.addEventListener('updatefound', () => {
          const newWorker = registration.installing;
          newWorker.addEventListener('statechange', () => {
            if (newWorker.state === 'activated') {
              console.log('New SW activated');
            }
          });
        });
      })
      .catch((error) => {
        console.error('SW registration failed:', error);
      });
  });
}
scope 作用域:
  register('/sw.js', { scope: '/' })

  scope 决定 SW 能控制哪些页面的请求:
    scope: '/'          → 控制整个站点
    scope: '/app/'      → 只控制 /app/ 下的页面
    scope: '/blog/'     → 只控制 /blog/ 下的页面

  默认 scope = sw.js 所在目录
  不能设置比 sw.js 位置更高的 scope(除非服务器设置 Service-Worker-Allowed 头)

install 事件 —— 预缓存资源

js
const CACHE_NAME = 'app-cache-v1';
const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/images/logo.svg',
  '/offline.html',
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(PRECACHE_URLS);
    })
  );
});

activate 事件 —— 清理旧缓存

js
const CURRENT_CACHES = ['app-cache-v1', 'api-cache-v1'];

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => !CURRENT_CACHES.includes(name))
          .map((name) => caches.delete(name))
      );
    })
  );
});

skipWaiting 与 clients.claim

js
// sw.js
self.addEventListener('install', (event) => {
  self.skipWaiting(); // 跳过等待,立即激活
  event.waitUntil(/* precache */);
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    clients.claim() // 立即接管所有已打开的页面
  );
});
skipWaiting + clients.claim 的效果:

不使用:
  Tab1(旧 SW 控制)→ 用户刷新页面 → Tab1(新 SW 控制)
  新 SW 安装后必须等所有旧 Tab 关闭

使用 skipWaiting + clients.claim:
  Tab1(旧 SW 控制)→ 新 SW 安装完成 → Tab1(立即切换到新 SW)
  
注意风险:
  页面加载过程中 SW 可能切换
  旧版缓存的 HTML 可能与新版 JS/CSS 不兼容
  需要设计版本兼容策略或提示用户刷新

Fetch 拦截与缓存策略

Service Worker 通过 fetch 事件拦截页面发出的所有网络请求,并可以根据不同策略决定如何响应。

Cache First(缓存优先 / 离线优先)

优先使用缓存,缓存未命中时回退到网络。适用于不经常变化的静态资源。

请求发起

检查缓存
  ├─ 命中 → 返回缓存响应 ✅
  └─ 未命中

    请求网络
       ├─ 成功 → 写入缓存 + 返回响应 ✅
       └─ 失败 → 返回错误 ❌
js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) {
        return cached;
      }
      return fetch(event.request).then((response) => {
        const cloned = response.clone();
        caches.open('runtime-cache').then((cache) => {
          cache.put(event.request, cloned);
        });
        return response;
      });
    })
  );
});

Network First(网络优先)

优先请求网络,网络失败时回退到缓存。适用于需要最新数据的 API 请求。

请求发起

请求网络
  ├─ 成功 → 更新缓存 + 返回网络响应 ✅
  └─ 失败(超时/离线)

    检查缓存
       ├─ 命中 → 返回缓存响应 ✅(可能过期但可用)
       └─ 未命中 → 返回离线页面 / 错误 ❌
js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const cloned = response.clone();
        caches.open('api-cache').then((cache) => {
          cache.put(event.request, cloned);
        });
        return response;
      })
      .catch(() => {
        return caches.match(event.request);
      })
  );
});

Stale While Revalidate(缓存 + 后台更新)

立即返回缓存(即使过期),同时后台发起网络请求更新缓存。下次请求时用户将获得最新数据。适用于更新频率适中、对实时性要求不高的资源。

请求发起

检查缓存
  ├─ 命中 → 立即返回缓存响应 ✅(用户无需等待)
  │           同时后台请求网络
  │             └─ 成功 → 更新缓存(下次使用新版本)

  └─ 未命中 → 请求网络 → 写入缓存 + 返回响应 ✅
js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('swr-cache').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;
      });
    })
  );
});

Network Only(仅网络)

完全不使用缓存,所有请求都走网络。适用于非 GET 请求(POST/PUT/DELETE)或实时性要求极高的数据。

请求发起

请求网络
  ├─ 成功 → 返回网络响应 ✅
  └─ 失败 → 返回错误 ❌
js
self.addEventListener('fetch', (event) => {
  event.respondWith(fetch(event.request));
});

Cache Only(仅缓存)

完全不走网络,只使用缓存。适用于在 install 阶段已预缓存的资源。

请求发起

检查缓存
  ├─ 命中 → 返回缓存响应 ✅
  └─ 未命中 → 返回错误 ❌
js
self.addEventListener('fetch', (event) => {
  event.respondWith(caches.match(event.request));
});

策略对比与选型

策略                    响应速度    数据新鲜度    离线能力    典型场景
──────────────────     ────────   ──────────   ────────   ──────────────
Cache First            ⚡ 最快     ⚠️ 可能旧    ✅ 强       字体/图片/CSS/JS
Network First          🐢 较慢     ✅ 最新      ✅ 有       API 数据/动态内容
Stale While Revalidate ⚡ 快       ✅ 较新      ✅ 有       头像/不紧急的 API
Network Only           🐢 取决网络  ✅ 最新      ❌ 无       非幂等请求/实时数据
Cache Only             ⚡ 最快     ❌ 固定       ✅ 强       预缓存的 App Shell

实际项目中的混合策略示例:
  HTML 入口       → Network First(确保获取最新版本)
  带 hash 的静态资源 → Cache First(内容不变,长期缓存)
  API 请求        → Network First 或 Stale While Revalidate
  App Shell       → Cache Only(预缓存的骨架)
  CDN 字体/图标   → Cache First(很少更新)

PWA 离线应用

PWA(Progressive Web App)通过 Service Worker + manifest.json 实现类原生应用体验。

manifest.json 配置

json
{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "A demo progressive web app",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4A90E2",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}
html
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#4A90E2">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
display 属性值:
  fullscreen  → 全屏(无系统 UI)
  standalone  → 独立窗口(像原生 App,无浏览器地址栏)
  minimal-ui  → 保留少量浏览器控件
  browser     → 普通浏览器标签页

PWA 安装条件(Chrome):
  1. 有效的 manifest.json(含 name/icons/start_url/display)
  2. 已注册并激活 Service Worker
  3. 通过 HTTPS 提供
  4. 用户与页面有一定的交互(engagement heuristic)

安装提示(beforeinstallprompt)

js
let deferredPrompt;

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

function onInstallClick() {
  if (!deferredPrompt) return;

  deferredPrompt.prompt();
  deferredPrompt.userChoice.then((result) => {
    if (result.outcome === 'accepted') {
      console.log('User accepted install');
    }
    deferredPrompt = null;
    hideInstallButton();
  });
}

window.addEventListener('appinstalled', () => {
  console.log('PWA installed');
  deferredPrompt = null;
});

离线页面(Offline Fallback)

js
// sw.js
const OFFLINE_URL = '/offline.html';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('offline-cache').then((cache) => {
      return cache.add(OFFLINE_URL);
    })
  );
});

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

App Shell 架构

App Shell 模式:
将应用的「外壳」(导航栏、侧边栏、布局框架)与「内容」分离

┌──────────────────────────────────┐
│         Header / NavBar          │  ←── App Shell(预缓存)
├──────────────────────────────────┤
│  Sidebar  │                      │
│           │    动态内容区域        │  ←── 内容(Network First)
│  (预缓存)  │    (从网络获取)       │
│           │                      │
├──────────────────────────────────┤
│            Footer                │  ←── App Shell(预缓存)
└──────────────────────────────────┘

优势:
  1. 首次加载后,App Shell 从缓存中瞬间加载
  2. 仅内容区域需要网络请求
  3. 离线时仍能展示应用骨架 + 离线提示
  4. 用户感知的加载速度极快

缓存策略:
  App Shell(HTML 骨架/CSS/JS) → Cache First / Cache Only
  动态内容(API 数据)           → Network First
  静态资源(图片/字体)          → Cache First

更新策略

Service Worker 更新流程

浏览器如何检测 SW 更新:

1. 每次导航到 SW 作用域内的页面时,浏览器都会尝试重新下载 sw.js
2. 浏览器对 sw.js 进行**逐字节比较**(byte-for-byte comparison)
3. 即使只有 1 个字节不同,也会触发更新流程
4. 浏览器至少每 24 小时检查一次更新(忽略 HTTP 缓存头)
5. 可通过 registration.update() 手动触发检查

更新时间线:

  用户访问页面(旧 SW v1 控制中)

  浏览器后台下载新 sw.js

  字节比较:发现与当前 SW 不同

  触发新 SW v2 的 install 事件

  新 SW v2 进入 waiting 状态
       ↓ ← 等待旧 SW v1 释放控制权
       ↓   (所有由 v1 控制的 Tab 关闭)

  新 SW v2 进入 activate 状态

  新 SW v2 接管后续页面

版本管理

js
// sw.js —— 通过缓存名称管理版本
const VERSION = 'v2';
const CACHE_NAME = `app-cache-${VERSION}`;

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll([
        '/',
        '/index.html',
        '/styles/main.css',
        '/scripts/app.js',
      ]);
    })
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) => {
      return Promise.all(
        names
          .filter((name) => name.startsWith('app-cache-') && name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
});

平滑迁移(新旧版本并存)

新旧 SW 并存期间的处理策略:

方案一:skipWaiting + 提示用户刷新
──────────────────────────────────
  新 SW 安装 → skipWaiting 立即激活
  → 通过 postMessage 通知页面
  → 页面显示「有新版本可用,点击刷新」提示

方案二:等待自然切换
──────────────────────
  新 SW 安装后等待
  → 用户关闭所有旧 Tab
  → 下次打开时自动使用新 SW

方案三:controllerchange 自动刷新
───────────────────────────────
  监听 SW 控制权变化,自动刷新页面
js
// 页面代码 —— 方案一:提示用户刷新
navigator.serviceWorker.register('/sw.js').then((registration) => {
  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing;
    newWorker.addEventListener('statechange', () => {
      if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
        showUpdateNotification();
      }
    });
  });
});

function showUpdateNotification() {
  const toast = document.createElement('div');
  toast.textContent = '新版本可用,点击刷新';
  toast.addEventListener('click', () => {
    window.location.reload();
  });
  document.body.appendChild(toast);
}

// 页面代码 —— 方案三:自动刷新
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (!refreshing) {
    refreshing = true;
    window.location.reload();
  }
});

Workbox

Workbox 是 Google 推出的 Service Worker 工具库,封装了常用的缓存策略和最佳实践,大幅简化 SW 开发。

Workbox 核心模块:

workbox-precaching     预缓存管理(带版本哈希)
workbox-routing        请求路由匹配(URL / 正则 / 回调)
workbox-strategies     缓存策略实现(5 种内置策略)
workbox-expiration     缓存过期控制(条目数 / 时间)
workbox-cacheable-response  可缓存响应过滤(按状态码 / Header)
workbox-background-sync     后台同步(离线请求队列)
workbox-broadcast-update    广播缓存更新通知
workbox-window              页面端的 SW 生命周期管理

workbox-precaching

js
import { precacheAndRoute } from 'workbox-precaching';

precacheAndRoute(self.__WB_MANIFEST);
__WB_MANIFEST 在构建时由 workbox-webpack-plugin 或 workbox-build 注入:

[
  { url: '/index.html', revision: 'a1b2c3' },
  { url: '/styles/main.css', revision: 'd4e5f6' },
  { url: '/scripts/app.js', revision: 'g7h8i9' },
]

带 hash 的文件名(如 app.a1b2c3.js)不需要 revision 字段
Workbox 自动处理版本更新:新版本的资源会在 install 阶段下载,旧版本在 activate 阶段清理

workbox-routing + workbox-strategies

js
import { registerRoute } from 'workbox-routing';
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60,
      }),
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
    ],
  })
);

registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 3,
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60,
      }),
    ],
  })
);

registerRoute(
  ({ request }) =>
    request.destination === 'style' || request.destination === 'script',
  new StaleWhileRevalidate({
    cacheName: 'static-resources',
  })
);

Workbox 构建配置(workbox-webpack-plugin)

js
const { InjectManifest } = require('workbox-webpack-plugin');

module.exports = {
  plugins: [
    new InjectManifest({
      swSrc: './src/sw.js',
      swDest: 'sw.js',
      maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
    }),
  ],
};
两种 Webpack 插件模式:

GenerateSW:
  自动生成完整的 sw.js
  适合简单场景,零配置
  不支持自定义逻辑

InjectManifest:
  将预缓存清单注入到你编写的 sw.js 中
  完全控制 SW 逻辑
  适合需要自定义缓存策略、推送通知等场景
  推荐用于生产环境

消息通信

Service Worker 与页面之间无法直接共享变量,需要通过消息机制通信。

postMessage 双向通信

js
// 页面 → SW
navigator.serviceWorker.controller.postMessage({
  type: 'CACHE_URLS',
  payload: ['/api/articles', '/api/users'],
});

// SW 接收
self.addEventListener('message', (event) => {
  if (event.data.type === 'CACHE_URLS') {
    caches.open('dynamic-cache').then((cache) => {
      cache.addAll(event.data.payload);
    });
  }
});
js
// SW → 页面(向所有受控页面广播)
self.clients.matchAll().then((clients) => {
  clients.forEach((client) => {
    client.postMessage({
      type: 'CACHE_UPDATED',
      payload: { url: '/api/data' },
    });
  });
});

// 页面接收
navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data.type === 'CACHE_UPDATED') {
    console.log('Cache updated for:', event.data.payload.url);
    refreshUI();
  }
});

MessageChannel(一对一双向通道)

js
// 页面发送带回复通道的消息
const messageChannel = new MessageChannel();

messageChannel.port1.onmessage = (event) => {
  console.log('SW replied:', event.data);
};

navigator.serviceWorker.controller.postMessage(
  { type: 'GET_CACHE_SIZE' },
  [messageChannel.port2]
);

// SW 接收并通过 port 回复
self.addEventListener('message', (event) => {
  if (event.data.type === 'GET_CACHE_SIZE') {
    getCacheSize().then((size) => {
      event.ports[0].postMessage({ size });
    });
  }
});

BroadcastChannel(多 Tab 广播)

js
// SW 中
const channel = new BroadcastChannel('sw-updates');
channel.postMessage({
  type: 'NEW_VERSION',
  version: 'v2.0.0',
});

// 所有 Tab 页面中
const channel = new BroadcastChannel('sw-updates');
channel.addEventListener('message', (event) => {
  if (event.data.type === 'NEW_VERSION') {
    showUpdateBanner(event.data.version);
  }
});
三种通信方式对比:

方式                 方向          范围            场景
────────────        ──────       ────────       ──────────
postMessage         双向          页面 ↔ SW      通用通信
MessageChannel      双向(1对1)  页面 ↔ SW      需要回复的请求
BroadcastChannel    广播          多 Tab + SW    版本更新通知/数据同步

推送通知

Push API + Notification API 基本流程

推送通知完整流程:

  ┌──────────┐     ┌──────────────┐     ┌──────────┐
  │  应用服务器 │     │  推送服务      │     │  浏览器    │
  │ (Your     │     │ (FCM/APNs/   │     │ (Service  │
  │  Server)  │     │  Web Push)   │     │  Worker)  │
  └──────────┘     └──────────────┘     └──────────┘
       |                   |                   |
       |                   |    ①用户授权通知    |
       |                   |    Notification    |
       |                   |    .requestPermission()
       |                   |                   |
       |                   |    ②订阅推送服务    |
       |                   |←── subscribe() ── |
       |                   |──→ pushSubscription|
       |                   |                   |
       | ③保存 subscription |                   |
       |←─────────────────────────────────── |
       |                   |                   |
       | ④发送推送消息       |                   |
       |──→ web-push API ─→|                   |
       |                   |──→ push 事件 ────→|
       |                   |                   |
       |                   |        ⑤显示通知   |
       |                   |   showNotification()|
       |                   |                   |

订阅推送

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(VAPID_PUBLIC_KEY),
  });

  await fetch('/api/push/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 = atob(base64);
  return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}

Service Worker 接收推送并显示通知

js
// sw.js
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};
  const title = data.title || '新消息';
  const options = {
    body: data.body || '你有一条新通知',
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png',
    data: { url: data.url || '/' },
    actions: [
      { action: 'open', title: '查看' },
      { action: 'dismiss', title: '忽略' },
    ],
  };

  event.waitUntil(self.registration.showNotification(title, options));
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'dismiss') return;

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

服务端发送推送(Node.js)

js
const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:admin@example.com',
  VAPID_PUBLIC_KEY,
  VAPID_PRIVATE_KEY
);

async function sendPush(subscription, payload) {
  await webpush.sendNotification(subscription, JSON.stringify(payload));
}

sendPush(userSubscription, {
  title: '文章更新',
  body: '你关注的专栏发布了新文章',
  url: '/articles/123',
});

面试高频题

1. Service Worker 的生命周期是怎样的?为什么新的 Service Worker 不会立即生效?

Service Worker 的生命周期分为:注册(register)→ 安装(install)→ 等待(waiting)→ 激活(activate)→ 控制(fetch 拦截)→ 废弃(redundant)。新 SW 安装完成后默认进入 waiting 状态而不会立即激活,这是因为旧 SW 可能还在控制页面——如果新 SW 直接接管,页面可能加载了旧版资源却用新版 SW 处理请求,导致不一致。浏览器要求所有由旧 SW 控制的 Tab 全部关闭后,新 SW 才会激活。可以通过 self.skipWaiting() 跳过等待立即激活,配合 clients.claim() 立即接管所有页面,但需要处理好版本兼容问题。

2. Service Worker 有哪些缓存策略?分别适用于什么场景?

五种主要缓存策略:①Cache First(缓存优先):先查缓存,未命中才走网络,适合字体、图片、带 hash 的 JS/CSS 等不常变化的静态资源;②Network First(网络优先):先走网络,失败回退缓存,适合 API 请求、HTML 入口等需要最新数据的场景;③Stale While Revalidate:立即返回缓存,后台更新,适合头像、非实时 API 等对新鲜度要求不高的资源;④Network Only:完全不用缓存,适合非幂等请求(POST/PUT)或实时数据;⑤Cache Only:完全不走网络,适合 install 阶段已预缓存的 App Shell 资源。实际项目通常混合使用多种策略。

3. Service Worker 与 Web Worker 有什么区别?

两者都运行在独立线程、不能访问 DOM,但核心定位不同。Service Worker 是浏览器和网络之间的代理,生命周期独立于页面,可以控制多个页面的网络请求,支持离线缓存、推送通知和后台同步,必须在 HTTPS 环境下运行,浏览器会在空闲时终止并在需要时重新启动。Web Worker 是为主线程分担计算密集型任务的工具线程,随页面创建和销毁,只服务于创建它的页面,不能拦截网络请求,没有 HTTPS 要求。简单说:Service Worker 是"网络代理",Web Worker 是"计算助手"。

4. 如何实现 PWA 的离线访问?App Shell 架构是什么?

离线访问的实现步骤:①注册 Service Worker;②在 install 事件中预缓存关键资源(HTML 骨架、CSS、JS、离线回退页面);③在 fetch 事件中对导航请求使用 Network First 策略,网络失败时返回预缓存的离线页面;④对静态资源使用 Cache First 策略。App Shell 架构是将应用的「外壳」(导航栏、侧边栏、布局框架)与「动态内容」分离——Shell 部分预缓存后从 Cache 瞬间加载,内容区域走网络请求。这样首屏加载极快,离线时也能展示应用骨架加离线提示,再配合 manifest.json 实现可安装的 PWA 体验。

5. Workbox 解决了什么问题?它的核心模块有哪些?

手动编写 Service Worker 需要自己实现缓存策略、版本管理、预缓存清单生成、缓存过期清理等,代码复杂且容易出错。Workbox 将这些最佳实践封装为模块:workbox-precaching 自动管理预缓存(带版本哈希,自动更新旧资源);workbox-routing 提供路由匹配(按 URL、请求类型、正则等分发到不同策略);workbox-strategies 提供五种开箱即用的缓存策略;workbox-expiration 控制缓存过期(按条目数和时间);workbox-background-sync 实现离线请求队列(网络恢复后自动重发)。配合 Webpack/Vite 插件,可以在构建时自动注入预缓存清单,无需手动维护资源列表。


追问思考

  1. Service Worker 中为什么不能使用 localStorage?它可以使用哪些存储 API?IndexedDBCache API 分别适合存储什么类型的数据?
  2. event.waitUntil() 的作用是什么?如果在 install 事件中不使用它会发生什么?它和 event.respondWith() 有什么区别?
  3. Service Worker 被浏览器终止后重新启动时,全局变量会丢失。如果需要在多次 SW 启动之间保持状态(如请求计数),应该怎么做?
  4. 在 iOS Safari 上,Service Worker 有哪些已知的限制和坑?为什么说 PWA 在 iOS 上的体验不如 Android?
  5. 如果 Service Worker 中缓存了错误的资源(如缓存了一个报错页面),如何实现"紧急回滚"?有哪些策略可以避免这类缓存灾难?

用心学习,用代码说话 💻