前端离线暂停更新策略:Service Worker 与 PWA 实战指南



前端离线暂停更新策略:Service Worker 与 PWA 实战指南


摘要

在现代 Web 应用开发中,Progressive Web App(PWA)凭借其离线可用、可安装、推送通知等原生级体验,已成为前端工程化的重要方向。Service Worker 作为 PWA 的核心基础设施,承担着网络请求拦截、资源缓存管理和后台同步等关键职责。然而,在实际生产环境中,一个被广泛忽视却极为重要的需求是:用户需要能够主动控制应用的更新节奏

想象以下场景:一位医生正在使用离线医疗记录应用查看患者病历,此时 Service Worker 在后台静默拉取了新版本并激活,导致页面刷新后界面布局发生变化,正在查看的数据丢失;一位工程师在飞机上使用离线文档工具,落地后应用自动更新,新版本的 API 变更导致本地缓存数据格式不兼容;一位教师正在课堂上使用离线课件应用,突然的更新弹窗打断了教学节奏。

本文以"用户可控的静默更新暂停"为核心命题,从 Service Worker 生命周期原理出发,手把手带你构建一套完整的"暂停更新"机制。内容涵盖:PWA 项目从零搭建、Service Worker 注册与缓存策略设计、暂停标志位的状态管理、install/activate 阶段的拦截逻辑、用户交互界面设计、全链路测试验证、常见陷阱排查以及生产环境最佳实践。所有代码均可直接复制运行,适合前端初中级开发者阅读。

适用技术栈:原生 JavaScript / TypeScript、Service Worker API、Cache API、IndexedDB、Workbox(可选)

适用浏览器:Chrome 80+、Firefox 78+、Safari 14+、Edge 80+

关键词:Service Worker、PWA、离线缓存、暂停更新、Cache API、生命周期、前端工程化


目录

  • 一、场景引入:为何需要用户可控的静默更新暂停
    • 1.1 一个真实的生产事故
    • 1.2 静默更新的"双刃剑"效应
    • 1.3 哪些场景必须支持暂停更新
    • 1.4 本文目标与读者指引
  • 二、核心原理解析:Service Worker 生命周期与更新机制
    • 2.1 Service Worker 是什么
    • 2.2 注册与安装流程
    • 2.3 六大生命周期阶段详解
      • 2.3.1 parsed(解析)
      • 2.3.2 installing(安装中)
      • 2.3.3 installed / waiting(已安装 / 等待中)
      • 2.3.4 activating(激活中)
      • 2.3.5 activated(已激活)
      • 2.3.6 redundant(冗余 / 终止)
    • 2.4 更新触发机制
    • 2.5 skipWaiting 与 clients.claim 的作用
    • 2.6 缓存策略基础:Cache-First / Network-First / Stale-While-Revalidate
  • 三、环境搭建:快速初始化 PWA 项目结构
    • 3.1 开发环境准备
    • 3.2 项目目录结构设计
    • 3.3 基础 HTML 页面
    • 3.4 manifest.json 配置
    • 3.5 本地 HTTPS 开发环境
    • 3.6 验证 PWA 基础功能
  • 四、关键代码实现:构建暂停更新的标志位逻辑
    • 4.1 设计思路:状态存储方案选型
    • 4.2 使用 IndexedDB 持久化暂停状态
    • 4.3 使用 BroadcastChannel 实现跨标签页通信
    • 4.4 暂停状态的数据模型设计
    • 4.5 状态读写工具类封装
    • 4.6 版本比对逻辑
  • 五、拦截更新流程:在 install 阶段注入暂停判断
    • 5.1 Service Worker 主文件结构
    • 5.2 install 事件中的暂停拦截
    • 5.3 activate 事件中的版本切换控制
    • 5.4 fetch 事件中的缓存路由
    • 5.5 消息通信:主线程与 SW 的对话
    • 5.6 强制更新的"逃生通道"
  • 六、用户交互设计:提供清晰的一键暂停与恢复入口
    • 6.1 UI 设计原则
    • 6.2 更新提示横幅组件
    • 6.3 暂停/恢复控制面板
    • 6.4 设置页面中的更新偏好
    • 6.5 无障碍访问(a11y)考量
    • 6.6 完整交互流程图
  • 七、完整实操演练:从注册到验证的全链路测试
    • 7.1 测试场景设计
    • 7.2 使用 Chrome DevTools 调试 Service Worker
    • 7.3 模拟离线环境
    • 7.4 模拟版本更新
    • 7.5 验证暂停逻辑
    • 7.6 验证恢复逻辑
    • 7.7 自动化测试脚本
  • 八、常见陷阱排查:缓存版本冲突与状态不同步问题
    • 8.1 陷阱一:旧 SW 未被终止导致双实例
    • 8.2 陷阱二:缓存键名冲突
    • 8.3 陷阱三:IndexedDB 与 SW 状态不同步
    • 8.4 陷阱四:多标签页竞态条件
    • 8.5 陷阱五:iOS Safari 的特殊行为
    • 8.6 陷阱六:开发环境的缓存"幽灵"
    • 8.7 问题排查决策树
  • 九、进阶优化技巧:平衡用户体验与内容时效性
    • 9.1 分级更新策略
    • 9.2 基于网络质量的自适应更新
    • 9.3 增量更新与差分缓存
    • 9.4 后台静默预缓存
    • 9.5 更新回滚机制
    • 9.6 监控与上报
  • 十、最佳实践总结:构建健壮的离线优先应用策略
    • 10.1 架构设计原则
    • 10.2 代码组织规范
    • 10.3 上线检查清单
    • 10.4 团队协作约定
  • 十一、详细参考资料
  • 附录
    • 附录 A:Service Worker API 速查表
    • 附录 B:完整项目源码清单
    • 附录 C:调试命令与工具速查
    • 附录 D:浏览器兼容性矩阵

一、场景引入:为何需要用户可控的静默更新暂停

1.1 一个真实的生产事故

2024 年某在线教育平台遭遇了一起典型的 PWA 更新事故。该平台为偏远地区学校提供离线课件应用,教师可以提前下载课件,在无网络的教室中使用。

事故经过如下:

复制代码
时间线:
09:00 - 教师张老师打开离线课件应用,开始授课
09:15 - 平台运维团队发布了 v2.3.1 版本(修复了一个非关键 bug)
09:16 - 张老师的浏览器在后台检测到新 SW 文件
09:17 - 新 SW 完成 install,进入 waiting 状态
09:20 - 张老师切换到另一个标签页查看资料
09:21 - 切回课件应用时,旧 SW 被终止,新 SW 激活
09:21 - 新版本修改了课件数据格式(JSON schema 变更)
09:22 - 已缓存的旧格式课件无法解析,页面白屏
09:22 - 教室无网络,无法重新下载
09:22 - 教学中断 15 分钟

这起事故的根本原因并非代码 bug,而是更新策略的设计缺陷:应用没有给用户任何控制权,Service Worker 按照默认的"静默更新 → 下次导航激活"机制运行,用户完全无感知。

1.2 静默更新的"双刃剑"效应

Service Worker 的默认更新机制设计初衷是好的------确保用户始终使用最新版本,无需手动刷新。但在特定场景下,这种"好意"会变成灾难:

静默更新的优点

  • 用户无感知,体验流畅
  • 安全补丁可以及时生效
  • 无需用户手动操作
  • 减少"请清除缓存"类客服工单

静默更新的风险

  • 正在进行的离线操作可能被打断
  • 本地数据格式可能与新版本不兼容
  • 用户无法选择"现在不更新"
  • 多标签页场景下行为不可预测
  • 弱网环境下更新可能下载不完整

1.3 哪些场景必须支持暂停更新

场景 风险等级 说明
离线医疗记录 🔴 极高 数据丢失可能危及生命
离线文档编辑 🔴 极高 未保存内容可能丢失
离线考试系统 🔴 极高 更新导致考试中断
离线地图导航 🟡 高 更新导致路线数据不兼容
离线课件/教材 🟡 高 教学中断
离线笔记应用 🟡 高 笔记格式变更
普通资讯网站 🟢 低 更新影响较小
电商展示页 🟢 低 无离线数据依赖

1.4 本文目标与读者指引

本文目标:手把手带你实现一套完整的"用户可控暂停更新"机制,包括:

  1. 理解 Service Worker 更新的完整生命周期
  2. 搭建一个可运行的 PWA 项目
  3. 实现暂停/恢复更新的核心逻辑
  4. 设计用户友好的交互界面
  5. 掌握测试验证方法
  6. 避开常见的坑

读者要求

  • 了解 HTML/CSS/JavaScript 基础
  • 了解 Promise 和 async/await
  • 有基本的浏览器开发者工具使用经验
  • 无需 Service Worker 前置知识(本文从零讲起)

阅读建议

  • 第一遍:通读全文,理解整体架构
  • 第二遍:跟着代码实操,在自己电脑上跑通
  • 第三遍:尝试修改参数,观察行为变化

二、核心原理解析:Service Worker 生命周期与更新机制

2.1 Service Worker 是什么

Service Worker(以下简称 SW)是浏览器在后台运行的一段 JavaScript 脚本,它独立于网页主线程,不能直接访问 DOM,但可以拦截和处理网络请求、管理缓存、接收推送通知。

复制代码
┌─────────────────────────────────────────────────────────┐
│                    浏览器架构                             │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  ┌─────────────┐    ┌─────────────┐                    │
│  │  标签页 A    │    │  标签页 B    │   ← 主线程        │
│  │  (DOM/JS)   │    │  (DOM/JS)   │                    │
│  └──────┬──────┘    └──────┬──────┘                    │
│         │                  │                            │
│         │    消息通信       │                            │
│         │  (postMessage)   │                            │
│         ▼                  ▼                            │
│  ┌─────────────────────────────────┐                   │
│  │      Service Worker             │  ← 独立线程       │
│  │  (拦截请求 / 管理缓存)          │                    │
│  │  (无 DOM 访问 / 无 window)      │                    │
│  └──────────────┬──────────────────┘                   │
│                 │                                      │
│                 ▼                                      │
│  ┌─────────────────────────────────┐                   │
│  │      Cache API / IndexedDB      │  ← 存储层         │
│  └─────────────────────────────────┘                   │
│                                                         │
│  ┌─────────────────────────────────┐                   │
│  │      网络层 (HTTP/HTTPS)        │                   │
│  └─────────────────────────────────┘                   │
│                                                         │
└─────────────────────────────────────────────────────────┘

关键特性

  • 运行在独立的 Worker 线程中
  • 没有 DOM 访问能力(无 documentwindow
  • 完全异步(大量使用 Promise)
  • 只在 HTTPS 环境下工作(localhost 除外)
  • 生命周期由浏览器管理,空闲时会被终止
  • 可以拦截其作用域内的所有 fetch 请求

2.2 注册与安装流程

SW 的注册通常在主线程中完成:

javascript 复制代码
// main.js - 在主线程中注册 Service Worker

/**
 * 注册 Service Worker
 * 注意:SW 文件的路径决定了其作用域
 * /sw.js → 作用域为整个站点 /
 * /app/sw.js → 作用域为 /app/ 及其子路径
 */
if ('serviceWorker' in navigator) {
  // 等待页面加载完成后再注册(避免影响首屏性能)
  window.addEventListener('load', async () => {
    try {
      // navigator.serviceWorker.register() 返回一个 Promise
      // 参数1:SW 文件路径
      // 参数2:配置对象(可选)
      const registration = await navigator.serviceWorker.register('/sw.js', {
        // scope: '/app/',  // 可选:手动指定作用域(不能超出 SW 文件所在路径)
        // type: 'classic', // 'classic'(默认)或 'module'(ES Module)
      });

      console.log('[Main] SW 注册成功');
      console.log('[Main] 作用域:', registration.scope);
      console.log('[Main] 当前活跃 SW:', registration.active?.scriptURL);
      console.log('[Main] 等待中 SW:', registration.waiting?.scriptURL);
      console.log('[Main] 安装中 SW:', registration.installing?.scriptURL);

    } catch (error) {
      console.error('[Main] SW 注册失败:', error);
    }
  });
}

2.3 六大生命周期阶段详解

Service Worker 的生命周期是理解更新机制的核心。以下是完整的状态流转:

复制代码
                    ┌──────────┐
                    │  parsed  │  ← SW 文件被下载并解析
                    └────┬─────┘
                         │
                         ▼
                    ┌──────────┐
                    │installing│  ← 执行 install 事件回调
                    └────┬─────┘
                         │
              ┌──────────┼──────────┐
              │ 成功     │          │ 失败
              ▼          │          ▼
        ┌──────────┐     │    ┌──────────┐
        │installed │     │    │redundant │  ← 安装失败,SW 被丢弃
        │(waiting) │     │    └──────────┘
        └────┬─────┘     │
             │           │
             │ 旧 SW 终止  │ skipWaiting()
             │ 或首次安装   │
             ▼           │
        ┌──────────┐     │
        │activating│  ←──┘  执行 activate 事件回调
        └────┬─────┘
             │
             ▼
        ┌──────────┐
        │activated │  ← 正常工作状态,拦截 fetch 请求
        └────┬─────┘
             │
             │ 新版本安装成功 / 被手动注销
             ▼
        ┌──────────┐
        │redundant │  ← 被替代或终止
        └──────────┘
2.3.1 parsed(解析)

浏览器下载 SW 文件后进行语法解析。如果文件有语法错误,SW 不会进入 installing 阶段。

javascript 复制代码
// 这个阶段没有对应的事件回调
// 如果 sw.js 有语法错误:
// - 注册 Promise 会 reject
// - DevTools → Application → Service Workers 会显示红色错误
2.3.2 installing(安装中)

SW 文件解析成功后触发 install 事件。这是执行预缓存的最佳时机。

javascript 复制代码
// sw.js
self.addEventListener('install', (event) => {
  console.log('[SW] 进入 installing 阶段');
  
  // event.waitUntil() 告诉浏览器:
  // "这个 Promise 完成之前,不要认为安装结束"
  // 如果 Promise reject,安装失败,SW 变为 redundant
  event.waitUntil(
    caches.open('app-cache-v1').then((cache) => {
      // 预缓存关键资源
      return cache.addAll([
        '/',
        '/index.html',
        '/styles/main.css',
        '/scripts/app.js',
        '/images/logo.png',
      ]);
    })
  );
});
2.3.3 installed / waiting(已安装 / 等待中)

install 成功后,如果当前已有旧版本 SW 在控制页面,新 SW 会进入 waiting 状态。它已经安装好了,但在等待旧 SW "让位"。

javascript 复制代码
// 此时新 SW 的状态是 "waiting"
// 它不会拦截任何请求
// 它不会接收 fetch 事件
// 它在等待以下任一条件:
// 1. 所有由旧 SW 控制的标签页关闭
// 2. 旧 SW 调用了 self.skipWaiting()
// 3. 用户手动在 DevTools 中点击 "Skip waiting"
2.3.4 activating(激活中)

当旧 SW 不再控制任何页面时,新 SW 触发 activate 事件。这是执行缓存清理的最佳时机。

javascript 复制代码
// sw.js
self.addEventListener('activate', (event) => {
  console.log('[SW] 进入 activating 阶段');
  
  const CURRENT_CACHE = 'app-cache-v2';
  
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CURRENT_CACHE) // 找出旧版本缓存
          .map((name) => {
            console.log('[SW] 删除旧缓存:', name);
            return caches.delete(name); // 删除
          })
      );
    }).then(() => {
      // 立即接管所有客户端(可选)
      return self.clients.claim();
    })
  );
});
2.3.5 activated(已激活)

