陌上人如玉
公子世无双

10.11 PWA 与 Service Worker

10.11 PWA 与 Service Worker

一句话:PWA 的本质是「用 manifest 声明『我是一个应用』 + 用 Service Worker 拦截网络请求」,它的价值是「可安装、离线可用、后台能力(同步/推送)」;而最容易被做坏的恰恰是更新机制——「缓存优先」会让用户永远看到旧版本,所以「版本更新 + 提示刷新」必须一开始就设计好。

📌 与 5.16 网络优化与资源提示 的分工:5.16 讲「用缓存换速度」(策略与 HTTP 缓存),本篇讲「用缓存换离线能力」(安装、离线体验、更新、推送)。

一、PWA 的四个能力清单

⭐⭐ PWA 提供四类能力(按「值不值得做」排序):

① 【可安装(Installable)】—— 最有价值
   · manifest + 满足「安装条件」→ 浏览器显示「安装到主屏幕」
   · ⭐ 价值:有独立图标、无地址栏、进任务列表(像原生应用)
   · ⭐⭐⭐ **转换率提升**是它最实在的收益(尤其移动端电商/内容站)

② 【离线可用(Offline)】—— 最实用
   · Service Worker 拦截请求 → 用缓存响应 → 断网也能打开
   · ⭐ 价值:「地铁/电梯/弱网」场景下仍可用;配合「乐观 UI + 后台同步」
   · ⚠️ 代价:**必须设计「陈旧数据」的呈现与冲突处理**(见第五节)

③ 【后台同步(Background Sync)】
   · 网络恢复时自动重发「之前失败的操作」(如提交表单/埋点)
   · ⚠️ 兼容性有限(主要 Chrome/Edge);iOS 支持弱
   · ✅ 更稳的替代:「页面内自己排队 + 监听 online 事件重试」

④ 【推送通知(Push)】
   · Push API + Notification API
   · ⚠️ iOS 上「必须先『添加到主屏幕』」才能用推送(16.4+)
   · ⚠️ 滥用会「被用户永久拒绝」→ ⭐ 只在「用户明确需要」时才请求权限

二、manifest.json:安装的「身份证」

