PWA 与离线应用
PWA 概述
渐进式 Web 应用(Progressive Web App,PWA)是一种利用现代 Web 技术构建的应用程序,它能够为用户提供接近原生应用的使用体验。PWA 并不是一项单一的技术,而是由一系列 Web 技术和最佳实践组合而成的开发理念。其核心目标在于让 Web 应用具备可靠(Reliable)、快速(Fast)和沉浸(Engaging)三大特性。
核心三要素
PWA 的实现依赖三个核心技术支柱:
- Web App Manifest:一个 JSON 格式的清单文件,定义了应用的元数据,包括名称、图标、主题色、显示模式等。浏览器通过解析 Manifest 文件,能够将 Web 应用以独立窗口的形式安装到用户设备桌面。
- Service Worker:一种独立于浏览器主线程的脚本代理,运行在后台,负责拦截网络请求、管理缓存、处理推送消息等关键任务。没有 Service Worker,就无法实现离线能力和消息推送功能。
- HTTPS 安全要求:出于安全考量,Service Worker 只能在通过 HTTPS 协议的页面中注册和工作。在开发环境下,
localhost被视为安全源,允许调试,但生产环境必须配置 HTTPS。
渐进增强
PWA 遵循渐进增强(Progressive Enhancement)的设计理念。这意味着应用在任何浏览器中都能提供基本可用的功能,而在支持 PWA 技术的现代浏览器中,则能解锁安装、离线、推送等高级能力。开发者不需要为不同浏览器维护多个版本,浏览器会自动根据自身能力选择启用对应的功能。
Manifest 文件详解
Manifest 文件是 PWA 的身份标识,通常命名为 manifest.json,通过 <link rel="manifest" href="/manifest.json"> 引入。一个典型的 Manifest 文件示例如下:
{
"name": "我的阅读器",
"short_name": "阅读器",
"description": "一个支持离线阅读的 PWA 应用",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1976d2",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}关键字段说明:
name和short_name:应用的全称和短名称,短名称用于空间有限的场景(如启动器图标下方)。start_url:用户从桌面图标打开应用时加载的起始页面。display:控制应用的显示模式,可选值包括fullscreen(全屏)、standalone(独立窗口,隐藏浏览器 UI)、minimal-ui(最小化浏览器 UI)和browser(普通浏览器标签)。background_color:启动画面背景色,在样式表加载完成前生效。theme_color:应用的主题色,会影响浏览器地址栏等 UI 元素的颜色。icons:应用的图标数组,推荐提供 192x192 和 512x512 两种尺寸,purpose: "maskable"表示图标支持自适应遮罩。
HTTPS 的必要性
Service Worker 拥有拦截和修改网络请求的能力,这意味着它可能成为中间人攻击的载体。因此,浏览器强制要求 Service Worker 必须在 HTTPS 环境下注册。HTTPS 不仅保护了用户数据的传输安全,也确保了 Service Worker 脚本在传输过程中未被篡改。对于开发调试,浏览器允许在 localhost 和通过 --unsafely-treat-insecure-origin-as-secure 标志启动的 Chrome 实例中注册 Service Worker。
Service Worker 生命周期
Service Worker 的生命周期与普通 Web Worker 不同,它经历注册、安装、激活、闲置和更新等多个阶段。
注册
Service Worker 的注册通过 JavaScript 调用 navigator.serviceWorker.register() 方法完成。注册时需要指定 Service Worker 脚本的 URL 和作用域(scope)。作用域决定了 Service Worker 能够拦截哪些路径下的请求。
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then(registration => {
console.log('Service Worker 注册成功', registration.scope);
})
.catch(error => {
console.log('Service Worker 注册失败', error);
});
}注册过程是幂等的:如果 Service Worker 已注册且脚本未变化,浏览器不会重复安装。可以通过 registration.update() 强制检查更新。
作用域
Service Worker 的作用域(Scope)默认是其脚本所在的路径。例如,/sw.js 的作用域是 /,而 /blog/sw.js 的作用域是 /blog/。可以通过 register() 的第二个参数显式设置作用域,但作用域只能缩小不能扩大(静态作用域限制)。作用域决定了 Service Worker 能够拦截的 fetch 事件范围——超出作用域的请求不会被拦截。
安装阶段
注册完成后,浏览器会触发 Service Worker 的 install 事件。这是预缓存资源的最佳时机:
self.addEventListener('install', event => {
event.waitUntil(
caches.open('v1').then(cache => {
return cache.addAll([
'/',
'/styles/main.css',
'/scripts/app.js',
'/offline.html'
]);
})
);
});event.waitUntil() 方法会延长安装事件的等待时间,直到传入的 Promise 完成。如果 Promise 被拒绝(reject),安装失败,Service Worker 不会激活。
激活阶段
安装成功后,Service Worker 进入 activate 事件。这是清理旧缓存的理想时机:
self.addEventListener('activate', event => {
const cacheWhitelist = ['v2'];
event.waitUntil(
caches.keys().then(keyList => {
return Promise.all(keyList.map(key => {
if (!cacheWhitelist.includes(key)) {
return caches.delete(key);
}
}));
})
);
});默认情况下,新激活的 Service Worker 不会立即接管已有的客户端页面,直到所有页面关闭并重新打开。调用 self.skipWaiting() 和 clients.claim() 可以强制立即激活并接管控制。
更新机制
当用户访问 PWA 时,浏览器会在后台静默检查 Service Worker 脚本是否有更新(字节级别的差异检测)。如果检测到更新:
- 新版本的 Service Worker 会安装,但不会立即激活,进入等待状态。
- 旧的 Service Worker 继续控制已打开的页面。
- 当所有受控页面关闭后,新版本自动激活。
- 开发者也可以调用
self.skipWaiting()跳过等待阶段,并通过registration.update()触发更新检查。
Service Worker 与主线程通信
Service Worker 运行在独立的线程中,与主线程通过消息机制通信。
主线程向 Service Worker 发送消息:
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_ARTICLE', url: '/article/123' });Service Worker 接收消息并回复:
self.addEventListener('message', event => {
if (event.data.type === 'CACHE_ARTICLE') {
caches.open('articles').then(cache => {
cache.add(event.data.url);
});
event.ports[0].postMessage({ status: 'cached' });
}
});主线程接收消息:
navigator.serviceWorker.addEventListener('message', event => {
console.log('收到 SW 消息:', event.data);
});通过 MessageChannel 或 event.ports 可以实现双向通信,而 BroadcastChannel API 则适合一对多的广播场景。
Cache Storage API
Cache Storage API 是 Service Worker 中实现离线缓存的核心机制,它提供了对 Request/Response 对的存储能力。与浏览器 HTTP 缓存不同,Cache Storage 完全由开发者通过代码控制。
基本用法
// 打开缓存
caches.open('my-cache').then(cache => {
// 添加资源
cache.add('/api/data.json'); // 自动 fetch 并存储
cache.addAll(['/', '/styles.css']); // 批量添加
cache.put('/api/data', new Response('{"ok":true}')); // 手动构造响应
});caches.keys() 返回所有缓存名称的列表,caches.delete(name) 删除指定缓存,cache.match(request) 查找匹配的缓存条目。
缓存策略
不同的资源类型适合不同的缓存策略,以下是五种核心策略及其适用场景。
Cache First(缓存优先)
对于不常变化的静态资源(如 CSS、字体、Logo),优先从缓存读取,缓存未命中时才请求网络:
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});优点:速度极快,完全离线可用。 缺点:缓存的资源可能不是最新版本。
Network First(网络优先)
对于需要实时数据但允许降级体验的场景(如新闻列表),优先请求网络,网络失败时回退到缓存:
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.then(response => {
const clone = response.clone();
caches.open('dynamic').then(cache => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
});优点:优先展示最新内容,离线时提供降级体验。 缺点:网络慢时延迟较高。
Stale While Revalidate(闲置时重新验证)
这是折中策略——立即返回缓存内容,同时在后台发起网络请求更新缓存,下次访问时使用新内容:
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => {
const fetchPromise = fetch(event.request).then(response => {
const clone = response.clone();
caches.open('dynamic').then(cache => cache.put(event.request, clone));
return response;
});
return cached || fetchPromise;
})
);
});优点:响应极快,后台自动更新缓存,用户无感知。 缺点:首次访问仍需要网络。
Cache Only(仅缓存)
仅从缓存读取,不发起网络请求,适用于完全离线的应用资源:
self.addEventListener('fetch', event => {
event.respondWith(caches.match(event.request));
});适用场景:离线游戏、已预缓存的静态文档。
Network Only(仅网络)
不读取缓存,所有请求必须通过网络,适用于支付接口、实时数据流等不可使用缓存的场景:
self.addEventListener('fetch', event => {
// 不调用 event.respondWith,请求直接发送到网络
// 或者显式转发
fetch(event.request);
});混合策略实践
在实际项目中,通常会根据请求类型组合多种策略:
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// API 请求使用 Network First
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request));
return;
}
// 静态资源使用 Cache First
if (event.request.destination === 'style' || event.request.destination === 'script') {
event.respondWith(cacheFirst(event.request));
return;
}
// 其他请求使用 Stale While Revalidate
event.respondWith(staleWhileRevalidate(event.request));
});离线策略
构建离线应用不仅涉及缓存 API 数据,还需要从架构层面设计离线体验。
App Shell 架构
App Shell 架构将应用的 UI 骨架(外壳)与动态内容分离。外壳包括头部导航、侧边栏、页脚等不会频繁变化的结构性 HTML/CSS/JS。这些资源在首次访问时被预缓存,后续打开应用时直接从缓存加载,实现瞬间启动。
// 在 install 事件中预缓存 App Shell
const SHELL_FILES = [
'/',
'/shell/header.html',
'/shell/sidebar.html',
'/styles/shell.css',
'/scripts/app.js',
'/offline.html'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open('shell-v1').then(cache => cache.addAll(SHELL_FILES))
);
});动态内容(如文章正文)则在用户访问时通过运行时缓存策略存储。
Prefetch 预缓存
在 Service Worker 安装阶段,开发者可以预先缓存一组关键资源。这些资源在注册时就被下载并存储,确保后续访问即使是离线状态也能正常工作。预缓存的资源列表应在构建时生成,避免硬编码。
Lazy Cache 按需缓存
对于非关键资源或海量数据,采用按需缓存的策略更为合理。用户首次访问某个页面或资源时,缓存该资源的副本,后续访问即可离线使用。可以结合 IndexedDB 记录用户已访问的资源列表。
IndexedDB 离线存储
对于结构化数据(如文章内容、用户偏好、离线队列),Cache Storage 并非最佳选择。IndexedDB 提供了键值对存储和索引查询能力,更适合存储 JSON 数据。
// 打开 IndexedDB 数据库
const dbPromise = idb.openDB('reader-store', 1, {
upgrade(db) {
db.createObjectStore('articles', { keyPath: 'id' });
db.createObjectStore('pending-sync', { autoIncrement: true });
}
});
// 存储文章
async function cacheArticle(article) {
const db = await dbPromise;
await db.put('articles', article);
}
// 读取离线文章
async function getOfflineArticle(id) {
const db = await dbPromise;
return await db.get('articles', id);
}IndexedDB 的存储空间远大于 Cache Storage(通常可达几十 MB 甚至更多),且支持游标遍历、范围查询等高级操作,适合存储离线阅读的文章列表。
SyncManager 后台同步
SyncManager API 允许 Service Worker 在网络恢复时自动重试失败的操作。例如,用户离线时发表的评论可以先存储在 IndexedDB 中,网络恢复后自动提交:
主线程注册同步:
if ('sync' in navigator.serviceWorker) {
navigator.serviceWorker.ready.then(registration => {
registration.sync.register('sync-comments');
});
}Service Worker 处理同步事件:
self.addEventListener('sync', event => {
if (event.tag === 'sync-comments') {
event.waitUntil(syncPendingComments());
}
});event.waitUntil() 的时长有限制(通常约 5 分钟),超时的同步会被丢弃。同步名称(tag)相同的多个注册会被合并为一个事件。
消息推送
PWA 支持通过 Push API 和 Notification API 实现服务器推送通知,即使浏览器没有打开也能接收消息。
Push API
Push API 允许服务器向 Service Worker 推送消息,由以下组件配合工作:
- 订阅管理:客户端通过
registration.pushManager.subscribe()生成订阅对象。 - 推送服务:浏览器使用自有的推送服务(如 Chrome 的 FCM、Firefox 的 Autopush)中转消息。
- 应用服务器:后端将消息发送到推送服务,再由推送服务转发给客户端。
// 订阅推送
async function subscribePush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array('YOUR_VAPID_PUBLIC_KEY')
});
// 将 subscription 发送到服务器存储
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription)
});
}userVisibleOnly: true 要求所有推送都必须显示通知,防止暗中推送。applicationServerKey 是 VAPID(Voluntary Application Server Identification)协议的公共密钥,用于标识应用服务器。
接收推送消息
推送消息在 Service Worker 的 push 事件中接收:
self.addEventListener('push', event => {
const data = event.data ? event.data.json() : {};
const options = {
body: data.body || '您收到一条新消息',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge.png',
vibrate: [200, 100, 200],
data: { url: data.url || '/' }
};
event.waitUntil(
self.registration.showNotification(data.title || 'PWA 通知', options)
);
});event.waitUntil() 确保通知在 Promise 完成前不会关闭。self.registration.showNotification() 在设备上显示系统级通知。
权限管理
通过 Notification API 请求权限:
if (Notification.permission === 'granted') {
// 已有权限,可以订阅
} else if (Notification.permission === 'denied') {
// 用户已拒绝,不宜再次请求
} else {
// 请求权限
const result = await Notification.requestPermission();
if (result === 'granted') {
// 权限获得
}
}最佳实践:在用户执行特定操作(如点击"开启通知"按钮)时请求权限,而非页面加载时立即请求,这样可以提高授权率。
通知交互
通知不仅用于展示信息,还能响应用户的点击操作:
self.addEventListener('notificationclick', event => {
event.notification.close();
// 根据点击动作处理
if (event.action === 'archive') {
archiveArticle(event.notification.data.articleId);
} else {
// 默认点击:打开相关页面
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});notificationclick 事件中的 event.action 字段用于区分用户点击的是通知主体还是操作按钮。通过 clients.openWindow() 可以打开或聚焦到指定页面。
PWA 安装
安装提示
当满足以下条件时,Chrome 等浏览器会自动触发 beforeinstallprompt 事件,显示安装横幅:
- 应用已注册 Service Worker
- 已提供有效的 Manifest 文件
- 通过 HTTPS 访问
- 用户有足够的交互行为
开发者可以监听该事件,延迟或自定义安装提示:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', event => {
// 阻止默认安装提示
event.preventDefault();
deferredPrompt = event;
// 显示自定义安装按钮
installButton.style.display = 'block';
installButton.addEventListener('click', async () => {
// 显示安装对话框
deferredPrompt.prompt();
const result = await deferredPrompt.userChoice;
if (result.outcome === 'accepted') {
console.log('用户接受了安装');
}
deferredPrompt = null;
installButton.style.display = 'none';
});
});显示模式
应用安装后运行在独立窗口中,可以通过 CSS 媒体查询区分显示模式:
@media (display-mode: standalone) {
body { padding-top: env(safe-area-inset-top); }
.browser-nav { display: none; }
}
@media (display-mode: fullscreen) {
body { background: #000; }
}display-mode 媒体查询支持的值包括 fullscreen、standalone、minimal-ui 和 browser。开发者可以在 navigator.standalone(iOS Safari)或通过 CSS 检测这些模式,提供差异化的 UI。
桌面图标与启动画面
安装到桌面后,操作系统会根据 Manifest 中指定的 icons 和 background_color 生成应用图标和启动画面。启动画面是 Web 应用加载前的"闪屏",由 background_color 和 name 字段组合生成,显示为纯色背景 + 应用名称。启动画面没有复杂的自定义能力,但可以通过 background_color 和 theme_color 配合应用图标,给用户平滑的启动体验。
PWA 性能优化
Lighthouse PWA 审计
Lighthouse 是 Chrome DevTools 内置的审计工具,可以对 PWA 进行全面评分。PWA 审计清单包括:
- 注册 Service Worker
- 提供有效的 Manifest 文件
- HTTPS 已启用
- 离线可用时返回 200
start_url在离线时能正常响应- 页面加载性能(FCP、LCP、TTI)
- 可安装性(满足安装条件)
- 跨浏览器兼容性
运行 Lighthouse 审计后,开发者应重点关注 PWA 类别下的失败项,逐一修复。
离线体验优化
离线体验的核心在于确保用户在无网络环境下仍能完成核心操作。优化方向包括:
- 优雅降级:当检测到网络不可用时,显示友好的离线页面而非空白页或错误页面。
- 离线数据访问:使用 IndexedDB 持久化用户上次访问的数据,确保离线时能看到历史内容。
- 操作队列:用户在离线时的操作(如表单提交)应保存在本地队列中,网络恢复后自动执行。
- 离线占位符:对于图片等大资源,使用占位符或模糊缩略图替代。
// 网络状态检测
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
function updateOnlineStatus() {
document.body.dataset.online = navigator.onLine;
if (navigator.onLine) {
// 网络恢复,触发后台同步
navigator.serviceWorker.ready.then(reg => {
reg.sync.register('sync-pending');
});
}
}加载速度优化
- 预缓存关键资源:在
install事件中预缓存 HTML 外壳、关键 CSS/JS。 - 按需加载:非关键资源使用运行时缓存,仅在首次访问时缓存。
- 缓存版本管理:每次更新 Service Worker 时升级缓存版本号,清理旧缓存。
- 资源预加载:利用
<link rel="preload">和 Service Worker 配合,在页面渲染前加载关键资源。 - 流式响应:Service Worker 可以使用 Streams API 将页面分块流式传递给客户端,加速首屏渲染。
安全考量
- Service Worker 脚本只能通过 HTTPS 加载(除
localhost外)。 - 避免在 Service Worker 中缓存敏感数据(如用户令牌、个人信息)。
- 缓存的内容应与服务器端验证结合,确保数据完整性。
- 及时清理过期缓存,避免用户设备存储空间被无限制占用(触发浏览器存储配额限制)。
- 使用 Content Security Policy(CSP)防止 Service Worker 被恶意篡改。
总结
PWA 代表 Web 应用向原生体验演进的重要方向。通过 Service Worker、Cache Storage API、Manifest 文件和 Push API 的组合使用,开发者可以构建出在恶劣网络条件下依然可用的可靠应用。离线能力不再只是原生应用的专利——Web 应用通过合理的缓存策略和离线架构设计,同样能为用户提供流畅、可靠的体验。随着浏览器对 PWA 技术支持力度的不断加大(如 Badging API、File System Access API、Window Controls Overlay 等),PWA 的能力边界正在持续扩展,是前端开发者必须掌握的重要技术方向。