SW 正式"上岗",开始拦截作用域内的所有 fetch 请求。

javascript 复制代码
// 此时 SW 完全活跃
// 所有新的导航请求和子资源请求都会经过 fetch 事件
// 可以通过 self.clients.matchAll() 获取所有受控页面
2.3.6 redundant(冗余 / 终止)

SW 被替代(新版本激活)或安装/激活失败时进入此状态。

javascript 复制代码
// redundant 的触发条件:
// 1. install 事件中 waitUntil 的 Promise reject
// 2. activate 事件中 waitUntil 的 Promise reject
// 3. 新版本 SW 成功激活,旧 SW 被替代
// 4. 浏览器内存压力过大,强制终止(较少见)

2.4 更新触发机制

SW 的更新检查在以下时机自动发生:

复制代码
触发更新检查的时机:
├── 导航请求(用户访问页面)
├── 功能性事件(push / sync)
├── 每 24 小时自动检查(浏览器内部定时器)
└── 手动调用 registration.update()

更新检查的逻辑:
1. 浏览器重新下载 SW 文件(忽略 HTTP 缓存,使用 no-cache)
2. 逐字节比对新旧文件
3. 如果有任何一个字节不同 → 触发新 SW 的 install
4. 如果完全相同 → 什么都不做

重要细节:
- 即使 HTTP 响应头设置了 Cache-Control: max-age=31536000
  浏览器对 SW 文件本身也会使用 no-cache 策略重新请求
- 但 SW 文件中 importScripts() 引入的脚本受 HTTP 缓存影响!
  (这是一个常见的坑,后文详述)

2.5 skipWaiting 与 clients.claim 的作用

javascript 复制代码
// ===== skipWaiting =====
// 作用:让新 SW 跳过 waiting 阶段,立即进入 activating
// 使用场景:你希望新版本立即生效(但本文要做的恰恰是"不"立即生效)

self.addEventListener('install', (event) => {
  // 如果调用 skipWaiting,新 SW 会立即替代旧 SW
  // 本文的暂停逻辑就是要"阻止"这个行为
  // self.skipWaiting(); // ← 暂停更新时,这行不能执行
});

// ===== clients.claim =====
// 作用:让新激活的 SW 立即接管所有已打开的标签页
// 默认行为:新 SW 激活后,只控制"之后"打开的页面
//          已经打开的页面仍由旧 SW 控制(直到刷新)

self.addEventListener('activate', (event) => {
  event.waitUntil(
    self.clients.claim() // 立即接管所有标签页
  );
});

2.6 缓存策略基础

策略 行为 适用场景
Cache-First 先查缓存,没有再请求网络 静态资源(图片、字体)
Network-First 先请求网络,失败再用缓存 API 数据、频繁更新的内容
Stale-While-Revalidate 立即返回缓存,同时后台更新 不要求实时性的内容
Network-Only 只走网络,不用缓存 实时性要求极高的数据
Cache-Only 只用缓存,不请求网络 纯离线场景
javascript 复制代码
// Cache-First 示例
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      if (cachedResponse) {
        return cachedResponse; // 缓存命中,直接返回
      }
      return fetch(event.request); // 缓存未命中,走网络
    })
  );
});

三、环境搭建:快速初始化 PWA 项目结构

3.1 开发环境准备

bash 复制代码
# 确认 Node.js 已安装(用于本地开发服务器)
node --version   # 需要 v16+
npm --version    # 需要 v8+

# 创建项目目录
mkdir pwa-pause-update && cd pwa-pause-update

# 初始化 npm 项目
npm init -y

# 安装开发依赖(仅用于本地 HTTPS 服务器)
npm install --save-dev serve
# 或者使用 Python 内置服务器(更简单):
# python3 -m http.server 8080

3.2 项目目录结构设计

复制代码
pwa-pause-update/
├── index.html              # 主页面
├── manifest.json           # PWA 清单文件
├── sw.js                   # Service Worker 主文件
├── sw-utils.js             # SW 工具函数(缓存管理)
├── css/
│   └── style.css           # 样式文件
├── js/
│   ├── main.js             # 主线程逻辑
│   ├── update-manager.js   # 更新管理器(暂停/恢复)
│   ├── db.js               # IndexedDB 封装
│   └── ui.js               # UI 交互组件
├── icons/
│   ├── icon-192.png        # PWA 图标 192x192
│   └── icon-512.png        # PWA 图标 512x512
├── offline.html            # 离线回退页面
└── package.json            # 项目配置

3.3 基础 HTML 页面

html 复制代码
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  
  <!-- PWA 必需:关联 manifest 文件 -->
  <link rel="manifest" href="/manifest.json">
  
  <!-- iOS Safari PWA 支持 -->
  <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="离线应用">
  <link rel="apple-touch-icon" href="/icons/icon-192.png">
  
  <!-- 主题色 -->
  <meta name="theme-color" content="#1a73e8">
  
  <title>PWA 暂停更新演示</title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <!-- 更新提示横幅(默认隐藏) -->
  <div id="update-banner" class="update-banner hidden" role="alert" aria-live="polite">
    <span class="banner-text">🎉 发现新版本可用!</span>
    <button id="btn-update-now" class="btn btn-primary">立即更新</button>
    <button id="btn-update-later" class="btn btn-secondary">稍后提醒</button>
    <button id="btn-pause-update" class="btn btn-warning">暂停更新</button>
  </div>

  <!-- 主内容区 -->
  <main class="container">
    <h1>📱 PWA 暂停更新演示</h1>
    <p class="subtitle">Service Worker 生命周期控制实战</p>

    <!-- 状态面板 -->
    <section class="status-panel" aria-label="应用状态">
      <h2>当前状态</h2>
      <div class="status-grid">
        <div class="status-item">
          <span class="label">SW 状态:</span>
          <span id="sw-status" class="value">检测中...</span>
        </div>
        <div class="status-item">
          <span class="label">缓存版本:</span>
          <span id="cache-version" class="value">-</span>
        </div>
        <div class="status-item">
          <span class="label">更新暂停:</span>
          <span id="pause-status" class="value">否</span>
        </div>
        <div class="status-item">
          <span class="label">网络状态:</span>
          <span id="network-status" class="value">在线</span>
        </div>
      </div>
    </section>

    <!-- 控制面板 -->
    <section class="control-panel" aria-label="更新控制">
      <h2>更新控制</h2>
      <div class="control-buttons">
        <button id="btn-toggle-pause" class="btn btn-large">
          ⏸️ 暂停更新
        </button>
        <button id="btn-check-update" class="btn btn-large">
          🔄 检查更新
        </button>
        <button id="btn-force-update" class="btn btn-large btn-danger">
          ⚡ 强制更新
        </button>
        <button id="btn-clear-cache" class="btn btn-large btn-secondary">
          🗑️ 清除缓存
        </button>
      </div>
    </section>

    <!-- 日志面板 -->
    <section class="log-panel" aria-label="事件日志">
      <h2>事件日志</h2>
      <div id="event-log" class="log-container" aria-live="polite">
        <p class="log-placeholder">等待事件...</p>
      </div>
    </section>
  </main>

  <!-- 设置面板(侧滑) -->
  <aside id="settings-panel" class="settings-panel hidden" aria-label="设置">
    <h2>更新设置</h2>
    <div class="setting-item">
      <label for="auto-update-toggle">自动更新</label>
      <input type="checkbox" id="auto-update-toggle" checked>
    </div>
    <div class="setting-item">
      <label for="pause-duration">暂停时长</label>
      <select id="pause-duration">
        <option value="3600000">1 小时</option>
        <option value="86400000">1 天</option>
        <option value="604800000">1 周</option>
        <option value="0">永久(手动恢复)</option>
      </select>
    </div>
    <div class="setting-item">
      <label for="notify-on-update">更新时通知我</label>
      <input type="checkbox" id="notify-on-update" checked>
    </div>
    <button id="btn-close-settings" class="btn">关闭</button>
  </aside>

  <!-- 脚本加载 -->
  <script src="/js/db.js"></script>
  <script src="/js/update-manager.js"></script>
  <script src="/js/ui.js"></script>
  <script src="/js/main.js"></script>
</body>
</html>

3.4 manifest.json 配置

json 复制代码
{
  "name": "PWA 暂停更新演示应用",
  "short_name": "PWA演示",
  "description": "演示 Service Worker 暂停更新策略的 PWA 应用",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#1a73e8",
  "orientation": "portrait-primary",
  "scope": "/",
  "lang": "zh-CN",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "categories": ["education", "productivity"],
  "screenshots": [],
  "prefer_related_applications": false
}

3.5 本地 HTTPS 开发环境

Service Worker 要求 HTTPS 环境(localhost 除外)。本地开发有两种方案:

bash 复制代码
# 方案一:使用 localhost(最简单,SW 允许 localhost 使用 HTTP)
npx serve -l 3000 .
# 访问 http://localhost:3000

# 方案二:使用 mkcert 生成本地可信证书(模拟真实 HTTPS)
# 安装 mkcert
brew install mkcert        # macOS
# 或 choco install mkcert  # Windows

# 生成本地 CA 和证书
mkcert -install
mkcert localhost 127.0.0.1

# 使用 https-server
npx http-server -S -C localhost.pem -K localhost-key.pem -p 8443 .
# 访问 https://localhost:8443

3.6 验证 PWA 基础功能

复制代码
验证清单:
□ 打开 Chrome DevTools → Application → Manifest
  - 确认 manifest.json 被正确解析
  - 无红色错误

□ Application → Service Workers
  - 确认 SW 状态为 "activated and running"
  - 作用域显示为 "/"

□ Application → Cache Storage
  - 确认缓存已创建
  - 展开查看缓存的资源列表

□ 地址栏出现"安装"图标(或菜单中有"安装应用"选项)

□ Lighthouse 审计 → PWA 分数 > 90

四、关键代码实现:构建暂停更新的标志位逻辑

4.1 设计思路:状态存储方案选型

暂停状态需要持久化存储,且在 SW 和主线程之间共享。可选方案对比:

方案 SW 可访问 主线程可访问 持久化 跨标签页 推荐度
localStorage ✅(同源) ⭐⭐
IndexedDB ✅(同源) ⭐⭐⭐⭐⭐
Cache API ✅(同源) ⭐⭐⭐
全局变量 ❌(SW独立)
Cookie ⭐⭐

最终选择:IndexedDB

  • SW 和主线程都可以访问
  • 结构化存储,支持复杂查询
  • 异步非阻塞
  • 容量充足(通常 50MB+)

4.2 使用 IndexedDB 持久化暂停状态

javascript 复制代码
// js/db.js
// IndexedDB 封装 - 管理更新暂停状态

/**
 * 数据库配置常量
 */
const DB_CONFIG = {
  name: 'pwa-update-control',    // 数据库名称
  version: 1,                     // 数据库版本号
  storeName: 'update-settings',   // 对象存储名称
};

/**
 * 打开(或创建)IndexedDB 数据库
 * @returns {Promise<IDBDatabase>} 数据库实例
 */