// ✅ public/manifest.webmanifest
{
  "name": "Acme 商城",
  "short_name": "Acme",
  "description": "随时随地逛 Acme",
  "id": "/",                                  // ⭐ 应用的唯一标识(默认 start_url)
  "start_url": "/?source=pwa",                // ⭐ 从主屏幕启动时打开的地址
  "scope": "/",                               // ⭐ 应用「管辖」的路径范围(超出范围会用浏览器打开)
  "display": "standalone",                    // fullscreen | standalone | minimal-ui | browser
  "display_override": ["window-controls-overlay", "standalone"],
  "orientation": "portrait-primary",
  "background_color": "#ffffff",              // ⭐ 「启动画面」的背景色(不是主题色)
  "theme_color": "#1677ff",                   // ⭐ 标题栏/状态栏颜色(也能用 meta 覆盖)
  "lang": "zh-CN",
  "dir": "ltr",
  "categories": ["shopping"],
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
    // ⭐⭐ maskable:Android 的「自适应图标」(系统会裁成圆形/方形/水滴形)
    //    ⚠️ maskable 图标必须留「安全区」:**关键内容在内 80% 的圆内**
    { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  // ⭐ 用于「更丰富的安装 UI」(部分平台会展示截图)
  "screenshots": [
    { "src": "/screenshots/home-wide.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" },
    { "src": "/screenshots/home-narrow.png", "sizes": "720x1280", "type": "image/png", "form_factor": "narrow" }
  ],
  // ⭐ 长按图标时的快捷入口
  "shortcuts": [
    { "name": "我的订单", "url": "/orders", "icons": [{ "src": "/icons/orders.png", "sizes": "96x96" }] },
    { "name": "搜索", "url": "/search", "icons": [{ "src": "/icons/search.png", "sizes": "96x96" }] }
  ]
}
<!-- ✅ 在 HTML 里引用(顺便设置 theme-color 与 iOS 特殊标签) -->
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#1677ff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#141414" media="(prefers-color-scheme: dark)" />

<!-- ⭐ iOS 的特有标签(iOS 不读 manifest 的部分字段) -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Acme" />
<link rel="apple-touch-icon" href="/icons/apple-touch-180.png" />
⭐⭐⭐ PWA 的「可安装条件」(不满足就不显示安装入口):

   Chromium 系要求:
   ① 有 manifest,且包含 `name`/`short_name`、`start_url`、`icons`(含 192 与 512)
   ② `display` 为 `standalone`/`fullscreen`/`minimal-ui`(不能是 browser)
   ③ **注册了 Service Worker**(且有一个 `fetch` 事件处理器)
   ④ **通过 HTTPS 提供**(`localhost` 例外)
   ⭐ 且「用户有交互」(不是刚打开就弹)

   ⚠️ iOS Safari 的不同:
   · **不支持 `beforeinstallprompt`** → 无法用 JS 触发安装提示
   · ⭐ 只能「引导用户手动操作」:分享 → 添加到主屏幕
   · 所以要在 iOS 上显示一段「如何安装」的图文引导
// ✅ 安装引导(Chromium + iOS 的差异化处理)
import { useEffect, useState } from 'react';

interface BeforeInstallPromptEvent extends Event {
  prompt: () => Promise<void>;
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}

export function useInstallPrompt() {
  const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
  const [installed, setInstalled] = useState(false);
  const [isIosManual, setIsIosManual] = useState(false);

  useEffect(() => {
    // ⭐ 已经安装(以 standalone 显示)→ 不再提示
    const standalone =
      window.matchMedia('(display-mode: standalone)').matches ||
      (navigator as unknown as { standalone?: boolean }).standalone === true;
    setInstalled(standalone);
    if (standalone) return;

    // ⭐ iOS:没有 beforeinstallprompt → 只能手动引导
    const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
    const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(navigator.userAgent);
    if (isIos && isSafari) setIsIosManual(true);

    const onPrompt = (e: Event) => {
      e.preventDefault();                    // ⭐⭐ 必须阻止默认(否则浏览器自己弹,无法控制时机)
      setDeferred(e as BeforeInstallPromptEvent);
    };
    const onInstalled = () => { setInstalled(true); setDeferred(null); };

    window.addEventListener('beforeinstallprompt', onPrompt);
    window.addEventListener('appinstalled', onInstalled);
    return () => {
      window.removeEventListener('beforeinstallprompt', onPrompt);
      window.removeEventListener('appinstalled', onInstalled);
    };
  }, []);

  /** ⭐ 在「用户点击『安装』按钮」时调用(而不是自动弹) */
  const promptInstall = async (): Promise<'accepted' | 'dismissed' | 'unavailable'> => {
    if (!deferred) return 'unavailable';
    await deferred.prompt();
    const { outcome } = await deferred.userChoice;
    setDeferred(null);                       // ⚠️ 只能用一次
    return outcome;
  };

  return { canPrompt: Boolean(deferred), promptInstall, installed, isIosManual };
}

三、Service Worker 生命周期:install → waiting → activate

⭐⭐⭐ SW 的生命周期是「理解 PWA」的关键(几乎所有更新问题都源于它):

   ① 【install】
      新 SW 下载并执行 install 事件(这里通常「预缓存」应用的壳资源)
      · 成功 → 进入 `installed`(但**还没接管页面**)
      · ⚠️ 如果此时「已有旧 SW 在控制页面」→ 新 SW 进入 **waiting** 状态

   ② 【waiting】⭐ 所有「更新不生效」问题的根源
      新 SW 在等待「所有由旧 SW 控制的页面都关闭」才激活
      · 用户「刷新页面」**不会**让它激活(因为页面没真正关闭)
      · ⭐ 所以「用户刷新了还是旧版本」是**正常行为**,不是 bug

   ③ 【activate】
      接管控制(清理旧缓存通常在这里做)
      ⭐ 提供两个「提前激活」的手段:
        · `self.skipWaiting()`(在 install 里调用)→ 不等页面关闭,立刻激活
        · `self.clients.claim()`(在 activate 里调用)→ 立刻接管「已打开的页面」

   ④ 【fetch】
      接管页面的所有网络请求(在这里实现缓存策略)

   ⑤ 【message】
      与页面通信(用于「通知新版本可用」)
// ✅ 一个「离线优先 + 可控更新」的 Service Worker(完整可跑)
const VERSION = 'v5';
const SHELL_CACHE = `shell-${VERSION}`;
const DATA_CACHE = `data-${VERSION}`;
const IMAGE_CACHE = `image-${VERSION}`;

/** ⭐ 需要「离线也能打开」的最小壳资源 */
const PRECACHE = [
  '/',
  '/offline.html',
  '/manifest.webmanifest',
  '/icons/192.png',
];

/** ============ install:预缓存 + (可选)立即激活 ============ */
self.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(SHELL_CACHE);
      // ⚠️ 用 addAll 时,任何一个失败都会让整个 install 失败
      //    ⭐ 更稳的做法是「逐个 add + 忽略失败」(关键资源必须成功)
      const results = await Promise.allSettled(PRECACHE.map((url) => cache.add(url)));
      const failed = results.filter((r) => r.status === 'rejected').length;
      if (failed) console.warn(`[sw] 预缓存有 ${failed} 项失败(不影响激活)`);

      // ⭐ 不在这里 skipWaiting —— 让「更新」走「提示用户刷新」的流程
      //    如果一定要「立即生效」,取消下面这行的注释:
      // await self.skipWaiting();
    })()
  );
});

