
前端离线暂停更新策略: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 本文目标与读者指引
本文目标:手把手带你实现一套完整的"用户可控暂停更新"机制,包括:
- 理解 Service Worker 更新的完整生命周期
- 搭建一个可运行的 PWA 项目
- 实现暂停/恢复更新的核心逻辑
- 设计用户友好的交互界面
- 掌握测试验证方法
- 避开常见的坑
读者要求:
- 了解 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 访问能力(无
document、window) - 完全异步(大量使用 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+
--- 全文完 ---