function openDatabase() {
  return new Promise((resolve, reject) => {
    // indexedDB.open() 返回一个 IDBOpenDBRequest
    const request = indexedDB.open(DB_CONFIG.name, DB_CONFIG.version);

    // 数据库首次创建或版本升级时触发
    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      
      // 如果对象存储不存在,则创建
      if (!db.objectStoreNames.contains(DB_CONFIG.storeName)) {
        const store = db.createObjectStore(DB_CONFIG.storeName, {
          keyPath: 'id',  // 使用 'id' 字段作为主键
        });
        
        // 创建索引:按时间戳查询
        store.createIndex('timestamp', 'timestamp', { unique: false });
        
        console.log('[DB] 对象存储创建成功:', DB_CONFIG.storeName);
      }
    };

    // 数据库打开成功
    request.onsuccess = (event) => {
      resolve(event.target.result);
    };

    // 数据库打开失败
    request.onerror = (event) => {
      reject(new Error(`[DB] 打开失败: ${event.target.error?.message}`));
    };
  });
}

/**
 * 写入一条记录
 * @param {Object} data - 要存储的数据对象(必须包含 id 字段)
 * @returns {Promise<void>}
 */
async function dbPut(data) {
  const db = await openDatabase();
  
  return new Promise((resolve, reject) => {
    // 创建读写事务
    const transaction = db.transaction(DB_CONFIG.storeName, 'readwrite');
    const store = transaction.objectStore(DB_CONFIG.storeName);
    
    // put:存在则更新,不存在则插入
    const request = store.put({
      ...data,
      timestamp: Date.now(),  // 自动添加时间戳
    });
    
    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
    
    // 事务完成后关闭数据库连接
    transaction.oncomplete = () => db.close();
  });
}

/**
 * 读取一条记录
 * @param {string} id - 记录主键
 * @returns {Promise<Object|undefined>}
 */
async function dbGet(id) {
  const db = await openDatabase();
  
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(DB_CONFIG.storeName, 'readonly');
    const store = transaction.objectStore(DB_CONFIG.storeName);
    const request = store.get(id);
    
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
    
    transaction.oncomplete = () => db.close();
  });
}

/**
 * 删除一条记录
 * @param {string} id - 记录主键
 * @returns {Promise<void>}
 */
async function dbDelete(id) {
  const db = await openDatabase();
  
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(DB_CONFIG.storeName, 'readwrite');
    const store = transaction.objectStore(DB_CONFIG.storeName);
    const request = store.delete(id);
    
    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
    
    transaction.oncomplete = () => db.close();
  });
}

// 导出(兼容 SW 和主线程两种环境)
if (typeof module !== 'undefined' && module.exports) {
  module.exports = { openDatabase, dbPut, dbGet, dbDelete, DB_CONFIG };
}

4.3 使用 BroadcastChannel 实现跨标签页通信

javascript 复制代码
// js/update-manager.js(通信部分)

/**
 * 更新管理器 - 负责暂停/恢复逻辑和跨组件通信
 */
class UpdateManager {
  constructor() {
    // BroadcastChannel:同源下所有标签页和 SW 都能收到消息
    this.channel = new BroadcastChannel('pwa-update-channel');
    
    // 当前暂停状态(内存缓存,避免频繁读 DB)
    this.isPaused = false;
    
    // 暂停到期时间
    this.pauseUntil = 0;
    
    // 初始化:从 IndexedDB 恢复状态
    this._restoreState();
    
    // 监听来自其他标签页/SW 的消息
    this.channel.onmessage = (event) => this._handleMessage(event.data);
  }

  /**
   * 从 IndexedDB 恢复暂停状态
   * @private
   */
  async _restoreState() {
    try {
      const record = await dbGet('pause-state');
      
      if (record) {
        // 检查暂停是否已过期
        if (record.pauseUntil > 0 && Date.now() > record.pauseUntil) {
          // 已过期,自动恢复
          console.log('[UpdateManager] 暂停已过期,自动恢复更新');
          await this.resumeUpdate();
          return;
        }
        
        this.isPaused = record.isPaused;
        this.pauseUntil = record.pauseUntil || 0;
        console.log('[UpdateManager] 恢复状态 - 暂停:', this.isPaused);
      }
    } catch (error) {
      console.error('[UpdateManager] 恢复状态失败:', error);
    }
  }

  /**
   * 暂停更新
   * @param {number} durationMs - 暂停时长(毫秒),0 表示永久
   * @returns {Promise<void>}
   */
  async pauseUpdate(durationMs = 0) {
    const pauseUntil = durationMs > 0 ? Date.now() + durationMs : 0;
    
    this.isPaused = true;
    this.pauseUntil = pauseUntil;
    
    // 持久化到 IndexedDB
    await dbPut({
      id: 'pause-state',
      isPaused: true,
      pauseUntil: pauseUntil,
      pausedAt: Date.now(),
      reason: 'user-manual',  // 暂停原因
    });
    
    // 通知 Service Worker 更新暂停状态
    this._notifySW({ type: 'PAUSE_UPDATE', pauseUntil });
    
    // 通知所有标签页
    this.channel.postMessage({ type: 'STATE_CHANGED', isPaused: true, pauseUntil });
    
    console.log('[UpdateManager] 更新已暂停', 
      pauseUntil > 0 ? `至 ${new Date(pauseUntil).toLocaleString()}` : '(永久)');
  }

  /**
   * 恢复更新
   * @returns {Promise<void>}
   */
  async resumeUpdate() {
    this.isPaused = false;
    this.pauseUntil = 0;
    
    // 更新 IndexedDB
    await dbPut({
      id: 'pause-state',
      isPaused: false,
      pauseUntil: 0,
      resumedAt: Date.now(),
    });
    
    // 通知 SW 恢复
    this._notifySW({ type: 'RESUME_UPDATE' });
    
    // 通知所有标签页
    this.channel.postMessage({ type: 'STATE_CHANGED', isPaused: false, pauseUntil: 0 });
    
    // 恢复后立即检查一次更新
    this._notifySW({ type: 'CHECK_UPDATE' });
    
    console.log('[UpdateManager] 更新已恢复');
  }

  /**
   * 向 Service Worker 发送消息
   * @private
   * @param {Object} message - 消息对象
   */
  _notifySW(message) {
    if (navigator.serviceWorker.controller) {
      navigator.serviceWorker.controller.postMessage(message);
    } else {
      console.warn('[UpdateManager] SW 未激活,消息未发送:', message.type);
    }
  }

  /**
   * 处理来自 BroadcastChannel 的消息
   * @private
   * @param {Object} data - 消息数据
   */
  _handleMessage(data) {
    switch (data.type) {
      case 'STATE_CHANGED':
        // 其他标签页修改了暂停状态,同步到当前标签页
        this.isPaused = data.isPaused;
        this.pauseUntil = data.pauseUntil;
        // 触发 UI 更新(通过自定义事件)
        document.dispatchEvent(new CustomEvent('update-state-changed', {
          detail: { isPaused: data.isPaused, pauseUntil: data.pauseUntil }
        }));
        break;
        
      case 'UPDATE_AVAILABLE':
        // SW 通知有新版本可用
        document.dispatchEvent(new CustomEvent('update-available', {
          detail: data
        }));
        break;
        
      case 'UPDATE_APPLIED':
        // 更新已应用
        document.dispatchEvent(new CustomEvent('update-applied', {
          detail: data
        }));
        break;
    }
  }

  /**
   * 获取当前状态(供 UI 层调用)
   * @returns {Object}
   */
  getState() {
    return {
      isPaused: this.isPaused,
      pauseUntil: this.pauseUntil,
      isExpired: this.pauseUntil > 0 && Date.now() > this.pauseUntil,
    };
  }
}

// 创建全局单例
const updateManager = new UpdateManager();

4.4 暂停状态的数据模型设计

javascript 复制代码
/**
 * IndexedDB 中 'pause-state' 记录的完整数据结构
 * 
 * {
 *   id: 'pause-state',           // 主键(固定值)
 *   isPaused: true,              // 是否处于暂停状态
 *   pauseUntil: 1722500000000,   // 暂停到期时间戳(0=永久)
 *   pausedAt: 1722400000000,     // 暂停操作的时间
 *   resumedAt: null,             // 最近一次恢复的时间
 *   reason: 'user-manual',       // 暂停原因
 *   timestamp: 1722400000000,    // 记录最后修改时间
 * }
 * 
 * reason 可选值:
 * - 'user-manual'    用户手动暂停
 * - 'low-battery'    低电量自动暂停(进阶功能)
 * - 'metered-network' 计量网络自动暂停(进阶功能)
 * - 'critical-task'  关键任务进行中(如考试、手术记录)
 */

4.5 状态读写工具类封装

javascript 复制代码
// js/db.js(补充:SW 环境兼容版本)

/**
 * 在 Service Worker 中读写暂停状态
 * SW 环境没有 window,但可以使用 indexedDB(全局对象)
 */

// SW 中读取暂停状态
async function swGetPauseState() {
  try {
    const db = await openDatabase();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(DB_CONFIG.storeName, 'readonly');
      const store = tx.objectStore(DB_CONFIG.storeName);
      const request = store.get('pause-state');
      
      request.onsuccess = () => {
        const result = request.result;
        // 如果记录不存在,返回默认值(未暂停)
        resolve(result || { id: 'pause-state', isPaused: false, pauseUntil: 0 });
      };
      request.onerror = () => reject(request.error);
      tx.oncomplete = () => db.close();
    });
  } catch (error) {
    console.error('[SW-DB] 读取暂停状态失败:', error);
    // 出错时默认不暂停(安全降级)
    return { id: 'pause-state', isPaused: false, pauseUntil: 0 };
  }
}

// SW 中检查是否应该暂停更新
async function swShouldPauseUpdate() {
  const state = await swGetPauseState();
  
  if (!state.isPaused) {
    return false;  // 未暂停,允许更新
  }
  
  // 检查是否已过期
  if (state.pauseUntil > 0 && Date.now() > state.pauseUntil) {
    console.log('[SW] 暂停已过期,允许更新');
    return false;
  }
  
  console.log('[SW] 更新被暂停,跳过本次更新');
  return true;  // 暂停中,阻止更新
}

4.6 版本比对逻辑

javascript 复制代码
// sw-utils.js(版本管理部分)

/**
 * 应用版本配置
 * 每次发版时修改此值
 */
const APP_VERSION = 'v2.3.1';
const CACHE_NAME = `app-cache-${APP_VERSION}`;

// 需要预缓存的资源列表
const PRECACHE_ASSETS = [
  '/',
  '/index.html',
  '/offline.html',
  '/css/style.css',
  '/js/main.js',
  '/js/update-manager.js',
  '/js/db.js',
  '/js/ui.js',
  '/manifest.json',
  '/icons/icon-192.png',
  '/icons/icon-512.png',
];

/**
 * 比对版本号
 * @param {string} oldVersion - 旧版本(如 'v2.3.0')
 * @param {string} newVersion - 新版本(如 'v2.3.1')
 * @returns {number} 1=新版本更高, 0=相同, -1=旧版本更高
 */
function compareVersions(oldVersion, newVersion) {
  const parse = (v) => v.replace(/^v/, '').split('.').map(Number);
  const old = parse(oldVersion);
  const neu = parse(newVersion);
  
  for (let i = 0; i < Math.max(old.length, neu.length); i++) {
    const o = old[i] || 0;
    const n = neu[i] || 0;
    if (n > o) return 1;
    if (n < o) return -1;
  }
  return 0;
}

/**
 * 从缓存名中提取版本号
 * @param {string} cacheName - 缓存名称(如 'app-cache-v2.3.1')
 * @returns {string} 版本号(如 'v2.3.1')
 */
function extractVersion(cacheName) {
  const match = cacheName.match(/app-cache-(v[\d.]+)/);
  return match ? match[1] : 'unknown';
}

五、拦截更新流程:在 install 阶段注入暂停判断

5.1 Service Worker 主文件结构

javascript 复制代码
// sw.js - Service Worker 主文件(完整版)

/**
 * PWA 暂停更新策略 - Service Worker 实现
 * 
 * 核心逻辑:
 * 1. install 阶段:检查暂停标志,决定是否执行预缓存
 * 2. activate 阶段:清理旧缓存
 * 3. fetch 阶段:根据策略返回缓存或网络响应
 * 4. message 阶段:接收主线程的控制指令
 */

// ===== 引入工具函数 =====
// 注意:importScripts 是同步的,在 SW 顶层执行
importScripts('/sw-utils.js');
importScripts('/js/db.js');  // IndexedDB 工具(SW 中也可用)

// ===== 全局常量 =====
const APP_VERSION = 'v2.3.1';
const CACHE_NAME = `app-cache-${APP_VERSION}`;
const RUNTIME_CACHE = `runtime-cache-${APP_VERSION}`;

const PRECACHE_ASSETS = [
  '/',
  '/index.html',
  '/offline.html',
  '/css/style.css',
  '/js/main.js',
  '/js/update-manager.js',
  '/js/db.js',
  '/js/ui.js',
  '/manifest.json',
];

// ===== 内存中的暂停状态缓存 =====
// 避免每次 install 都读 IndexedDB(虽然 IndexedDB 很快)
let pauseStateCache = { isPaused: false, pauseUntil: 0 };