/** ============ activate:清理旧版本缓存 + 可选的立即接管 ============ */
self.addEventListener('activate', (event) => {
  event.waitUntil(
    (async () => {
      const keep = new Set([SHELL_CACHE, DATA_CACHE, IMAGE_CACHE]);
      const keys = await caches.keys();
      await Promise.all(keys.filter((k) => !keep.has(k)).map((k) => caches.delete(k)));

      // ⭐ 开启「导航预加载」:在「页面发起导航」的同时「并行启动 SW」,
      //    避免「SW 冷启动」拖慢首屏(只对「导航请求」生效)
      if (self.registration.navigationPreload) {
        await self.registration.navigationPreload.enable();
      }

      // ⭐ 让当前已打开的页面立刻被新 SW 接管(配合「提示刷新」的流程)
      await self.clients.claim();
    })()
  );
});
/** ============ fetch:策略分发 ============ */
self.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET') return;                    // ⚠️ 只缓存 GET

  const url = new URL(request.url);
  if (url.origin !== self.location.origin) return;         // ⚠️ 跨域不接管(避免 opaque 响应)

  // ⭐ ① 导航请求(HTML)→ network-first + 离线兜底
  if (request.mode === 'navigate') {
    event.respondWith(handleNavigation(event));
    return;
  }

  // ⭐ ② 带 hash 的静态资源 → cache-first(内容不会变)
  if (/\/assets\/.*\.[0-9a-f]{6,}\.(js|css|woff2?)$/.test(url.pathname)) {
    event.respondWith(cacheFirst(request, SHELL_CACHE));
    return;
  }

  // ⭐ ③ 图片 → stale-while-revalidate(旧一点无感,立刻显示价值大)
  if (/\.(png|jpe?g|webp|avif|svg|gif)$/i.test(url.pathname)) {
    event.respondWith(staleWhileRevalidate(event, request, IMAGE_CACHE));
    return;
  }

  // ⭐ ④ API → network-first + 短超时 + 无网时的「陈旧数据」(带提示)
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirstWithTimeout(request, DATA_CACHE, 3000));
    return;
  }

  // ⑤ 其它:默认网络
});

async function handleNavigation(event) {
  try {
    // ⭐ 用「导航预加载」的响应(如果可用)—— 它已经在「SW 启动的同时」开始了
    const preload = await event.preloadResponse;
    if (preload) {
      const cache = await caches.open(SHELL_CACHE);
      cache.put('/', preload.clone());
      return preload;
    }

    const response = await fetch(event.request);
    if (response.ok) {
      const cache = await caches.open(SHELL_CACHE);
      cache.put('/', response.clone());
    }
    return response;
  } catch {
    // ⭐ 离线:返回「缓存的壳」(SPA 能自己渲染路由)
    const cached = await caches.match('/');
    if (cached) return cached;
    const offline = await caches.match('/offline.html');
    return offline ?? new Response('离线且无缓存', { status: 503 });
  }
}

async function cacheFirst(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  if (cached) return cached;
  const response = await fetch(request);
  if (response.ok) cache.put(request, response.clone());
  return response;
}

async function staleWhileRevalidate(event, request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);

  // ⭐ 用 event.waitUntil 保证「后台更新」不会被浏览器提前中断
  const network = fetch(request).then((response) => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  });

  if (cached) {
    event.waitUntil(network.catch(() => {}));
    return cached;
  }
  return network;
}

