引言
在移动互联网时代,用户对Web应用的期望已不仅限于可访问性,更要求其具备类似原生应用的体验。Progressive Web App (PWA) 技术的出现,使Web应用能够提供接近原生应用的用户体验,尤其是在网络连接不稳定或完全离线的情况下仍然可以使用。本文将深入探讨如何构建真正离线可用的PWA,帮助开发者掌握这一强大技术的核心要点。
PWA核心技术回顾
在深入探讨离线功能之前,让我们简要回顾PWA的三个核心技术:
- Service Worker:一种运行在浏览器背后的脚本,能够拦截和处理网络请求,是实现离线功能的关键
- Web App Manifest:定义应用的外观和行为的JSON文件
- HTTPS:PWA必须通过安全连接提供服务
Service Worker生命周期
Service Worker的生命周期是理解离线功能的基础:
- 注册:告诉浏览器Service Worker的位置
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker注册成功,作用域为:', registration.scope);
})
.catch(error => {
console.log('Service Worker注册失败:', error);
});
}
- 安装:首次注册或更新时触发,通常用于缓存静态资源
self.addEventListener('install', event => {
event.waitUntil(
caches.open('v1').then(cache => {
return cache.addAll([
'/',
'/index.html',
'/styles.css',
'/script.js',
'/images/logo.png'
]);
})
);
});
- 激活:安装成功后触发,用于清理旧缓存
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(cacheName => {
return cacheName !== 'v1';
}).map(cacheName => {
return caches.delete(cacheName);
})
);
})
);
});
- 拦截请求:激活后开始拦截网络请求
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});
高级缓存策略
1. 缓存优先策略 (Cache First)
适用于不经常变化的静态资源,如图片、CSS、JavaScript文件等。
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// 如果在缓存中找到响应,则返回缓存的版本
if (response) {
return response;
}
// 否则发送网络请求并缓存结果
return fetch(event.request).then(networkResponse => {
// 检查我们收到的是否为有效响应
if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
return networkResponse;
}
// 克隆响应,因为响应是流,只能使用一次
const responseToCache = networkResponse.clone();
caches.open('v1')
.then(cache => {
cache.put(event.request, responseToCache);
});
return networkResponse;
});
})
);
});
2. 网络优先策略 (Network First)
适用于需要最新数据的请求,同时提供离线访问能力。
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.then(response => {
// 如果响应成功,克隆它并存储到缓存中
if (response.status === 200) {
const responseToCache = response.clone();
caches.open('v1')
.then(cache => {
cache.put(event.request, responseToCache);
});
}
return response;
})
.catch(() => {
// 如果网络请求失败,尝试从缓存中获取
return caches.match(event.request);
})
);
});
3. Stale-While-Revalidate
提供快速响应的同时在后台更新缓存。
self.addEventListener('fetch', event => {
event.respondWith(
caches.open('v1').then(cache => {
return cache.match(event.request).then(response => {
const fetchPromise = fetch(event.request).then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// 立即返回缓存的响应,同时更新缓存
return response || fetchPromise;
});
})
);
});
IndexedDB实现离线数据存储
对于需要存储和操作结构化数据的应用,IndexedDB是理想的选择:
// 打开数据库
const openDB = () => {
return new Promise((resolve, reject) => {
const request = indexedDB.open('MyDatabase', 1);
request.onerror = event => {
reject('数据库打开失败');
};
request.onsuccess = event => {
resolve(event.target.result);
};
request.onupgradeneeded = event => {
const db = event.target.result;
// 创建对象仓库
const store = db.createObjectStore('tasks', { keyPath: 'id', autoIncrement: true });
store.createIndex('title', 'title', { unique: false });
store.createIndex('completed', 'completed', { unique: false });
};
});
};
// 添加任务
const addTask = async (task) => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['tasks'], 'readwrite');
const store = transaction.objectStore('tasks');
const request = store.add(task);
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject('添加任务失败');
};
});
};
// 获取所有任务
const getAllTasks = async () => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['tasks'], 'readonly');
const store = transaction.objectStore('tasks');
const request = store.getAll();
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject('获取任务失败');
};
});
};
实现离线数据同步
当网络恢复时,将本地更改同步到服务器:
// 检查网络连接并同步数据
const syncData = async () => {
if (!navigator.onLine) return;
try {
const tasks = await getAllTasks();
const unsynced = tasks.filter(task => !task.synced);
for (const task of unsynced) {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(task)
});
if (response.ok) {
// 更新本地状态为已同步
await updateTaskSyncStatus(task.id, true);
}
}
} catch (error) {
console.error('同步失败:', error);
}
};
// 监听网络恢复事件
window.addEventListener('online', syncData);
提供离线用户体验反馈
确保用户了解应用的离线状态:
// 检测并显示网络状态
const updateNetworkStatus = () => {
const statusEl = document.getElementById('network-status');
if (navigator.onLine) {
statusEl.textContent = '在线';
statusEl.classList.remove('offline');
statusEl.classList.add('online');
// 尝试同步数据
syncData();
} else {
statusEl.textContent = '离线';
statusEl.classList.remove('online');
statusEl.classList.add('offline');
}
};
// 初始化时检查
updateNetworkStatus();
// 监听网络状态变化
window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);
完整示例:离线待办事项应用
下面是一个完整的离线待办事项应用示例:
index.html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离线待办事项</title>
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/styles.css">
<meta name="theme-color" content="#2196F3">
</head>
<body>
<header>
<h1>我的待办事项</h1>
<div id="network-status"></div>
</header>
<main>
<form id="task-form">
<input type="text" id="task-input" placeholder="添加新任务..." required>
<button type="submit">添加</button>
</form>
<ul id="tasks-list"></ul>
</main>
<script src="/app.js"></script>
<script>
// 注册 Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker注册成功');
})
.catch(error => {
console.error('Service Worker注册失败:', error);
});
}
</script>
</body>
</html>
app.js
// IndexedDB操作
const dbPromise = idb.openDB('tasks-store', 1, {
upgrade(db) {
db.createObjectStore('tasks', { keyPath: 'id', autoIncrement: true });
}
});
// DOM元素
const taskForm = document.getElementById('task-form');
const taskInput = document.getElementById('task-input');
const tasksList = document.getElementById('tasks-list');
const networkStatus = document.getElementById('network-status');
// 更新网络状态显示
const updateNetworkStatus = () => {
if (navigator.onLine) {
networkStatus.textContent = '在线';
networkStatus.className = 'online';
syncTasks();
} else {
networkStatus.textContent = '离线';
networkStatus.className = 'offline';
}
};
// 添加任务到IndexedDB
const addTask = async (title) => {
const db = await dbPromise;
const tx = db.transaction('tasks', 'readwrite');
const store = tx.objectStore('tasks');
await store.add({
title,
completed: false,
createdAt: new Date().toISOString(),
synced: false
});
await tx.done;
await loadTasks();
if (navigator.onLine) {
syncTasks();
}
};
// 从IndexedDB加载任务
const loadTasks = async () => {
const db = await dbPromise;
const tasks = await db.getAll('tasks');
tasksList.innerHTML = '';
tasks.forEach(task => {
const li = document.createElement('li');
li.dataset.id = task.id;
li.classList.toggle('completed', task.completed);
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = task.completed;
checkbox.addEventListener('change', () => toggleTaskStatus(task.id, checkbox.checked));
const span = document.createElement('span');
span.textContent = task.title;
const syncStatus = document.createElement('small');
syncStatus.className = task.synced ? 'synced' : 'unsynced';
syncStatus.textContent = task.synced ? '已同步' : '未同步';
li.appendChild(checkbox);
li.appendChild(span);
li.appendChild(syncStatus);
tasksList.appendChild(li);
});
};
// 切换任务状态
const toggleTaskStatus = async (id, completed) => {
const db = await dbPromise;
const tx = db.transaction('tasks', 'readwrite');
const store = tx.objectStore('tasks');
const task = await store.get(id);
task.completed = completed;
task.synced = false;
await store.put(task);
await tx.done;
await loadTasks();
if (navigator.onLine) {
syncTasks();
}
};
// 同步任务到服务器
const syncTasks = async () => {
const db = await dbPromise;
const tasks = await db.getAll('tasks');
const unsyncedTasks = tasks.filter(task => !task.synced);
for (const task of unsyncedTasks) {
try {
// 这里使用模拟API调用
const response = await fetch('/api/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(task)
}).catch(() => ({ ok: false }));
if (response.ok) {
const tx = db.transaction('tasks', 'readwrite');
const store = tx.objectStore('tasks');
task.synced = true;
await store.put(task);
await tx.done;
}
} catch (error) {
console.error('同步失败:', error);
}
}
await loadTasks();
};
// 事件监听器
taskForm.addEventListener('submit', event => {
event.preventDefault();
const title = taskInput.value.trim();
if (title) {
addTask(title);
taskInput.value = '';
}
});
window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);
// 初始化
updateNetworkStatus();
loadTasks();
sw.js
const CACHE_NAME = 'tasks-app-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/app.js',
'/styles.css',
'/manifest.json',
'/idb.js',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png'
];
// 安装Service Worker并缓存静态资源
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
return cache.addAll(ASSETS_TO_CACHE);
})
.then(() => self.skipWaiting())
);
});
// 激活并清理旧缓存
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(cacheName => {
return cacheName !== CACHE_NAME;
}).map(cacheName => {
return caches.delete(cacheName);
})
);
}).then(() => self.clients.claim())
);
});
// 处理网络请求
self.addEventListener('fetch', event => {
// 静态资源使用缓存优先策略
if (event.request.url.match(/\.(html|css|js|json|png|jpg|jpeg|svg|gif)$/)) {
event.respondWith(
caches.match(event.request)
.then(response => {
return response || fetch(event.request).then(fetchRes => {
return caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, fetchRes.clone());
return fetchRes;
});
});
})
);
} else if (event.request.url.includes('/api/')) {
// API请求使用网络优先,失败时将请求缓存起来等待后续同步
event.respondWith(
fetch(event.request)
.catch(() => {
return new Response(JSON.stringify({
error: 'Currently offline. Your request will sync when online.'
}), {
headers: { 'Content-Type': 'application/json' },
status: 503
});
})
);
}
});
// 后台同步
self.addEventListener('sync', event => {
if (event.tag === 'sync-tasks') {
event.waitUntil(syncTasks());
}
});
// 模拟同步任务
const syncTasks = async () => {
// 这部分代码在真实应用中需要与主线程共享IndexedDB逻辑
// 为了简化示例,这里只是展示概念
console.log('后台同步任务执行');
};
manifest.json
{
"name": "离线待办事项",
"short_name": "待办事项",
"description": "一个支持离线使用的待办事项应用",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196F3",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
测试与调试
Lighthouse审计
使用Chrome DevTools中的Lighthouse进行PWA审计,确保你的应用满足PWA的要求。
模拟离线状态
- 在Chrome DevTools中勾选"Network"面板的"Offline"选项
- 使用
navigator.onLine
属性检测网络状态 - 监听
online
和offline
事件
Service Worker调试
在Chrome DevTools中,进入"Application"面板,查看"Service Workers"部分:
- 查看Service Worker状态
- 模拟更新周期
- 查看缓存内容
最佳实践
-
渐进增强:确保基本功能在不支持Service Worker的浏览器上仍然可用
-
定期更新缓存:实现缓存版本管理,避免过时的资源
-
防止缓存污染:小心处理用户特定数据,不要将敏感信息存入缓存
-
适当的后备机制:当资源既不在缓存中也无法从网络获取时,提供合理的后备内容
-
定期清理IndexedDB:实现数据过期策略,避免无限存储
-
注意配额限制:浏览器对存储空间有限制,监控使用情况并处理超出配额的情况
-
提供明确的离线指示器:让用户知道应用当前是在线还是离线状态
结论
构建真正离线可用的PWA需要深入理解Service Worker、缓存策略和客户端存储技术。通过合理组合这些技术,我们可以打造出在各种网络条件下都能提供出色用户体验的Web应用。
随着浏览器对PWA技术的支持不断增强,离线Web应用将会变得越来越强大,为用户提供更加流畅、可靠的体验,同时为开发者提供更广阔的创新空间。