// ============================================================
// 阶段一:INSTALL - 安装(暂停拦截的核心位置)
// ============================================================
self.addEventListener('install', (event) => {
  console.log(`[SW ${APP_VERSION}] install 事件触发`);
  
  event.waitUntil(
    (async () => {
      // ★★★ 核心:检查是否应该暂停更新 ★★★
      const shouldPause = await swShouldPauseUpdate();
      
      if (shouldPause) {
        console.log(`[SW ${APP_VERSION}] ⏸️ 更新被用户暂停,跳过预缓存`);
        
        // 关键决策:暂停时我们有两个选择
        // 选择A:让 install 成功但不预缓存(SW 进入 waiting)
        // 选择B:让 install 失败(SW 变为 redundant,完全阻止)
        
        // 我们选择 A:安装成功但不预缓存
        // 这样当用户恢复更新时,可以手动触发重新安装
        // 如果选择 B,浏览器会在下次导航时重新尝试安装(又会被拦截)
        
        // 通知主线程:更新被暂停
        const clients = await self.clients.matchAll({ type: 'window' });
        clients.forEach((client) => {
          client.postMessage({
            type: 'UPDATE_PAUSED',
            version: APP_VERSION,
            message: `新版本 ${APP_VERSION} 的安装已被暂停`,
          });
        });
        
        // 不调用 self.skipWaiting()
        // 不执行 caches.open().addAll()
        // install 正常完成,SW 进入 waiting 状态
        return;
      }
      
      // ===== 未暂停:正常执行预缓存 =====
      console.log(`[SW ${APP_VERSION}] ✅ 开始预缓存资源`);
      
      const cache = await caches.open(CACHE_NAME);
      
      // 使用 addAll 批量缓存(任何一个失败则整体失败)
      await cache.addAll(PRECACHE_ASSETS);
      
      console.log(`[SW ${APP_VERSION}] ✅ 预缓存完成,共 ${PRECACHE_ASSETS.length} 个资源`);
      
      // 注意:这里我们"不"调用 self.skipWaiting()
      // 让新 SW 在 waiting 状态等待,由用户决定何时激活
      // 如果业务需要立即激活,可以取消下面这行的注释:
      // await self.skipWaiting();
      
    })()
  );
});

// ============================================================
// 阶段二:ACTIVATE - 激活(清理旧缓存)
// ============================================================
self.addEventListener('activate', (event) => {
  console.log(`[SW ${APP_VERSION}] activate 事件触发`);
  
  event.waitUntil(
    (async () => {
      // 获取所有缓存名称
      const cacheNames = await caches.keys();
      
      // 找出需要删除的旧缓存
      const cachesToDelete = cacheNames.filter((name) => {
        // 保留当前版本的缓存
        if (name === CACHE_NAME || name === RUNTIME_CACHE) {
          return false;
        }
        // 删除所有其他 app-cache-* 和 runtime-cache-*
        return name.startsWith('app-cache-') || name.startsWith('runtime-cache-');
      });
      
      // 并行删除旧缓存
      await Promise.all(
        cachesToDelete.map((name) => {
          console.log(`[SW] 🗑️ 删除旧缓存: ${name}`);
          return caches.delete(name);
        })
      );
      
      // 立即接管所有客户端
      // 这确保新 SW 激活后立即控制所有已打开的标签页
      await self.clients.claim();
      
      console.log(`[SW ${APP_VERSION}] ✅ 激活完成,已接管所有客户端`);
      
      // 通知所有客户端:新版本已激活
      const clients = await self.clients.matchAll({ type: 'window' });
      clients.forEach((client) => {
        client.postMessage({
          type: 'UPDATE_APPLIED',
          version: APP_VERSION,
        });
      });
      
    })()
  );
});

// ============================================================
// 阶段三:FETCH - 请求拦截(缓存路由)
// ============================================================
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);
  
  // 只处理同源请求
  if (url.origin !== self.location.origin) {
    return;  // 跨域请求直接走网络
  }
  
  // 只处理 GET 请求
  if (request.method !== 'GET') {
    return;
  }
  
  // 导航请求(HTML 页面):Network-First 策略
  if (request.mode === 'navigate') {
    event.respondWith(handleNavigationRequest(request));
    return;
  }
  
  // 静态资源(JS/CSS/图片):Cache-First 策略
  if (isStaticAsset(url.pathname)) {
    event.respondWith(handleStaticAsset(request));
    return;
  }
  
  // API 请求:Network-First + 缓存回退
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(handleApiRequest(request));
    return;
  }
  
  // 其他请求:Stale-While-Revalidate
  event.respondWith(handleDefaultRequest(request));
});

/**
 * 处理导航请求(HTML 页面)
 * 策略:Network-First,失败时返回缓存,再失败返回离线页面
 */
async function handleNavigationRequest(request) {
  try {
    // 先尝试网络
    const networkResponse = await fetch(request);
    
    // 网络成功,更新缓存
    const cache = await caches.open(RUNTIME_CACHE);
    cache.put(request, networkResponse.clone());
    
    return networkResponse;
    
  } catch (networkError) {
    // 网络失败,尝试缓存
    const cachedResponse = await caches.match(request);
    
    if (cachedResponse) {
      return cachedResponse;
    }
    
    // 缓存也没有,返回离线页面
    const offlinePage = await caches.match('/offline.html');
    return offlinePage || new Response('离线中,无法加载页面', {
      status: 503,
      headers: { 'Content-Type': 'text/html; charset=utf-8' },
    });
  }
}

/**
 * 处理静态资源请求
 * 策略:Cache-First
 */
async function handleStaticAsset(request) {
  // 先查缓存
  const cachedResponse = await caches.match(request);
  
  if (cachedResponse) {
    return cachedResponse;  // 缓存命中
  }
  
  // 缓存未命中,走网络
  try {
    const networkResponse = await fetch(request);
    
    // 缓存成功的响应
    if (networkResponse.ok) {
      const cache = await caches.open(RUNTIME_CACHE);
      cache.put(request, networkResponse.clone());
    }
    
    return networkResponse;
    
  } catch (error) {
    // 网络也失败了
    return new Response('资源不可用', { status: 503 });
  }
}

/**
 * 处理 API 请求
 * 策略:Network-First + 缓存回退
 */
async function handleApiRequest(request) {
  try {
    const networkResponse = await fetch(request);
    
    // 缓存 API 响应(用于离线回退)
    if (networkResponse.ok) {
      const cache = await caches.open(RUNTIME_CACHE);
      cache.put(request, networkResponse.clone());
    }
    
    return networkResponse;
    
  } catch (error) {
    // 离线时返回缓存的 API 数据
    const cachedResponse = await caches.match(request);
    
    if (cachedResponse) {
      // 添加标记头,告知前端这是缓存数据
      const headers = new Headers(cachedResponse.headers);
      headers.set('X-Cache-Status', 'STALE');
      
      return new Response(cachedResponse.body, {
        status: cachedResponse.status,
        statusText: cachedResponse.statusText,
        headers,
      });
    }
    
    return new Response(JSON.stringify({ error: '离线且无缓存' }), {
      status: 503,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

/**
 * 默认请求处理
 * 策略:Stale-While-Revalidate
 */
async function handleDefaultRequest(request) {
  const cache = await caches.open(RUNTIME_CACHE);
  const cachedResponse = await cache.match(request);
  
  // 立即返回缓存(如果有)
  const fetchPromise = fetch(request).then((networkResponse) => {
    // 后台更新缓存
    if (networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  }).catch(() => cachedResponse);  // 网络失败时回退到缓存
  
  return cachedResponse || fetchPromise;
}

/**
 * 判断是否为静态资源
 */
function isStaticAsset(pathname) {
  const staticExtensions = ['.js', '.css', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.woff2', '.ico'];
  return staticExtensions.some((ext) => pathname.endsWith(ext));
}

// ============================================================
// 阶段四:MESSAGE - 接收主线程消息
// ============================================================
self.addEventListener('message', (event) => {
  const { type, ...payload } = event.data;
  
  console.log(`[SW] 收到消息: ${type}`, payload);
  
  switch (type) {
    case 'PAUSE_UPDATE':
      // 主线程通知暂停更新
      pauseStateCache = { isPaused: true, pauseUntil: payload.pauseUntil || 0 };
      console.log('[SW] 已记录暂停状态');
      break;
      
    case 'RESUME_UPDATE':
      // 主线程通知恢复更新
      pauseStateCache = { isPaused: false, pauseUntil: 0 };
      console.log('[SW] 已恢复更新');
      break;
      
    case 'CHECK_UPDATE':
      // 手动触发更新检查
      self.registration.update();
      break;
      
    case 'SKIP_WAITING':
      // 用户确认要立即更新
      self.skipWaiting();
      break;
      
    case 'GET_VERSION':
      // 主线程查询当前 SW 版本
      event.source.postMessage({
        type: 'VERSION_INFO',
        version: APP_VERSION,
        cacheName: CACHE_NAME,
      });
      break;
      
    case 'FORCE_UPDATE':
      // 强制更新:忽略暂停状态
      pauseStateCache = { isPaused: false, pauseUntil: 0 };
      self.registration.update();
      break;
  }
});

5.2 install 事件中的暂停拦截(核心逻辑详解)

上面的 sw.js 中 install 事件是暂停机制的核心。让我们单独拆解这段逻辑:

javascript 复制代码
// 暂停拦截的核心流程(伪代码)

self.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      // 第一步:从 IndexedDB 读取暂停状态
      const pauseState = await swGetPauseState();
      
      // 第二步:判断是否应该暂停
      const shouldPause = pauseState.isPaused && 
        (pauseState.pauseUntil === 0 || Date.now() < pauseState.pauseUntil);
      
      if (shouldPause) {
        // 第三步(暂停):
        // - 不执行 caches.addAll()(不预缓存新资源)
        // - 不调用 self.skipWaiting()(不激活)
        // - 通知主线程"更新被暂停"
        // - install 正常完成 → SW 进入 waiting
        // 
        // 结果:新 SW 文件已下载,但没有实际"安装"任何资源
        //       它安静地待在 waiting 状态
        //       用户看到的仍是旧版本
        notifyClients('UPDATE_PAUSED');
        return;  // 提前返回,跳过预缓存
      }
      
      // 第四步(未暂停):正常预缓存
      const cache = await caches.open(CACHE_NAME);
      await cache.addAll(PRECACHE_ASSETS);
      
      // 第五步:不自动激活(等用户确认)
      // 如果你希望自动激活,取消下行注释:
      // await self.skipWaiting();
      
    })()
  );
});

5.3 activate 事件中的版本切换控制

javascript 复制代码
// activate 阶段的额外安全检查

self.addEventListener('activate', (event) => {
  event.waitUntil(
    (async () => {
      // 再次检查暂停状态(防止竞态)
      // 场景:用户在 install 之后、activate 之前暂停了更新
      const shouldPause = await swShouldPauseUpdate();
      
      if (shouldPause) {
        // 极端情况:activate 被触发但用户刚暂停
        // 此时我们仍然执行激活(因为 install 已经完成了)
        // 但跳过缓存清理(保留旧缓存作为回退)
        console.log('[SW] activate 时检测到暂停,保留旧缓存');
        await self.clients.claim();
        return;
      }
      
      // 正常清理旧缓存
      const cacheNames = await caches.keys();
      await Promise.all(
        cacheNames
          .filter((n) => n.startsWith('app-cache-') && n !== CACHE_NAME)
          .map((n) => caches.delete(n))
      );
      
      await self.clients.claim();
    })()
  );
});

5.4 fetch 事件中的缓存路由

(已在 5.1 中完整实现,此处补充一个关键细节)

javascript 复制代码
// 在 fetch 中添加版本标记头(方便调试)

async function handleStaticAsset(request) {
  const cachedResponse = await caches.match(request);
  
  if (cachedResponse) {
    // 克隆响应并添加自定义头(标识缓存来源)
    const headers = new Headers(cachedResponse.headers);
    headers.set('X-SW-Cache', CACHE_NAME);
    headers.set('X-SW-Version', APP_VERSION);
    
    return new Response(cachedResponse.body, {
      status: cachedResponse.status,
      statusText: cachedResponse.statusText,
      headers,
    });
  }
  
  // ... 网络回退逻辑
}

5.5 消息通信:主线程与 SW 的对话

javascript 复制代码
// js/main.js(主线程消息处理)

/**
 * 监听来自 Service Worker 的消息
 */
navigator.serviceWorker.addEventListener('message', (event) => {
  const { type, ...payload } = event.data;
  
  switch (type) {
    case 'UPDATE_PAUSED':
      // SW 告知:更新被暂停了
      addLog(`⏸️ ${payload.message}`);
      showPauseNotification(payload.version);
      break;
      
    case 'UPDATE_AVAILABLE':
      // SW 告知:有新版本可用(未暂停时)
      addLog(`🆕 新版本 ${payload.version} 可用`);
      showUpdateBanner(payload.version);
      break;
      
    case 'UPDATE_APPLIED':
      // SW 告知:新版本已激活
      addLog(`✅ 已更新到 ${payload.version}`);
      showUpdateSuccess(payload.version);
      break;
      
    case 'VERSION_INFO':
      // 响应版本查询
      updateVersionDisplay(payload.version);
      break;
  }
});

/**
 * 等待 SW 控制器就绪
 * 首次加载时 navigator.serviceWorker.controller 可能为 null
 */
function waitForSWController() {
  return new Promise((resolve) => {
    if (navigator.serviceWorker.controller) {
      resolve(navigator.serviceWorker.controller);
      return;
    }
    
    navigator.serviceWorker.addEventListener('controllerchange', () => {
      resolve(navigator.serviceWorker.controller);
    }, { once: true });
  });
}

5.6 强制更新的"逃生通道"

javascript 复制代码
// 即使暂停了更新,也必须提供强制更新的途径
// 场景:安全漏洞修复、严重 bug 修复

/**
 * 强制更新(忽略暂停状态)
 * 在 UI 上需要二次确认
 */
async function forceUpdate() {
  // 二次确认
  const confirmed = confirm(
    '⚠️ 强制更新将立即替换当前版本。\n' +
    '未保存的离线数据可能丢失。\n\n' +
    '确定要继续吗?'
  );
  
  if (!confirmed) return;
  
  addLog('⚡ 用户触发强制更新');
  
  // 通知 SW 忽略暂停状态
  if (navigator.serviceWorker.controller) {
    navigator.serviceWorker.controller.postMessage({ type: 'FORCE_UPDATE' });
  }
  
  // 同时恢复暂停状态
  await updateManager.resumeUpdate();
  
  // 等待新 SW 激活后刷新页面
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    addLog('🔄 新版本已激活,正在刷新...');
    setTimeout(() => window.location.reload(), 1000);
  }, { once: true });
}