async function networkFirstWithTimeout(request, cacheName, timeoutMs) {
  const cache = await caches.open(cacheName);
  try {
    const response = await Promise.race([
      fetch(request),
      new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)),
    ]);
    if (response.ok) cache.put(request, response.clone());
    return response;
  } catch {
    const cached = await cache.match(request);
    if (cached) {
      // ⭐ 关键:告诉页面「你拿到的是陈旧数据」(让 UI 提示「离线/可能是旧数据」)
      const headers = new Headers(cached.headers);
      headers.set('X-From-SW-Cache', 'stale');
      return new Response(cached.body, { status: cached.status, statusText: cached.statusText, headers });
    }
    return new Response(JSON.stringify({ error: 'offline' }), {
      status: 503,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

四、更新机制:PWA 最难的部分

⭐⭐⭐ 「用户为什么看不到新版本」的完整因果链:

   ① 你发布了 v5 → 浏览器检查 sw.js 有变化 → 下载新 SW
   ② 新 SW 的 install 完成 → ⚠️ 进入 **waiting**(因为旧 SW 还在控制页面)
   ③ 用户「刷新页面」→ ⭐ **还是旧 SW 在控制**(刷新不等于关闭页面)
   ④ 用户「关闭所有标签页再打开」→ 新 SW 激活
   → ⭐ 于是「用户刷新了 5 次还是旧版本」是**符合规范的行为**

⭐ 三种更新策略(必须选一种,不能不管):

   【A. 静默更新(skipWaiting + 立刻 reload)】
     ✅ 用户总是最新;⚠️ ⚠️ 可能「打断用户操作」(正在填表单时页面刷新了)
     ⭐ 适合:内部工具、后台系统

   【B. 提示更新(推荐)】⭐
     新 SW 进入 waiting → 通过 postMessage 告诉页面 → 页面显示一个「有新版本,刷新」的 toast
     ✅ 用户可控、不打断;⚠️ 需要「用户愿意点」
     ⭐ 适合:面向用户的产品

   【C. 关键更新(强制)】
     检测到「关键版本」(如安全修复/协议变更)→ 强制刷新
     ⚠️ 只有在「旧版本会出严重问题」时才用
// ✅ 策略 B 的完整实现(SW 侧 + 页面侧)
/** ============ SW 侧:等待激活时通知所有页面 ============ */
self.addEventListener('message', (event) => {
  // ⭐ 页面点击「立即更新」后调用
  if (event.data?.type === 'SKIP_WAITING') {
    void self.skipWaiting();
  }
});

/** ============ 页面侧:检测更新 + 提示 ============ */
export function setupServiceWorker({
  swUrl = '/sw.js',
  onUpdateReady,
}: {
  swUrl?: string;
  onUpdateReady?: (applyUpdate: () => void) => void;
} = {}) {
  if (!('serviceWorker' in navigator)) return () => {};

  let refreshing = false;

  // ⭐⭐ 关键:SW 接管变化时(说明新 SW 已激活)→ 刷新一次页面
  //    ⚠️ 必须有「防重复刷新」的保护,否则会无限刷新
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    if (refreshing) return;
    refreshing = true;
    window.location.reload();
  });

  let registration;
  const register = navigator.serviceWorker.register(swUrl, { scope: '/' }).then((reg) => {
    registration = reg;

    /** 检查更新的时机:⭐ 不用定时器硬刷,而是「页面可见时 + 路由变化时」 */
    const checkForUpdate = () => {
      if (document.visibilityState === 'visible') void reg.update();
    };
    document.addEventListener('visibilitychange', checkForUpdate);
    window.addEventListener('online', checkForUpdate);

    reg.addEventListener('updatefound', () => {
      const newWorker = reg.installing;
      if (!newWorker) return;

      newWorker.addEventListener('statechange', () => {
        // ⭐ 新 SW 已「安装完成」且「已有旧 SW 在控制」→ 说明「有更新等待激活」
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          const applyUpdate = () => newWorker.postMessage({ type: 'SKIP_WAITING' });
          onUpdateReady?.(applyUpdate);
        }
      });
    });

    return reg;
  });

  return () => {
    document.removeEventListener('visibilitychange', () => {});
    void registration;
    void register;
  };
}
// ✅ 页面侧的「更新提示」组件
export function UpdateToast() {
  const [apply, setApply] = useState<(() => void) | null>(null);

  useEffect(
    () =>
      setupServiceWorker({
        onUpdateReady: (applyUpdate) => setApply(() => applyUpdate),
      }),
    []
  );

  // ⚠️ 如果用户「正在输入」(表单有 focus 或在编辑中),先不打扰
  const [busy, setBusy] = useState(false);
  useEffect(() => {
    const onFocusIn = () => setBusy(true);
    const onFocusOut = (e: FocusEvent) => {
      const next = e.relatedTarget as HTMLElement | null;
      if (!next || !['INPUT', 'TEXTAREA'].includes(next.tagName)) setBusy(false);
    };
    document.addEventListener('focusin', onFocusIn);
    document.addEventListener('focusout', onFocusOut);
    return () => {
      document.removeEventListener('focusin', onFocusIn);
      document.removeEventListener('focusout', onFocusOut);
    };
  }, []);

  if (!apply || busy) return null;

  return (
    <div role="status" style={{
      position: 'fixed', bottom: 16, left: '50%', transform: 'translateX(-50%)',
      zIndex: 'var(--z-toast, 4000)',   // 见 1.12
      padding: '10px 14px', borderRadius: 10,
      background: 'rgba(20,20,20,.94)', color: '#fff', fontSize: 13,
      display: 'flex', gap: 12, alignItems: 'center',
    }}>
      <span>有新版本可用</span>
      <button onClick={apply} style={{ color: '#8cf', background: 'none', border: 'none', cursor: 'pointer' }}>
        立即更新
      </button>
    </div>
  );
}

五、离线体验设计:比「缓存策略」更重要

⭐⭐⭐ 离线不是「能不能打开」,而是「打开之后怎么办」。四件事:

① 【壳可以离线,数据往往不能】
   SPA 的 HTML/JS/CSS 可以预缓存 → 页面能打开;
   ⭐ 但「数据」是用户的(不能随便缓存共享)→ 必须处理「数据不可用」
   ✅ 做法:缓存「最近一次成功的数据」+ 明确标注「离线数据,可能不是最新」

② 【写操作要能「排队」】
   用户离线时点了「提交」→ 不能直接失败
   ✅ 做法:
     · 乐观 UI(先本地更新,标记「待同步」)
     · 把「待同步操作」存 IndexedDB
     · 监听 `online` 事件(或 Background Sync)时重发
     · ⚠️ 必须处理「冲突」与「重复提交」(用「幂等键」)

③ 【状态要可见】
   ⭐ 用户必须知道「我现在离线」「这条数据可能是旧的」「刚才的操作还没同步」
   ✅ 用 `navigator.onLine` + `online`/`offline` 事件 + 一个全局状态条

④ 【失败要有出路】
   离线时也要能「继续做别的事」(而不是「一个错误弹窗挡住一切」)
// ✅ 离线状态 + 待同步队列(一个可用的最小实现)
import { useSyncExternalStore } from 'react';

/** ============ 一个极简的「离线队列」 ============ */
interface QueuedRequest {
  id: string;
  url: string;
  method: string;
  body?: string;
  createdAt: number;
  /** ⭐ 幂等键(服务端据此去重) */
  idempotencyKey: string;
}

class OfflineQueue {
  private queue: QueuedRequest[] = [];
  private listeners = new Set<() => void>();
  private snapshot = { online: true, pending: 0 };

  constructor() {
    if (typeof window === 'undefined') return;

    this.snapshot.online = navigator.onLine;
    window.addEventListener('online', () => { this.update({ online: true }); void this.flush(); });
    window.addEventListener('offline', () => this.update({ online: false }));
  }

  private update(patch: Partial<typeof this.snapshot>) {
    this.snapshot = { ...this.snapshot, ...patch };
    this.listeners.forEach((l) => l());
  }

  subscribe = (listener: () => void) => {
    this.listeners.add(listener);
    return () => { this.listeners.delete(listener); };
  };

  getSnapshot = () => this.snapshot;

  /** ⭐ 入队(离线时调用) */
  enqueue(req: Omit<QueuedRequest, 'id' | 'createdAt' | 'idempotencyKey'>) {
    const item: QueuedRequest = {
      ...req,
      id: crypto.randomUUID(),
      createdAt: Date.now(),
      // ⭐⭐ 幂等键:服务端用它「去重」,保证「重发不会重复下单」
      idempotencyKey: crypto.randomUUID(),
    };
    this.queue.push(item);
    this.update({ pending: this.queue.length });
    void this.persist();
    return item.id;
  }

  /** ⭐ 恢复网络后按顺序重发 */
  async flush() {
    if (!navigator.onLine) return;
    const items = [...this.queue];
    for (const item of items) {
      try {
        const res = await fetch(item.url, {
          method: item.method,
          body: item.body,
          headers: {
            'Content-Type': 'application/json',
            // ⭐ 带上幂等键
            'Idempotency-Key': item.idempotencyKey,
          },
        });
        // ⭐ 4xx 视为「永久失败」(不再重试);5xx/网络错误视为「可重试」
        if (res.ok || (res.status >= 400 && res.status < 500)) {
          this.queue = this.queue.filter((q) => q.id !== item.id);
        }
      } catch {
        break;                                // 网络又断了 → 停止
      }
    }
    await this.persist();
    this.update({ pending: this.queue.length });
  }

  private async persist() {
    try {
      // ⚠️ 真实项目用 IndexedDB(localStorage 有 5MB 限制且同步阻塞)
      localStorage.setItem('offline-queue', JSON.stringify(this.queue));
    } catch { /* 配额满 */ }
  }
}

const offlineQueue = new OfflineQueue();
/** ============ 状态条 ============ */
export function NetworkStatusBar() {
  const state = useSyncExternalStore(offlineQueue.subscribe, offlineQueue.getSnapshot, offlineQueue.getSnapshot);

  if (state.online && state.pending === 0) return null;

  return (
    <div role="status" aria-live="polite" style={{
      position: 'fixed', top: 0, left: 0, right: 0, zIndex: 'var(--z-toast, 4000)',
      padding: '6px 12px', fontSize: 12, textAlign: 'center',
      background: state.online ? '#fff7e6' : '#fff1f0',
      color: state.online ? '#874d00' : '#a8071a',
    }}>
      {!state.online && '当前处于离线状态,显示的可能不是最新数据'}
      {state.online && state.pending > 0 && `正在同步 ${state.pending} 条待提交的操作…`}
    </div>
  );
}

六、推送通知:三个必须知道的前提

⭐⭐⭐ Web 推送的三个前提(不满足就不要做):

① 【权限只能请求一次】
   浏览器只允许「用户拒绝后不再弹」→ ⚠️ 滥用会「永久失去推送能力」
   ✅ 做法:「先用自定义 UI 说明价值」→ 用户点了「开启」→ 才调用 `Notification.requestPermission()`
      (⭐ 这叫「双重许可」模式,能显著提升接受率)

② 【iOS 要求「已添加到主屏幕」】
   iOS 16.4+ 才支持 Web Push,且**必须已经安装到主屏幕**(且用户交互后请求权限)
   ⭐ 所以 iOS 上的推送 = 「先引导安装 → 再请求权限」