六、用户交互设计:提供清晰的一键暂停与恢复入口

6.1 UI 设计原则

复制代码
暂停更新功能的 UI 设计原则:

1. 可见性:用户必须能清楚看到当前更新状态
2. 可控性:一键操作,不超过 2 次点击
3. 可逆性:暂停后随时可以恢复
4. 非侵入性:不打断用户当前操作
5. 信息充分:告知用户暂停的影响和到期时间
6. 无障碍:支持键盘操作和屏幕阅读器

6.2 更新提示横幅组件

javascript 复制代码
// js/ui.js - UI 交互组件

/**
 * 更新提示横幅
 * 当检测到新版本且未暂停时显示
 */
class UpdateBanner {
  constructor() {
    this.element = document.getElementById('update-banner');
    this.btnUpdateNow = document.getElementById('btn-update-now');
    this.btnUpdateLater = document.getElementById('btn-update-later');
    this.btnPause = document.getElementById('btn-pause-update');
    
    this._bindEvents();
  }
  
  _bindEvents() {
    // 立即更新
    this.btnUpdateNow.addEventListener('click', () => {
      this.hide();
      this._applyUpdate();
    });
    
    // 稍后提醒(隐藏横幅,下次导航时再显示)
    this.btnUpdateLater.addEventListener('click', () => {
      this.hide();
      addLog('📌 已选择稍后更新');
      // 设置 sessionStorage 标记,本次会话不再弹出
      sessionStorage.setItem('update-dismissed', 'true');
    });
    
    // 暂停更新
    this.btnPause.addEventListener('click', async () => {
      this.hide();
      await updateManager.pauseUpdate(86400000); // 暂停 1 天
      addLog('⏸️ 更新已暂停 24 小时');
      this._showPauseConfirmation();
    });
  }
  
  /**
   * 显示横幅
   * @param {string} version - 新版本号
   */
  show(version) {
    // 如果用户本次会话已选择"稍后",不再弹出
    if (sessionStorage.getItem('update-dismissed')) {
      return;
    }
    
    this.element.querySelector('.banner-text').textContent = 
      `🎉 新版本 ${version} 可用!`;
    this.element.classList.remove('hidden');
    this.element.setAttribute('aria-hidden', 'false');
    
    // 聚焦到横幅(无障碍)
    this.element.focus();
  }
  
  hide() {
    this.element.classList.add('hidden');
    this.element.setAttribute('aria-hidden', 'true');
  }
  
  _applyUpdate() {
    addLog('🔄 正在应用更新...');
    
    // 通知 SW 跳过 waiting,立即激活
    if (navigator.serviceWorker.controller) {
      navigator.serviceWorker.controller.postMessage({ type: 'SKIP_WAITING' });
    }
    
    // 监听 SW 切换
    navigator.serviceWorker.addEventListener('controllerchange', () => {
      addLog('✅ 更新完成,正在刷新页面...');
      window.location.reload();
    }, { once: true });
  }
  
  _showPauseConfirmation() {
    // 显示一个短暂的 toast 提示
    showToast('更新已暂停 24 小时,可在设置中恢复', 'info', 5000);
  }
}

6.3 暂停/恢复控制面板

javascript 复制代码
// js/ui.js(续)- 控制面板

/**
 * 更新控制面板
 * 提供暂停/恢复/检查/强制更新按钮
 */
class ControlPanel {
  constructor() {
    this.btnTogglePause = document.getElementById('btn-toggle-pause');
    this.btnCheckUpdate = document.getElementById('btn-check-update');
    this.btnForceUpdate = document.getElementById('btn-force-update');
    this.btnClearCache = document.getElementById('btn-clear-cache');
    
    this._bindEvents();
    this._updateButtonState();
    
    // 监听状态变化,更新按钮
    document.addEventListener('update-state-changed', () => {
      this._updateButtonState();
    });
  }
  
  _bindEvents() {
    // 暂停/恢复切换
    this.btnTogglePause.addEventListener('click', async () => {
      const state = updateManager.getState();
      
      if (state.isPaused) {
        // 当前是暂停状态 → 恢复
        await updateManager.resumeUpdate();
        addLog('▶️ 更新已恢复');
        showToast('更新已恢复,正在检查新版本...', 'success');
      } else {
        // 当前是正常状态 → 暂停
        // 弹出选择暂停时长的对话框
        const duration = await this._askPauseDuration();
        if (duration !== null) {
          await updateManager.pauseUpdate(duration);
          const durationText = duration === 0 ? '永久' : `${duration / 3600000} 小时`;
          addLog(`⏸️ 更新已暂停(${durationText})`);
          showToast(`更新已暂停 ${durationText}`, 'warning');
        }
      }
      
      this._updateButtonState();
    });
    
    // 检查更新
    this.btnCheckUpdate.addEventListener('click', () => {
      addLog('🔍 正在检查更新...');
      
      if (navigator.serviceWorker.controller) {
        navigator.serviceWorker.controller.postMessage({ type: 'CHECK_UPDATE' });
      }
      
      // 也调用 registration.update()
      navigator.serviceWorker.getRegistration().then((reg) => {
        if (reg) reg.update();
      });
      
      showToast('正在检查更新...', 'info', 3000);
    });
    
    // 强制更新
    this.btnForceUpdate.addEventListener('click', () => {
      forceUpdate();  // 调用 5.6 中定义的函数
    });
    
    // 清除缓存
    this.btnClearCache.addEventListener('click', async () => {
      const confirmed = confirm('确定要清除所有缓存吗?\n离线数据将不可用。');
      if (!confirmed) return;
      
      const cacheNames = await caches.keys();
      await Promise.all(cacheNames.map((name) => caches.delete(name)));
      
      addLog('🗑️ 所有缓存已清除');
      showToast('缓存已清除', 'success');
      
      // 重新注册 SW 以重建缓存
      const reg = await navigator.serviceWorker.getRegistration();
      if (reg) await reg.update();
    });
  }
  
  /**
   * 更新按钮的文本和样式
   * @private
   */
  _updateButtonState() {
    const state = updateManager.getState();
    
    if (state.isPaused) {
      this.btnTogglePause.textContent = '▶️ 恢复更新';
      this.btnTogglePause.classList.remove('btn-warning');
      this.btnTogglePause.classList.add('btn-success');
      
      // 显示暂停到期时间
      if (state.pauseUntil > 0) {
        const until = new Date(state.pauseUntil).toLocaleString('zh-CN');
        this.btnTogglePause.title = `暂停至:${until}`;
      } else {
        this.btnTogglePause.title = '永久暂停(手动恢复)';
      }
    } else {
      this.btnTogglePause.textContent = '⏸️ 暂停更新';
      this.btnTogglePause.classList.remove('btn-success');
      this.btnTogglePause.classList.add('btn-warning');
      this.btnTogglePause.title = '暂停自动更新';
    }
    
    // 更新状态面板
    document.getElementById('pause-status').textContent = 
      state.isPaused ? '是 ⏸️' : '否 ▶️';
    document.getElementById('pause-status').style.color = 
      state.isPaused ? '#e65100' : '#2e7d32';
  }
  
  /**
   * 弹出暂停时长选择
   * @private
   * @returns {Promise<number|null>} 时长(毫秒),null 表示取消
   */
  _askPauseDuration() {
    return new Promise((resolve) => {
      const choice = prompt(
        '选择暂停时长:\n' +
        '1 = 1 小时\n' +
        '2 = 24 小时\n' +
        '3 = 1 周\n' +
        '4 = 永久(手动恢复)\n\n' +
        '请输入数字 (1-4):'
      );
      
      const durations = {
        '1': 3600000,       // 1 小时
        '2': 86400000,      // 24 小时
        '3': 604800000,     // 1 周
        '4': 0,             // 永久
      };
      
      resolve(durations[choice] !== undefined ? durations[choice] : null);
    });
  }
}

6.4 设置页面中的更新偏好

javascript 复制代码
// js/ui.js(续)- 设置面板

/**
 * 设置面板
 */
class SettingsPanel {
  constructor() {
    this.panel = document.getElementById('settings-panel');
    this.autoUpdateToggle = document.getElementById('auto-update-toggle');
    this.pauseDuration = document.getElementById('pause-duration');
    this.notifyToggle = document.getElementById('notify-on-update');
    this.btnClose = document.getElementById('btn-close-settings');
    
    this._loadSettings();
    this._bindEvents();
  }
  
  _loadSettings() {
    // 从 localStorage 加载用户偏好
    const prefs = JSON.parse(localStorage.getItem('update-preferences') || '{}');
    
    this.autoUpdateToggle.checked = prefs.autoUpdate !== false;
    this.pauseDuration.value = prefs.pauseDuration || '86400000';
    this.notifyToggle.checked = prefs.notify !== false;
  }
  
  _saveSettings() {
    const prefs = {
      autoUpdate: this.autoUpdateToggle.checked,
      pauseDuration: this.pauseDuration.value,
      notify: this.notifyToggle.checked,
    };
    localStorage.setItem('update-preferences', JSON.stringify(prefs));
  }
  
  _bindEvents() {
    this.btnClose.addEventListener('click', () => {
      this.panel.classList.add('hidden');
    });
    
    [this.autoUpdateToggle, this.pauseDuration, this.notifyToggle].forEach((el) => {
      el.addEventListener('change', () => this._saveSettings());
    });
  }
  
  open() {
    this.panel.classList.remove('hidden');
  }
  
  close() {
    this.panel.classList.add('hidden');
  }
}

6.5 无障碍访问(a11y)考量

html 复制代码
<!-- 更新横幅的无障碍标记 -->
<div id="update-banner" 
     class="update-banner hidden" 
     role="alert"              <!-- 屏幕阅读器会立即朗读 -->
     aria-live="polite"        <!--  polite: 等当前朗读完再读 -->
     aria-atomic="true"        <!-- 整体朗读,不逐字 -->
     tabindex="-1">            <!-- 可通过 JS focus -->
  
  <span class="banner-text" id="banner-text">
    <!-- 动态内容 -->
  </span>
  
  <!-- 按钮组:使用 role="group" 和 aria-label -->
  <div role="group" aria-label="更新操作">
    <button id="btn-update-now" 
            class="btn btn-primary"
            aria-describedby="banner-text">
      立即更新
    </button>
    <button id="btn-update-later" class="btn btn-secondary">
      稍后提醒
    </button>
    <button id="btn-pause-update" 
            class="btn btn-warning"
            aria-label="暂停自动更新24小时">
      暂停更新
    </button>
  </div>
</div>

6.6 完整交互流程图

复制代码
用户打开应用
     │
     ▼
┌─────────────────┐
│ SW 检查更新     │
│ (自动/手动)     │
└────────┬────────┘
         │
         ▼
┌─────────────────┐     是     ┌──────────────────┐
│ 有新版本?      │──────────→│ 检查暂停状态     │
└────────┬────────┘           └────────┬─────────┘
         │ 否                          │
         ▼                    ┌────────┴────────┐
┌─────────────────┐           │                 │
│ 无操作          │           ▼ 暂停中          ▼ 未暂停
└─────────────────┘    ┌────────────┐    ┌────────────┐
                       │ 静默跳过   │    │ 显示横幅   │
                       │ 通知用户   │    │ 三个按钮   │
                       └────────────┘    └─────┬──────┘
                                               │
                              ┌────────────────┼────────────────┐
                              ▼                ▼                ▼
                       ┌────────────┐   ┌────────────┐   ┌────────────┐
                       │ 立即更新   │   │ 稍后提醒   │   │ 暂停更新   │
                       └─────┬──────┘   └─────┬──────┘   └─────┬──────┘
                             │                │                │
                             ▼                ▼                ▼
                       ┌────────────┐   ┌────────────┐   ┌────────────┐
                       │skipWaiting │   │ 隐藏横幅   │   │ 写入 DB    │
                       │→ activate  │   │ 下次再提示 │   │ 通知 SW    │
                       │→ reload    │   └────────────┘   │ 显示确认   │
                       └────────────┘                    └────────────┘

七、完整实操演练:从注册到验证的全链路测试

7.1 测试场景设计

复制代码
测试矩阵:

场景 1:首次安装
- 预期:SW 注册成功,预缓存完成,无暂停

场景 2:正常更新(未暂停)
- 预期:检测到新 SW → 显示横幅 → 用户点击"立即更新" → 新版本激活

场景 3:暂停更新
- 预期:用户点击"暂停" → 新 SW install 被拦截 → 旧版本继续使用

场景 4:暂停到期自动恢复
- 预期:暂停 1 小时后 → 自动恢复 → 下次检查时正常更新

场景 5:强制更新(暂停中)
- 预期:用户点击"强制更新" → 忽略暂停 → 新版本激活

场景 6:离线状态
- 预期:使用缓存资源,不触发更新检查

场景 7:多标签页
- 预期:一个标签页暂停 → 其他标签页同步状态

7.2 使用 Chrome DevTools 调试 Service Worker

复制代码
调试步骤:

1. 打开 DevTools(F12 或 ⌘+Option+I)

2. Application → Service Workers
   - 查看 SW 状态(activated / waiting / installing)
   - 勾选 "Update on reload"(开发时自动更新)
   - 点击 "Skip waiting"(手动跳过等待)
   - 点击 "Unregister"(注销 SW)
   - 点击 "Push"(模拟推送)
   - 点击 "Sync"(模拟后台同步)

3. Application → Cache Storage
   - 展开查看每个缓存的内容
   - 可以手动删除特定缓存
   - 查看缓存的 Response Headers

4. Application → IndexedDB
   - 展开 pwa-update-control 数据库
   - 查看 update-settings 中的记录
   - 可以手动修改 isPaused 值(测试用)

5. Console 中过滤 "[SW]" 前缀的日志

6. Network 面板:
   - 勾选 "Offline" 模拟离线
   - 查看 SW 拦截的请求(Size 列显示 "ServiceWorker")

7.3 模拟离线环境

复制代码
方法一:DevTools Network 面板
- 打开 Network → 勾选 "Offline"
- 或选择 "Slow 3G" / "Fast 3G" 模拟弱网

方法二:命令行(macOS)
# 使用 networksetup 断开网络(谨慎使用)
sudo networksetup -setairportpower en0 off
# 恢复
sudo networksetup -setairportpower en0 on

方法三:Chrome 命令行参数
chrome --disable-features=NetworkService

验证离线功能:
1. 正常加载页面(建立缓存)
2. 开启 Offline 模式
3. 刷新页面 → 应该从缓存加载
4. 查看 Console → 应有 "[SW] 网络失败,使用缓存" 日志

7.4 模拟版本更新

javascript 复制代码
// 开发时模拟版本更新的方法:

// 方法一:修改 sw.js 中的版本号
// 将 const APP_VERSION = 'v2.3.1' 改为 'v2.3.2'
// 刷新页面 → 浏览器检测到 sw.js 变化 → 触发新 SW install

// 方法二:在 DevTools Console 中手动触发
navigator.serviceWorker.getRegistration().then((reg) => {
  reg.update().then(() => {
    console.log('更新检查完成');
  });
});

// 方法三:修改任意被缓存的文件(如 index.html 加一个空格)
// 这不会触发 SW 更新(SW 只比对 sw.js 本身)
// 但会影响缓存内容

// 方法四:使用 Workbox 的注入版本哈希(生产环境推荐)
// 构建时自动在 sw.js 中注入 precache manifest 的哈希

7.5 验证暂停逻辑

复制代码
测试步骤:

1. 确保当前 SW 版本为 v2.3.1(activated)
2. 点击"暂停更新"按钮 → 选择"1小时"
3. 验证 IndexedDB:
   - Application → IndexedDB → pwa-update-control → update-settings
   - 应看到 { id: 'pause-state', isPaused: true, pauseUntil: <未来时间戳> }
4. 修改 sw.js 版本号为 v2.3.2
5. 刷新页面
6. 观察 DevTools → Application → Service Workers:
   - 应看到新 SW 状态为 "waiting"(而非 activated)
   - Console 应有 "[SW v2.3.2] ⏸️ 更新被用户暂停" 日志
7. 页面内容应仍为 v2.3.1 的版本
8. 状态面板应显示"更新暂停:是 ⏸️"

7.6 验证恢复逻辑

复制代码
测试步骤:

1. 在暂停状态下,点击"恢复更新"按钮
2. 验证 IndexedDB:isPaused 变为 false
3. Console 应有 "[UpdateManager] 更新已恢复" 日志
4. SW 应自动触发 registration.update()
5. 新 SW(v2.3.2)重新执行 install → 这次不被拦截 → 预缓存成功
6. 新 SW 进入 waiting
7. 显示更新横幅:"新版本 v2.3.2 可用"
8. 点击"立即更新" → skipWaiting → activate → 页面刷新 → 新版本生效

7.7 自动化测试脚本

javascript 复制代码
// test/update-flow.test.js
// 使用 Puppeteer 进行端到端测试

const puppeteer = require('puppeteer');

describe('PWA 暂停更新流程', () => {
  let browser, page;
  
  beforeAll(async () => {
    browser = await puppeteer.launch({ headless: false });
    page = await browser.newPage();
    
    // 启用 Service Worker
    const client = await page.target().createCDPSession();
    await client.send('ServiceWorker.enable');
  });
  
  afterAll(async () => {
    await browser.close();
  });
  
  test('场景1:首次加载,SW 注册成功', async () => {
    await page.goto('http://localhost:3000', { waitUntil: 'networkidle0' });
    
    // 等待 SW 激活
    await page.waitForFunction(() => {
      return navigator.serviceWorker.controller !== null;
    }, { timeout: 10000 });
    
    // 验证 SW 状态
    const swStatus = await page.evaluate(() => {
      return navigator.serviceWorker.controller.state;
    });
    expect(swStatus).toBe('activated');
  });
  
  test('场景2:暂停更新后,新 SW 不激活', async () => {
    // 点击暂停按钮
    await page.click('#btn-toggle-pause');
    
    // 处理 prompt 对话框(选择"1小时")
    page.once('dialog', async (dialog) => {
      await dialog.accept('2');  // 选择 24 小时
    });
    
    // 等待暂停生效
    await page.waitForFunction(() => {
      return document.getElementById('pause-status').textContent.includes('是');
    });
    
    // 模拟版本更新(修改 SW 文件后刷新)
    // 注意:实际测试中需要修改服务器上的 sw.js
    await page.reload({ waitUntil: 'networkidle0' });
    
    // 验证新 SW 处于 waiting 而非 activated
    const swState = await page.evaluate(async () => {
      const reg = await navigator.serviceWorker.getRegistration();
      if (reg.waiting) return 'waiting';
      if (reg.active) return 'activated';
      return 'none';
    });
    
    // 如果暂停生效,新 SW 应该在 waiting(或 install 被跳过)
    // 具体行为取决于实现
    console.log('SW 状态:', swState);
  });
  
  test('场景3:恢复更新后,可以正常更新', async () => {
    // 点击恢复
    await page.click('#btn-toggle-pause');
    
    await page.waitForFunction(() => {
      return document.getElementById('pause-status').textContent.includes('否');
    });
    
    // 验证暂停状态已清除
    const isPaused = await page.evaluate(async () => {
      // 读取 IndexedDB
      return new Promise((resolve) => {
        const request = indexedDB.open('pwa-update-control', 1);
        request.onsuccess = (e) => {
          const db = e.target.result;
          const tx = db.transaction('update-settings', 'readonly');
          const store = tx.objectStore('update-settings');
          const getReq = store.get('pause-state');
          getReq.onsuccess = () => resolve(getReq.result?.isPaused || false);
        };
      });
    });
    
    expect(isPaused).toBe(false);
  });
});

八、常见陷阱排查:缓存版本冲突与状态不同步问题

8.1 陷阱一:旧 SW 未被终止导致双实例

症状:Console 中出现两个不同版本的 SW 日志交替输出。

原因:新 SW 激活后,旧 SW 仍在控制某些标签页。

javascript 复制代码
// ❌ 错误做法:activate 中不调用 clients.claim()
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) => {
      return Promise.all(names.filter(n => n !== CACHE_NAME).map(n => caches.delete(n)));
    })
    // 缺少 self.clients.claim()
  );
});

// ✅ 正确做法:activate 中调用 clients.claim()
self.addEventListener('activate', (event) => {
  event.waitUntil(
    (async () => {
      // 清理旧缓存
      const names = await caches.keys();
      await Promise.all(
        names.filter(n => n !== CACHE_NAME).map(n => caches.delete(n))
      );
      // 立即接管所有客户端
      await self.clients.claim();
    })()
  );
});

8.2 陷阱二:缓存键名冲突

症状:更新后页面显示旧内容,或新旧资源混合加载。

原因:不同版本使用了相同的缓存名称。

javascript 复制代码
// ❌ 错误:固定缓存名
const CACHE_NAME = 'my-app-cache';  // 所有版本共用一个名字

// ✅ 正确:缓存名包含版本号
const CACHE_NAME = `my-app-cache-v2.3.1`;

// ✅ 更好:使用构建时注入的哈希
const CACHE_NAME = `my-app-cache-${__BUILD_HASH__}`;
// Webpack: new webpack.DefinePlugin({ __BUILD_HASH__: JSON.stringify(hash) })
// Vite: import.meta.env.VITE_BUILD_HASH

8.3 陷阱三:IndexedDB 与 SW 状态不同步

症状:UI 显示"已暂停",但 SW 仍然执行了更新。

原因:主线程写入 IndexedDB 是异步的,SW 的 install 可能在写入完成前就触发了。

javascript 复制代码
// ❌ 有竞态风险的代码
async function pauseUpdate() {
  // 先通知 SW(此时 DB 还没写完)
  navigator.serviceWorker.controller.postMessage({ type: 'PAUSE_UPDATE' });
  
  // 后写 DB(可能还没完成,SW 的 install 就触发了)
  await dbPut({ id: 'pause-state', isPaused: true });
}

// ✅ 修复:先写 DB,后通知 SW
async function pauseUpdate() {
  // 第一步:先持久化(确保 SW 读到时已经是最新状态)
  await dbPut({ id: 'pause-state', isPaused: true, pauseUntil: 0 });
  
  // 第二步:更新内存缓存
  this.isPaused = true;
  
  // 第三步:通知 SW(此时 DB 已写入完成)
  navigator.serviceWorker.controller.postMessage({ type: 'PAUSE_UPDATE' });
}

8.4 陷阱四:多标签页竞态条件

症状:标签页 A 暂停了更新,标签页 B 仍然触发了更新。

原因:BroadcastChannel 消息是异步的,标签页 B 可能在收到消息前就触发了 SW 更新。

javascript 复制代码
// ✅ 解决方案:SW 中始终从 IndexedDB 读取最新状态
// 不依赖内存中的 pauseStateCache

self.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      // 每次都从 DB 读取(而非使用内存缓存)
      const shouldPause = await swShouldPauseUpdate();
      
      if (shouldPause) {
        // 暂停逻辑...
        return;
      }
      
      // 正常预缓存...
    })()
  );
});

8.5 陷阱五:iOS Safari 的特殊行为

症状:在 iOS Safari 上暂停逻辑不生效。

原因:iOS Safari 对 SW 有多项限制。

复制代码
iOS Safari 的 SW 限制(截至 iOS 17):
1. SW 缓存有 7 天上限(如果 7 天未使用 PWA,缓存可能被清除)
2. 不支持 Background Sync API
3. 不支持 Push API(iOS 16.4+ 部分支持)
4. SW 在后台被更积极地终止
5. 私有浏览模式下 SW 不可用
6. 存储配额更严格(约 50MB)

应对策略:
- 在 iOS 上降低对 SW 缓存的依赖
- 关键数据同时存储在 IndexedDB(更持久)
- 暂停状态使用 localStorage 作为备份
- 检测 iOS 环境并调整策略
javascript 复制代码
// 检测 iOS 环境
function isIOSSafari() {
  const ua = navigator.userAgent;
  const isIOS = /iPad|iPhone|iPod/.test(ua) || 
    (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
  const isSafari = /Safari/.test(ua) && !/Chrome/.test(ua);
  return isIOS && isSafari;
}

// iOS 上的降级策略
if (isIOSSafari()) {
  // 使用 localStorage 备份暂停状态
  const pauseState = JSON.parse(localStorage.getItem('pause-state') || '{}');
  // ...
}

8.6 陷阱六:开发环境的缓存"幽灵"

症状:修改了代码但页面不更新,或出现莫名其妙的旧版本行为。

原因:SW 缓存了旧资源,且 DevTools 的缓存未清除。

复制代码
开发环境防坑清单:

1. DevTools → Application → Service Workers
   ☑ Update on reload(开发时必勾)
   
2. DevTools → Network
   ☑ Disable cache(开发时必勾)

3. 每次修改 sw.js 后:
   - 在 DevTools 中点击 "Unregister"
   - 或 Ctrl+Shift+R(硬刷新)
   - 或关闭所有标签页再重新打开

4. 清除所有站点数据:
   DevTools → Application → Storage → "Clear site data"

5. 使用无痕模式测试(无缓存干扰)

6. 在 sw.js 顶部添加开发日志:
   console.log('[SW] 版本:', APP_VERSION, '时间:', new Date().toISOString());

8.7 问题排查决策树

复制代码
问题:更新行为异常
│
├── SW 根本没有注册?
│   ├── 检查是否在 HTTPS 或 localhost 下
│   ├── 检查 sw.js 路径是否正确
│   ├── 检查 Console 有无注册错误
│   └── 检查浏览器是否支持 SW
│
├── SW 注册了但不拦截请求?
│   ├── 检查 scope 是否覆盖当前页面
│   ├── 检查 SW 状态是否为 activated
│   ├── 检查 fetch 事件中是否有 return
│   └── 检查是否只处理了 GET 请求
│
├── 暂停不生效?
│   ├── 检查 IndexedDB 中 isPaused 是否为 true
│   ├── 检查 SW install 中是否读取了 DB
│   ├── 检查 pauseUntil 是否已过期
│   ├── 检查是否有多标签页竞态
│   └── 检查 iOS Safari 限制
│
├── 恢复后仍不更新?
│   ├── 手动调用 registration.update()
│   ├── 检查 sw.js 文件是否真的有变化
│   ├── 检查 HTTP 缓存(importScripts 的脚本)
│   └── 清除 SW 缓存后重试
│
└── 更新后页面白屏?
    ├── 检查新版本的预缓存列表是否完整
    ├── 检查 activate 中是否误删了需要的缓存
    ├── 检查 Console 错误信息
    └── 检查缓存的资源 MIME 类型是否正确

九、进阶优化技巧:平衡用户体验与内容时效性

9.1 分级更新策略

并非所有更新都需要同等的处理方式。将更新分为三个级别:

javascript 复制代码
// sw.js 中的分级更新逻辑

/**
 * 更新级别定义
 */
const UPDATE_LEVELS = {
  CRITICAL: 'critical',   // 安全漏洞、数据损坏 → 强制更新,不可暂停
  IMPORTANT: 'important', // 功能修复、性能优化 → 提示更新,可暂停
  MINOR: 'minor',         // UI 微调、文案修改 → 静默更新,无需提示
};

/**
 * 从服务器获取更新级别
 * 通常在 sw.js 或单独的 update-config.json 中声明
 */
async function getUpdateLevel() {
  try {
    const response = await fetch('/update-config.json', { cache: 'no-cache' });
    const config = await response.json();
    return config.level || UPDATE_LEVELS.MINOR;
  } catch {
    return UPDATE_LEVELS.MINOR;  // 获取失败时默认为 MINOR
  }
}

// update-config.json(部署在服务器根目录)
// {
//   "version": "v2.3.2",
//   "level": "important",
//   "message": "修复了离线数据同步的问题",
//   "forceBefore": "2024-08-15T00:00:00Z"  // 此日期后强制更新
// }

// install 事件中的分级处理
self.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      const level = await getUpdateLevel();
      
      if (level === UPDATE_LEVELS.CRITICAL) {
        // 关键更新:忽略暂停,强制安装
        console.log('[SW] 🚨 关键安全更新,强制安装');
        await preCache();
        self.skipWaiting();  // 立即激活
        return;
      }
      
      if (level === UPDATE_LEVELS.IMPORTANT) {
        // 重要更新:尊重暂停设置
        const shouldPause = await swShouldPauseUpdate();
        if (shouldPause) {
          notifyClients('UPDATE_PAUSED', { level });
          return;
        }
        await preCache();
        // 不 skipWaiting,等用户确认
        notifyClients('UPDATE_AVAILABLE', { level });
        return;
      }
      
      // MINOR:静默更新
      await preCache();
      self.skipWaiting();  // 静默激活
    })()
  );
});