③ 【推送要「有价值」而不是「打扰」】
   ⭐ 好的推送:「你关注的商品降价了」「你的订单已发货」
   ❌ 坏的推送:「快来逛逛」「我们上新了」(用户会关闭推送甚至卸载)
// ✅ 完整的推送订阅流程(含「双重许可」)
async function enablePush({ vapidPublicKey }: { vapidPublicKey: string }) {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;

  // ⭐ ① 先看当前权限状态(不要「一进来就请求」)
  if (Notification.permission === 'denied') return null;   // 已拒绝 → 不再骚扰

  // ⭐ ② 只有在「用户明确点击『开启通知』」时才执行下面(这就是「双重许可」)
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;

  // ⭐ ③ 订阅(用 VAPID 公钥,服务端用私钥发推送)
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,                                // ⭐ 必须为 true(规范要求)
    applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
  });

  // ⭐ ④ 把订阅信息发给自己的服务端
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  return subscription;
}

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

/** ============ SW 侧:接收推送 ============ */
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  event.waitUntil(
    self.registration.showNotification(data.title ?? '通知', {
      body: data.body,
      icon: '/icons/192.png',
      badge: '/icons/badge.png',
      // ⭐ tag 让「同 tag 的通知」互相替换(避免刷屏)
      tag: data.tag ?? 'default',
      data: { url: data.url ?? '/' },                     // ⭐ 存「点击后跳哪」
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const url = event.notification.data?.url ?? '/';
  event.waitUntil(
    (async () => {
      // ⭐ 优先「复用已打开的窗口」而不是「新开一个」
      const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
      for (const client of all) {
        if (new URL(client.url).origin === self.location.origin) {
          await client.focus();
          return client.navigate(url);
        }
      }
      return self.clients.openWindow(url);
    })()
  );
});

七、本篇特有的坑

① 没在 `beforeinstallprompt` 里 `preventDefault()`
   → 浏览器自己弹安装提示,你无法控制时机(可能一进来就弹)
   ✅ `e.preventDefault()` + 自己在合适时机 `prompt()`

② 期望 `beforeinstallprompt` 在 iOS 上触发(iOS 不支持)
   ✅ iOS 用手动引导(分享 → 添加到主屏幕)

③ 每次「安装被拒绝」后又立刻再弹(用户很烦)
   ⭐ 浏览器会「冷却」一段时间;✅ 自己做「拒绝后 30 天不提示」的记录

④ 用 `addAll` 预缓存,其中一个 404 导致整个 install 失败
   → SW 装不上 → 完全没有离线能力
   ✅ 关键资源用 `add` 并检查,非关键的用 `allSettled` 容错

⑤ 在 install 里 `skipWaiting()`(静默更新),但「正在填表」的用户被打断
   ✅ 用「提示更新」策略(策略 B)

⑥ 只监听 `updatefound` 但不检查 `navigator.serviceWorker.controller`
   ⚠️ 「首次安装」时也会触发 `updatefound`(那不是「更新」)
   ✅ 判断 `newWorker.state === 'installed' && navigator.serviceWorker.controller`

⑦ `controllerchange` 里无限刷新
   navigator.serviceWorker.addEventListener('controllerchange', () => location.reload());   // ❌
   ✅ 加「一次性」标志(本实现里的 `refreshing`)

⑧ 「缓存优先」用于 HTML → 用户永远看到旧版本
   ✅ HTML 用 network-first(见 5.16)

⑨ 缓存了「带 Vary/Cookie 的响应」→ 用户看到别人的数据
   ⚠️ ⚠️ 这是「安全级」的坑:把「用户相关的 API 响应」缓存起来,下一个用户可能拿到
   ✅ 只缓存「同源 + 非用户相关」的响应;用户数据用 `private` 或干脆不缓存
     (⭐ 一定要在 `cache.put` 前判断「是否包含 Set-Cookie / Authorization」)

⑩ 只缓存不清理 → 存储配额被占满,浏览器「整体清除」你的缓存
   ✅ activate 里清理旧版本;监控 `navigator.storage.estimate()`

⑪ 忘了「SW 更新后,老页面的 chunk 已被删」→ ChunkLoadError
   ✅ 监听 `vite:preloadError` 自动 reload 一次(见 5.16)

⑫ 离线时返回缓存的 API 数据但 UI 不提示
   ⚠️ 用户以为「看到的是最新的」
   ✅ 加 `X-From-SW-Cache` 头 + UI 上显示「离线/可能过期」

⑬ 「写操作」离线时直接失败(用户白填一遍)
   ✅ 乐观 UI + 离线队列 + 幂等键

⑭ 重发时没有「幂等键」→ 重复下单/重复提交
   ✅ `Idempotency-Key`(服务端按它去重)

⑮ 用 `localStorage` 存「待同步队列」(5MB 限制 + 同步阻塞)
   ✅ 用 IndexedDB(配 idb/Dexie)

⑯ `pushManager.subscribe` 没传 `userVisibleOnly: true`(规范要求,会报错)
   ✅ `userVisibleOnly: true`

⑰ 推送通知「点开后新开一个标签页」(已经开了 10 个)
   ✅ `notificationclick` 里「复用已打开的窗口」

⑱ 在「非 HTTPS」环境调试 SW(SW 只在 HTTPS 与 localhost 可用)
   ✅ 用 localhost 或配 HTTPS(自签证书 / mkcert)

⑲ 开发环境开着 SW(改代码不生效、缓存诡异)
   ⭐ 这是最常见的开发困扰
   ✅ 开发环境「不注册 SW」,或提供「一键注销」:
     navigator.serviceWorker.getRegistrations().then((rs) => rs.forEach((r) => r.unregister()));

⑳ 认为「PWA = 离线可用」(忽略「安装体验」才是最大价值)
   ⭐ 对很多业务来说,「添加到主屏幕」带来的「复访率」比「离线」更重要
// ⑨ 的完整处理(安全级):缓存前过滤「用户相关」的响应
async function safePut(cache, request, response) {
  // ⭐ ① 带 Set-Cookie 的响应不缓存(可能是「登录态」相关)
  if (response.headers.get('Set-Cookie')) return;

  // ⭐ ② 带 Authorization 的请求不缓存
  if (request.headers.get('Authorization')) return;

  // ⭐ ③ 响应声明了 no-store/private 的不缓存
  const cc = response.headers.get('Cache-Control') ?? '';
  if (/no-store|private/i.test(cc)) return;

  // ⭐ ④ 明确的「用户相关」路径不缓存(按业务约定)
  const url = new URL(request.url);
  if (/^\/(api\/(me|profile|cart|orders))/.test(url.pathname)) return;

  await cache.put(request, response);
}
// ⑲ 的完整处理(开发环境的 SW 治理)
// main.tsx
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
  setupServiceWorker({ onUpdateReady: showUpdateToast });
} else if ('serviceWorker' in navigator) {
  // ⭐ 开发环境:把「之前注册过的 SW 全部注销」(避免「改了代码但看到旧页面」)
  void navigator.serviceWorker.getRegistrations().then((rs) => rs.forEach((r) => r.unregister()));
  void caches.keys().then((keys) => keys.forEach((k) => caches.delete(k)));
}