9.2 基于网络质量的自适应更新

javascript 复制代码
// 根据网络状况决定是否执行更新

self.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      // 检查暂停状态
      if (await swShouldPauseUpdate()) return;
      
      // 检查网络质量(使用 Network Information API)
      // 注意:此 API 在 SW 中可能不可用,需要从主线程传递
      const connection = self.navigator?.connection;
      
      if (connection) {
        // 如果是 2G/3G 或数据节省模式,延迟更新
        if (connection.effectiveType === '2g' || 
            connection.effectiveType === 'slow-2g' ||
            connection.saveData === true) {
          console.log('[SW] 📶 网络质量差,延迟更新');
          notifyClients('UPDATE_DEFERRED', { 
            reason: '网络质量差,将在 WiFi 下自动更新' 
          });
          return;  // 不预缓存,等网络好转
        }
      }
      
      // 网络良好,正常更新
      await preCache();
    })()
  );
});

9.3 增量更新与差分缓存

javascript 复制代码
// 大型应用不必每次全量缓存
// 使用版本化的缓存键,只更新变化的资源

const PRECACHE_MANIFEST = [
  { url: '/', revision: 'abc123' },
  { url: '/index.html', revision: 'def456' },
  { url: '/css/style.css', revision: 'ghi789' },
  { url: '/js/app.js', revision: 'jkl012' },  // 只有这个变了
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      
      for (const asset of PRECACHE_MANIFEST) {
        // 检查是否已缓存且版本相同
        const cacheKey = `${asset.url}?__rev=${asset.revision}`;
        const existing = await cache.match(cacheKey);
        
        if (existing) {
          // 已缓存且版本相同,跳过
          continue;
        }
        
        // 需要更新:下载并缓存
        const response = await fetch(asset.url);
        await cache.put(cacheKey, response);
        
        // 删除旧版本
        const oldKeys = await cache.keys();
        for (const key of oldKeys) {
          if (key.url.startsWith(asset.url) && !key.url.includes(asset.revision)) {
            await cache.delete(key);
          }
        }
      }
    })()
  );
});

9.4 后台静默预缓存

javascript 复制代码
// 使用 Background Sync API 在网络恢复时预缓存
// (注意:iOS Safari 不支持)

// 主线程中注册同步任务
async function schedulePreCache() {
  const reg = await navigator.serviceWorker.ready;
  
  if ('sync' in reg) {
    await reg.sync.register('precache-next-version');
  }
}

// SW 中处理同步事件
self.addEventListener('sync', (event) => {
  if (event.tag === 'precache-next-version') {
    event.waitUntil(
      (async () => {
        // 在后台静默下载下一版本的资源
        // 不激活,只是预缓存
        const nextVersion = await fetchNextVersionManifest();
        const cache = await caches.open(`precache-${nextVersion}`);
        await cache.addAll(nextVersion.assets);
      })()
    );
  }
});

9.5 更新回滚机制

javascript 复制代码
// 如果新版本有问题,支持回滚到旧版本

/**
 * 回滚到上一个版本
 * 在 activate 阶段保留旧缓存(不立即删除)
 */
self.addEventListener('activate', (event) => {
  event.waitUntil(
    (async () => {
      const cacheNames = await caches.keys();
      const appCaches = cacheNames
        .filter(n => n.startsWith('app-cache-'))
        .sort();  // 按名称排序(版本号自然排序)
      
      // 保留最近 2 个版本的缓存(当前 + 上一个)
      const toKeep = appCaches.slice(-2);
      const toDelete = appCaches.filter(n => !toKeep.includes(n));
      
      await Promise.all(toDelete.map(n => caches.delete(n)));
      
      // 将上一个版本记录到 IndexedDB(用于回滚)
      if (appCaches.length >= 2) {
        const previousVersion = appCaches[appCaches.length - 2];
        await dbPut({
          id: 'rollback-info',
          previousCache: previousVersion,
          previousVersion: extractVersion(previousVersion),
          currentVersion: APP_VERSION,
          activatedAt: Date.now(),
        });
      }
      
      await self.clients.claim();
    })()
  );
});

/**
 * 执行回滚(由主线程调用)
 */
async function rollbackToPrevious() {
  const rollbackInfo = await dbGet('rollback-info');
  
  if (!rollbackInfo) {
    showToast('没有可回滚的版本', 'error');
    return;
  }
  
  // 通知 SW 切换到旧缓存
  navigator.serviceWorker.controller.postMessage({
    type: 'ROLLBACK',
    targetCache: rollbackInfo.previousCache,
  });
  
  // 刷新页面
  window.location.reload();
}

9.6 监控与上报

javascript 复制代码
// 更新事件的监控上报(生产环境)

class UpdateAnalytics {
  /**
   * 上报更新事件
   * @param {string} event - 事件名称
   * @param {Object} data - 附加数据
   */
  static track(event, data = {}) {
    const payload = {
      event,
      timestamp: Date.now(),
      swVersion: APP_VERSION,
      userAgent: navigator.userAgent,
      ...data,
    };
    
    // 使用 sendBeacon 确保页面关闭前也能发送
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/api/analytics', JSON.stringify(payload));
    } else {
      fetch('/api/analytics', {
        method: 'POST',
        body: JSON.stringify(payload),
        keepalive: true,
      }).catch(() => {});  // 静默失败
    }
  }
}

// 在关键节点上报:
// install 成功
UpdateAnalytics.track('sw_install_success', { cacheCount: PRECACHE_ASSETS.length });

// install 被暂停
UpdateAnalytics.track('sw_install_paused', { pauseReason: 'user-manual' });

// activate 成功
UpdateAnalytics.track('sw_activate_success', { fromVersion: oldVersion });

// 用户点击"立即更新"
UpdateAnalytics.track('user_update_accepted');

// 用户点击"暂停更新"
UpdateAnalytics.track('user_update_paused', { duration: 86400000 });

// 强制更新
UpdateAnalytics.track('user_force_update');

// 回滚
UpdateAnalytics.track('user_rollback', { toVersion: previousVersion });

十、最佳实践总结:构建健壮的离线优先应用策略

10.1 架构设计原则

复制代码
离线优先 PWA 的 5 条核心原则:

1. 用户拥有最终控制权
   - 任何自动行为都必须提供手动覆盖
   - 暂停/恢复必须一键可达
   - 强制更新通道永远存在

2. 状态持久化且可恢复
   - 暂停状态存储在 IndexedDB(非内存)
   - 应用崩溃后重启能恢复状态
   - 多标签页状态一致

3. 优雅降级
   - SW 不可用时,应用仍能基本运行
   - 缓存损坏时,自动回退到网络
   - IndexedDB 不可用时,降级到 localStorage

4. 版本可追溯
   - 缓存名包含版本号
   - 保留最近 N 个版本用于回滚
   - 更新日志可查询

5. 可观测性
   - 关键事件有日志
   - 生产环境有上报
   - 异常有告警

10.2 代码组织规范

复制代码
推荐的文件组织:

sw.js                    # SW 入口(尽量精简,只做事件绑定)
├── sw-utils.js          # 工具函数(版本比对、缓存管理)
├── sw-cache-strategy.js # 缓存策略(可替换)
└── sw-update-control.js # 暂停/恢复逻辑

js/
├── main.js              # 主线程入口
├── update-manager.js    # 更新管理器(状态机)
├── db.js                # IndexedDB 封装
└── ui.js                # UI 组件

命名规范:
- SW 中的变量/函数:sw 前缀(如 swGetPauseState)
- 主线程中的类:PascalCase(如 UpdateManager)
- 常量:UPPER_SNAKE_CASE(如 CACHE_NAME)
- 事件类型:UPPER_SNAKE_CASE(如 UPDATE_PAUSED)

10.3 上线检查清单

复制代码
PWA 暂停更新功能上线前检查:

□ sw.js 文件无语法错误(使用 ESLint 检查)
□ 所有预缓存资源路径正确(404 会导致 install 失败)
□ HTTPS 证书有效(SW 要求 HTTPS)
□ manifest.json 配置完整(Lighthouse 审计通过)
□ IndexedDB 读写正常(隐私模式下测试)
□ 暂停/恢复流程在 Chrome/Firefox/Safari 中测试通过
□ 多标签页状态同步正常
□ 离线模式下应用可用
□ 弱网模式下不会卡死
□ iOS Safari 降级方案就绪
□ 强制更新通道可用
□ 回滚机制测试通过
□ 监控上报正常
□ 缓存大小在合理范围(< 50MB)
□ 更新横幅不遮挡关键操作区域
□ 无障碍测试通过(键盘导航、屏幕阅读器)

10.4 团队协作约定

复制代码
团队约定:

1. 版本号管理
   - 使用语义化版本(SemVer):MAJOR.MINOR.PATCH
   - 每次修改 sw.js 必须更新版本号
   - 版本号在构建时自动注入(CI/CD)

2. 缓存策略变更
   - 修改缓存策略必须经过 Code Review
   - 必须考虑旧版本缓存的清理
   - 必须在多个浏览器中测试

3. 发布流程
   - 先发 update-config.json(声明更新级别)
   - 再发 sw.js 和静态资源
   - 最后发 HTML(触发用户导航)

4. 回滚预案
   - 每次发布保留上一版本的完整资源
   - CDN 上保留最近 3 个版本
   - 回滚操作 < 5 分钟完成

十一、详细参考资料

11.1 官方文档

资源 链接
MDN Service Worker API https://developer.mozilla.org/zh-CN/docs/Web/API/Service_Worker_API
MDN Cache API https://developer.mozilla.org/zh-CN/docs/Web/API/Cache
MDN IndexedDB API https://developer.mozilla.org/zh-CN/docs/Web/API/IndexedDB_API
MDN BroadcastChannel https://developer.mozilla.org/zh-CN/docs/Web/API/BroadcastChannel
web.dev Service Workers https://web.dev/articles/service-workers-cache-storage
web.dev PWA https://web.dev/progressive-web-apps/
Chrome DevTools SW 调试 https://developer.chrome.com/docs/devtools/progressive-web-apps
Workbox https://developer.chrome.com/docs/workbox
W3C Service Worker 规范 https://www.w3.org/TR/service-workers/

11.2 推荐工具

工具 用途
Lighthouse PWA 审计
PWABuilder PWA 打包
Workbox CLI SW 生成与预缓存
localforage IndexedDB 封装库
idb 轻量 IndexedDB Promise 封装
Puppeteer 自动化测试
MSW API Mock

11.3 开源参考项目

项目 说明
Google Chrome Samples - SW https://github.com/GoogleChrome/samples/tree/gh-pages/service-worker
Workbox https://github.com/GoogleChrome/workbox
PWA Starter https://github.com/pwa-builder/PWAStarter
Offline First Todo https://github.com/nicolasgarnil/offline-first-todo

附录

附录 A:Service Worker API 速查表

复制代码
┌─────────────────────────────────────────────────────────────────┐
│              Service Worker 核心 API 速查                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                   │
│  【注册】                                                         │
│  navigator.serviceWorker.register(url, options)                  │
│  → 返回 Promise<ServiceWorkerRegistration>                       │
│                                                                   │
│  【Registration 属性】                                            │
│  reg.installing   → 正在安装的 SW(或 null)                     │
│  reg.waiting      → 等待激活的 SW(或 null)                     │
│  reg.active       → 当前活跃的 SW(或 null)                     │
│  reg.scope        → 作用域字符串                                  │
│                                                                   │
│  【Registration 方法】                                            │
│  reg.update()     → 手动触发更新检查                              │
│  reg.unregister() → 注销 SW                                      │
│  reg.getNotifications() → 获取通知                                │
│  reg.showNotification() → 显示通知                                │
│  reg.pushManager  → 推送管理                                      │
│  reg.sync         → 后台同步(实验性)                            │
│                                                                   │
│  【SW 全局事件(在 sw.js 中)】                                   │
│  self.addEventListener('install', handler)                       │
│  self.addEventListener('activate', handler)                      │
│  self.addEventListener('fetch', handler)                         │
│  self.addEventListener('message', handler)                       │
│  self.addEventListener('push', handler)                          │
│  self.addEventListener('sync', handler)                          │
│                                                                   │
│  【SW 全局方法】                                                  │
│  self.skipWaiting()        → 跳过 waiting 阶段                   │
│  self.clients.claim()      → 接管所有客户端                      │
│  self.clients.matchAll()   → 获取所有客户端                      │
│  self.clients.get(id)      → 获取特定客户端                      │
│  self.registration         → 获取自身的 registration             │
│                                                                   │
│  【Cache API】                                                    │
│  caches.open(name)         → 打开/创建缓存                       │
│  caches.keys()             → 获取所有缓存名                      │
│  caches.delete(name)       → 删除缓存                            │
│  caches.match(request)     → 在所有缓存中查找                    │
│  cache.put(req, res)       → 存入缓存                            │
│  cache.match(request)      → 在特定缓存中查找                    │
│  cache.addAll(urls)        → 批量缓存                            │
│  cache.delete(request)     → 删除特定条目                        │
│  cache.keys()              → 获取所有缓存的 request              │
│                                                                   │
│  【主线程通信】                                                   │
│  navigator.serviceWorker.controller.postMessage(data)            │
│  navigator.serviceWorker.addEventListener('message', handler)    │
│  navigator.serviceWorker.addEventListener('controllerchange', h) │
│                                                                   │
│  【ExtendableEvent】                                              │
│  event.waitUntil(promise)  → 延长事件生命周期                    │
│                                                                   │
│  【FetchEvent】                                                   │
│  event.request             → 原始 Request 对象                   │
│  event.respondWith(promise)→ 提供自定义响应                      │
│  event.clientId            → 发起请求的客户端 ID                 │
│                                                                   │
└─────────────────────────────────────────────────────────────────┘

附录 B:完整项目源码清单

复制代码
文件清单及行数估算:

index.html              ~120 行
manifest.json           ~35 行
sw.js                   ~250 行
sw-utils.js             ~80 行
css/style.css           ~200 行
js/main.js              ~100 行
js/update-manager.js    ~180 行
js/db.js                ~120 行
js/ui.js                ~250 行
offline.html            ~30 行
package.json            ~20 行

总计:约 1385 行代码

附录 C:调试命令与工具速查

bash 复制代码
# ===== Chrome DevTools Console 常用命令 =====

# 查看 SW 注册信息
navigator.serviceWorker.getRegistration().then(r => console.log(r))

# 查看当前控制页面的 SW
console.log(navigator.serviceWorker.controller)

# 手动触发更新
navigator.serviceWorker.getRegistration().then(r => r.update())

# 注销 SW
navigator.serviceWorker.getRegistration().then(r => r.unregister())

# 查看所有缓存
caches.keys().then(names => console.log(names))

# 查看特定缓存内容
caches.open('app-cache-v2.3.1').then(c => c.keys()).then(keys => console.log(keys))

# 删除所有缓存
caches.keys().then(names => Promise.all(names.map(n => caches.delete(n))))

# 查看 IndexedDB(需要手动操作 DevTools)
# Application → IndexedDB → pwa-update-control

# 向 SW 发送消息
navigator.serviceWorker.controller.postMessage({ type: 'GET_VERSION' })

# ===== 命令行工具 =====

# 使用 http-server 启动本地服务
npx http-server -p 3000 -c-1 .
# -c-1 禁用缓存(开发用)

# 使用 serve(支持 SPA 路由)
npx serve -l 3000 .

# Lighthouse 审计
npx lighthouse http://localhost:3000 --view

# 检查 SW 文件语法
npx eslint sw.js --no-eslintrc --env serviceworker

附录 D:浏览器兼容性矩阵

复制代码
┌──────────────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│ 功能             │ Chrome  │ Firefox │ Safari  │ Edge    │ Samsung │
│                  │ 80+     │ 78+     │ 14+     │ 80+     │ 13+     │
├──────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ Service Worker   │ ✅      │ ✅      │ ✅      │ ✅      │ ✅      │
│ Cache API        │ ✅      │ ✅      │ ✅      │ ✅      │ ✅      │
│ IndexedDB        │ ✅      │ ✅      │ ✅      │ ✅      │ ✅      │
│ BroadcastChannel │ ✅      │ ✅      │ ✅ 15.4+│ ✅      │ ✅      │
│ clients.claim()  │ ✅      │ ✅      │ ✅      │ ✅      │ ✅      │
│ skipWaiting()    │ ✅      │ ✅      │ ✅      │ ✅      │ ✅      │
│ Background Sync  │ ✅      │ ❌      │ ❌      │ ✅      │ ✅      │
│ Push API         │ ✅      │ ✅      │ ✅ 16.4+│ ✅      │ ✅      │
│ Navigation Preload│ ✅     │ ✅      │ ❌      │ ✅      │ ✅      │
│ Periodic Sync    │ ✅      │ ❌      │ ❌      │ ✅      │ ✅      │
│ Network Info API │ ✅      │ ❌      │ ❌      │ ✅      │ ✅      │
│ importScripts()  │ ✅      │ ✅      │ ✅      │ ✅      │ ✅      │
│ ES Module SW     │ ✅ 91+  │ ❌      │ ❌      │ ✅ 91+  │ ❌      │
├──────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│ iOS Safari 限制  │ -       │ -       │ ⚠️      │ -       │ -       │
│ - 7天缓存上限    │         │         │ ⚠️      │         │         │
│ - 无 BG Sync    │         │         │ ❌      │         │         │
│ - 存储限制      │         │         │ ⚠️      │         │         │
└──────────────────┴─────────┴─────────┴─────────┴─────────┴─────────┘

图例:✅ 完全支持 | ⚠️ 部分支持/有限制 | ❌ 不支持
数据截至 2026 年 7 月

完整 CSS 样式文件

css 复制代码
/* css/style.css */

/* ===== CSS 变量 ===== */
:root {
  --color-primary: #1a73e8;
  --color-primary-dark: #1557b0;
  --color-success: #2e7d32;
  --color-warning: #e65100;
  --color-danger: #c62828;
  --color-info: #0277bd;
  --color-bg: #f5f5f5;
  --color-surface: #ffffff;
  --color-text: #212121;
  --color-text-secondary: #757575;
  --radius: 8px;
  --shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  --transition: 0.3s ease;
}

/* ===== 基础重置 ===== */
*, *::before, *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: var(--color-bg);
  color: var(--color-text);
  line-height: 1.6;
  min-height: 100vh;
}

/* ===== 更新横幅 ===== */
.update-banner {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 20px;
  background: var(--color-primary);
  color: white;
  box-shadow: var(--shadow);
  transform: translateY(0);
  transition: transform var(--transition);
}

.update-banner.hidden {
  transform: translateY(-100%);
  pointer-events: none;
}

.banner-text {
  flex: 1;
  font-weight: 500;
}

/* ===== 按钮 ===== */
.btn {
  padding: 8px 16px;
  border: none;
  border-radius: var(--radius);
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: all var(--transition);
  white-space: nowrap;
}

.btn:hover {
  opacity: 0.9;
  transform: translateY(-1px);
}

.btn:active {
  transform: translateY(0);
}

.btn:focus-visible {
  outline: 3px solid var(--color-primary);
  outline-offset: 2px;
}

.btn-primary { background: white; color: var(--color-primary); }
.btn-secondary { background: rgba(255,255,255,0.2); color: white; }
.btn-warning { background: var(--color-warning); color: white; }
.btn-success { background: var(--color-success); color: white; }
.btn-danger { background: var(--color-danger); color: white; }

.btn-large {
  padding: 12px 24px;
  font-size: 16px;
}

/* ===== 容器 ===== */
.container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

/* ===== 状态面板 ===== */
.status-panel, .control-panel, .log-panel {
  background: var(--color-surface);
  border-radius: var(--radius);
  padding: 20px;
  margin-bottom: 20px;
  box-shadow: var(--shadow);
}

.status-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 12px;
}

.status-item {
  display: flex;
  justify-content: space-between;
  padding: 8px 12px;
  background: var(--color-bg);
  border-radius: 4px;
}

.status-item .label {
  color: var(--color-text-secondary);
}

.status-item .value {
  font-weight: 600;
}

/* ===== 控制按钮 ===== */
.control-buttons {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 12px;
}

/* ===== 日志面板 ===== */
.log-container {
  max-height: 300px;
  overflow-y: auto;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 13px;
  background: #1e1e1e;
  color: #d4d4d4;
  padding: 12px;
  border-radius: 4px;
}

.log-entry {
  padding: 4px 0;
  border-bottom: 1px solid #333;
}

.log-entry .time {
  color: #6a9955;
  margin-right: 8px;
}

.log-placeholder {
  color: #666;
  font-style: italic;
}

/* ===== 设置面板 ===== */
.settings-panel {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  width: 320px;
  background: var(--color-surface);
  box-shadow: -4px 0 16px rgba(0,0,0,0.1);
  padding: 24px;
  transform: translateX(0);
  transition: transform var(--transition);
  z-index: 999;
}

.settings-panel.hidden {
  transform: translateX(100%);
}

.setting-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 0;
  border-bottom: 1px solid #eee;
}

/* ===== Toast 通知 ===== */
.toast {
  position: fixed;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%) translateY(100px);
  padding: 12px 24px;
  border-radius: var(--radius);
  color: white;
  font-weight: 500;
  box-shadow: var(--shadow);
  transition: transform var(--transition);
  z-index: 2000;
}

.toast.show {
  transform: translateX(-50%) translateY(0);
}

.toast.info { background: var(--color-info); }
.toast.success { background: var(--color-success); }
.toast.warning { background: var(--color-warning); }
.toast.error { background: var(--color-danger); }

/* ===== 响应式 ===== */
@media (max-width: 600px) {
  .container { padding: 12px; }
  .control-buttons { grid-template-columns: 1fr 1fr; }
  .update-banner { flex-wrap: wrap; }
  .settings-panel { width: 100%; }
}

/* ===== 暗色模式 ===== */
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #121212;
    --color-surface: #1e1e1e;
    --color-text: #e0e0e0;
    --color-text-secondary: #9e9e9e;
  }
}

/* ===== 减少动画(无障碍) ===== */
@media (prefers-reduced-motion: reduce) {
  * {
    transition: none !important;
    animation: none !important;
  }
}

离线回退页面

html 复制代码
<!-- offline.html -->
<!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 {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      font-family: -apple-system, sans-serif;
      background: #f5f5f5;
      color: #333;
      text-align: center;
      padding: 20px;
    }
    .icon { font-size: 64px; margin-bottom: 20px; }
    h1 { font-size: 24px; margin-bottom: 12px; }
    p { color: #666; max-width: 400px; }
    button {
      margin-top: 24px;
      padding: 12px 32px;
      background: #1a73e8;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 16px;
      cursor: pointer;
    }
    button:hover { background: #1557b0; }
  </style>
</head>
<body>
  <div class="icon">📡</div>
  <h1>当前处于离线状态</h1>
  <p>无法连接到网络。已缓存的内容仍可正常使用。请检查网络连接后重试。</p>
  <button onclick="window.location.reload()">重新加载</button>
  
  <script>
    // 监听网络恢复事件,自动刷新
    window.addEventListener('online', () => {
      window.location.reload();
    });
  </script>
</body>
</html>

版权声明:本文为原创技术教程,所有代码示例均可自由使用(MIT 协议)。文中引用的 API 文档内容来源于 MDN Web Docs(CC BY-SA 2.5 许可)。

最后更新:2026 年 7 月

适用环境:Chrome 80+ / Firefox 78+ / Safari 14+ / Edge 80+


--- 全文完 ---

相关推荐
不如摸鱼去1 小时前
Wot UI 2.3.0 发布:二维码组件来了,Open Wot 与 wot-starter 同步更新
前端·ui·微信小程序·前端框架·uni-app
Cloud_bread2 小时前
从ReAct到自主闭环:Agentic Coding核心执行引擎的技术演进
前端·react.js·前端框架
cidy_982 小时前
OptMem 使用教程
前端
杉氧2 小时前
用 Compose 挑战交互与动效天花板:ComposeCraftLab 开源实验室全解析
android·前端·kotlin
Canace2 小时前
AI 生成到 90% 突然断了:你的解决方案是?
前端·人工智能
TinssonTai2 小时前
Vite 8 版 Chrome 插件全家桶,popup/options/sidepanel 一次集齐
前端·vue.js
玉鸯2 小时前
让 Agent 面向用户:AG-UI 协议构建 Agent 前端
前端·python·agent
程序员黑豆3 小时前
鸿蒙应用开发 @Extend 装饰器使用教程
前端·harmonyos
杨先生哦3 小时前
【2026热端攻防系列 10/12】前端凭据安全深度攻防:Cookie/Storage劫持、会话固定、凭据泄露与浏览器最新加固方案
前端·笔记·安全·web安全