八、面试延伸

  1. 「PWA 是什么?它解决了什么问题?」
    PWA = 「用 Web 技术做出『像应用』的体验」,靠两个东西实现:① manifest.json(声明「我是一个应用」——名称、图标、启动方式、主题色)→ 让浏览器提供「安装到主屏幕」的能力;② Service Worker(可编程的网络代理)→ 提供「离线可用」与「后台能力(同步/推送)」。它解决的核心问题:① ⭐ 复访率——「添加到主屏幕」+「独立窗口」显著提升回访(对移动端业务这是最大价值);② ⭐ 弱网/离线可用——地铁、电梯、差网下仍能打开(壳资源预缓存);③ 成本——一套代码覆盖 Web 与「近似原生」的体验(不必做原生包)。

  2. 「Service Worker 的生命周期是怎样的?为什么『刷新了还是旧版本』?」
    生命周期是 install → waiting → activate → fetch。⭐ waiting 是所有更新问题的根源:当「已有旧 SW 在控制页面」时,新 SW 安装完会停在 waiting,等所有由旧 SW 控制的页面都关闭才激活——而 「刷新页面」不算关闭(所以刷新多次仍是旧版本,这是符合规范的行为)。提前激活的两个手段:self.skipWaiting()(不等页面关闭)+ self.clients.claim()(立刻接管已打开的页面)。⭐ 因此更新策略要显式设计:静默更新(skipWaiting + 自动 reload,可能打断用户)、提示更新(推荐,通知用户后由用户决定)、关键更新(强制刷新)。

  3. 「navigationPreload 是什么?」
    它是「解决 SW 冷启动拖慢首屏」的机制。问题:导航请求要先把 SW 启动起来才能处理,而 SW 冷启动(进程启动 + 脚本执行)可能花几十到几百毫秒,这段时间浏览器在干等。navigationPreload 让浏览器在启动 SW 的同时就并行发起网络请求,SW 起来后直接用 event.preloadResponse 拿到已经开始的响应 → ⭐ 把「SW 启动」与「网络请求」并行起来。启用方式:activate 里 registration.navigationPreload.enable(),并在 fetch 的导航分支里 await event.preloadResponse。⚠️ 它只对导航请求生效(不适用于子资源)。

  4. 「离线时要怎么设计体验?」
    四件事:① ⭐ 壳能离线、数据往往不能——SPA 的 HTML/JS/CSS 可以预缓存(页面能打开),但「数据」是用户的,必须处理「数据不可用」:✅ 缓存「最近一次成功的数据」+ 明确标注「离线数据,可能不是最新」;② ⭐ 写操作要排队——离线时点提交不能直接失败,要用「乐观 UI(先本地更新 + 标记待同步)+ 待同步队列(IndexedDB)+ 恢复网络时重发」;③ ⭐ 状态要可见——用户必须知道「我现在离线」「这条可能是旧的」「刚才的操作还没同步」(navigator.onLine + online/offline 事件 + 全局状态条);④ 失败要有出路——不要让「一个错误弹窗挡住一切」。

  5. 「重发离线队列时有什么风险?」
    两个核心风险:① ⭐ 重复提交——用户点了「下单」,请求其实到达了服务端但响应没回来(以为失败),重发就会下两单;✅ 解法是「幂等键(Idempotency-Key)」——客户端为每次操作生成一个 UUID,服务端按这个键去重(第二次到达直接返回第一次的结果);② ⭐ 冲突——离线时用户改了数据,恢复网络时服务端的数据已被别人改过(✅ 需要「版本号/If-Match」或「服务端合并策略」,至少要让用户知道「你的修改被覆盖了」)。另外:要区分「可重试」与「永久失败」——如 4xx(参数错误、无权限)不该无限重试,5xx/网络错误才重试,并配「指数退避」。

  6. 「PWA 的缓存有什么安全风险?」
    ⭐ 最大的风险是「把用户相关的响应缓存起来,被下一个用户拿到」——尤其在「共享设备/公共电脑」上,也可能因为「CDN 或 SW 的缓存键不包含身份」而串数据。防御措施:① cache.put 之前做检查——带 Set-Cookie 的响应不缓存、带 Authorization 的请求不缓存、Cache-Control 含 no-store/private 的不缓存、明确的用户相关路径(/api/me、/api/cart)不缓存;② 服务端配合设 Cache-Control: private;③ ⭐ 记住「微前端/SW 的『隔离』不是安全边界」,敏感数据的隔离要靠「认证与授权」,而不是「缓存策略」。

  7. 「Web 推送有哪些坑?」
    四个前提/坑:① ⭐ 权限只能请求一次——用户拒绝后浏览器不再弹(滥用会永久失去推送能力),所以要「双重许可」:先用自定义 UI 说明价值 → 用户点「开启」→ 才 Notification.requestPermission();② ⭐ iOS 要求「已添加到主屏幕」(16.4+ 才支持 Web Push),所以 iOS 上的推送 = 「先引导安装 → 再请求权限」;③ 必须 userVisibleOnly: true(规范要求,否则订阅报错);④ 推送要有价值——「你关注的商品降价了」「订单已发货」是好推送,「快来逛逛」是坏推送;另外「点击通知」时应该复用已打开的窗口(clients.matchAll + client.focus()),而不是「每点一次新开一个标签页」。

  8. 「开发时 SW 很烦(改了代码看到旧页面),怎么处理?」
    ⭐ 开发环境不要注册 SW(import.meta.env.PROD 判断),并且在开发环境「主动注销所有已注册的 SW 并清空所有缓存」——这是最省心的做法。原因:SW 会拦截请求并用缓存响应,于是「你改的代码根本没被请求」;而 SW 的更新又有 waiting 阶段,导致「改了不生效」的错觉。调试手段:① DevTools → Application → Service Workers(可勾选「Update on reload」强制每次刷新都更新,以及「Bypass for network」跳过 SW 直接走网络);② 「Unregister」按钮一键注销;③ 勾选「Offline」模拟离线验证兜底逻辑。另外:SW 只在 HTTPS 或 localhost 可用。

一句话速记

PWA = manifest(声明「我是应用」→ 可安装)+ Service Worker(可编程网络代理 → 离线可用),价值排序是「可安装(复访率)> 离线可用 > 后台同步 > 推送」;可安装条件:manifest 齐全(192/512 图标、start_url、display 非 browser)+ 注册了带 fetch 的 SW + HTTPS(⚠️ iOS 不支持 beforeinstallprompt,只能「分享 → 添加到主屏幕」手动引导;maskable 图标要留内 80% 安全区);⭐ SW 生命周期的 waiting 是「刷新了还是旧版本」的根源(刷新不等于关闭页面),提前激活靠 skipWaiting + clients.claim,更新策略三选一(静默 / ⭐ 提示更新 / 关键强制),并务必给 controllerchange 的 reload 加一次性保护;navigationPreload 让「SW 启动」与「网络请求」并行(只对导航请求);离线体验的四件事:壳可离线但数据要标注「可能过期」、写操作要「乐观 UI + 队列 + 幂等键」(⚠️ 没有幂等键会重复下单)、状态要可见、失败要有出路;⚠️ 安全级的坑是「缓存了用户相关的响应导致串数据」(Set-Cookie/Authorization/no-store/private/用户路径一律不缓存);推送三个前提:权限只能请求一次(用双重许可)、iOS 需先安装、必须 userVisibleOnly: true;⭐ 开发环境不要注册 SW,并主动注销旧 SW + 清缓存(否则「改了代码看到旧页面」)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.11 PWA 与 Service Worker

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