-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathpwa.js
More file actions
427 lines (384 loc) · 14 KB
/
pwa.js
File metadata and controls
427 lines (384 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
const CACHE_NAME = "static-cache";
self.addEventListener("install", (installEvent) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map((cacheName) => {
// 清理旧的缓存
if (cacheName !== CACHE_NAME) {
console.log('删除旧缓存:', cacheName);
return caches.delete(cacheName);
}
})
);
// 确保新的 Service Worker 立即接管页面
await self.clients.claim();
})()
);
});
// 监听消息事件
self.addEventListener('message', (event) => {
// 支持手动清除缓存
if (event.data && event.data.type === 'CLEAR_CACHE') {
event.waitUntil(
(async () => {
await caches.delete(CACHE_NAME);
console.log('手动清除静态资源缓存');
// 通知页面缓存已清除
const clients = await self.clients.matchAll();
clients.forEach(client => {
client.postMessage({ type: 'CACHE_CLEARED' });
});
})()
);
}
// 支持主动检查更新 (由页面定时触发)
if (event.data && event.data.type === 'CHECK_UPDATE') {
event.waitUntil(
(async () => {
const pageUrl = event.data.url;
if (!pageUrl) return;
// 始终使用根 URL 检查版本,与导航缓存键保持一致
// 避免不同 SPA 路由各自独立检测到版本变化,引发级联 reload
const canonicalUrl = new URL('/', new URL(pageUrl));
canonicalUrl.search = '';
const canonicalRequest = new Request(canonicalUrl.toString());
const cachedResponse = await caches.match(canonicalRequest);
await fetchAndCache(canonicalRequest, cachedResponse, true, canonicalRequest)
.catch(err => console.log('[SW] 定时检查更新失败 (网络或其它原因)', err));
})()
);
}
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
const url = new URL(event.request.url);
// 忽略本地请求 (比如 127.0.0.1 或 localhost),直接回退到网络,不缓存
if (url.hostname === '127.0.0.1' || url.hostname === 'localhost') {
return;
}
const path = url.pathname;
// 1. 导航请求(Navigation):如 index.html
// 策略:Network First + 离线 fallback
// 每次等待服务器返回最新 index.html,确保页面与静态资源版本始终一致。
// 网络失败时 fallback 到缓存,仍无缓存则返回离线页面。
// JS/CSS 仍走 Cache First,整体加载速度不受影响。
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
try {
// 无论访问哪个 SPA 路由,统一使用根 URL 作为 HTML shell 的缓存键
// 所有路由共享同一缓存条目,避免各路由独立检测到版本变更后级联触发多次 reload
const canonicalUrl = new URL('/', url);
canonicalUrl.search = '';
const cacheKeyRequest = new Request(canonicalUrl.toString());
const cachedResponse = await caches.match(cacheKeyRequest);
// 传递 clone 给 fetchAndCache 用于 ETag/版本比较,保留原始引用作 fallback
const cachedResponseForUpdate = cachedResponse ? cachedResponse.clone() : null;
try {
// Network First:等待网络,fetchAndCache 同时完成缓存写入与版本变更检测
const response = await fetchAndCache(event.request, cachedResponseForUpdate, true, cacheKeyRequest);
return response;
} catch (error) {
// 网络失败:fallback 到缓存
if (cachedResponse) {
return cachedResponse;
}
// 无缓存且无网络 -> 离线页面
return new Response(getOfflineHTML(), {
headers: { 'Content-Type': 'text/html' }
});
}
} catch (error) {
return new Response(getOfflineHTML(), {
headers: { 'Content-Type': 'text/html' }
});
}
})()
);
return;
}
// 2. 静态资源请求
if (shouldCache(path)) {
// 检查 URL 是否包含版本号参数
const hasVersion = url.searchParams.has('v') || url.searchParams.has('version') || url.searchParams.has('tmpui_page');
event.respondWith(
(async () => {
if (hasVersion) {
// 策略 A: 带有版本号的资源 -> Cache First
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
return fetchAndCache(event.request);
} else {
// 策略 B: 无版本号资源 -> SWR + ETag
const cachedResponse = await caches.match(event.request);
const networkFetchPromise = fetchAndCache(event.request, cachedResponse);
if (cachedResponse) {
// 有缓存,直接返回,后台更新
networkFetchPromise.catch(err => console.log('Background fetch failed:', err));
return cachedResponse;
}
// 无缓存,等待网络
try {
return await networkFetchPromise;
} catch (e) {
// 失败返回 undefined,由浏览器处理或者后续 failover
console.log('Fetch failed', e);
}
}
})()
);
return;
}
});
// 辅助函数:从 HTML 内容中提取版本号
const getVersionFromHtml = (html) => {
const match = html.match(/"version"\s*:\s*(\d+)/);
return match ? match[1] : null;
};
// 辅助函数:请求并缓存 (支持 ETag 和 Last-Modified 验证)
const fetchAndCache = async (request, cachedResponse = null, isNavigation = false, cacheKeyRequest = null) => {
let finalRequest = request;
// 构造条件请求头
if (cachedResponse) {
const headers = new Headers(request.headers);
let conditionAdded = false;
// 1. 优先使用 ETag
if (cachedResponse.headers.has('ETag')) {
headers.set('If-None-Match', cachedResponse.headers.get('ETag'));
conditionAdded = true;
}
// 2. 如果没有 ETag,尝试使用 Last-Modified
if (!conditionAdded && cachedResponse.headers.has('Last-Modified')) {
headers.set('If-Modified-Since', cachedResponse.headers.get('Last-Modified'));
}
finalRequest = new Request(request, { headers });
} else if (!isNavigation) {
// SW 缓存未命中(通常发生在版本更新清空缓存之后),强制绕过浏览器 HTTP 缓存。
// 直接向服务器请求最新版本,防止浏览器原生 HTTP 缓存仍然返回旧版静态资源。
finalRequest = new Request(request, { cache: 'reload' });
}
try {
const response = await fetch(finalRequest);
// 处理 304 Not Modified
if (response.status === 304) {
return cachedResponse; // 返回缓存的版本
}
// 检查响应是否有效
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// 如果是导航请求,在更新缓存前进行内容比对,防止无限刷新
if (isNavigation && cachedResponse) {
const cacheClone = cachedResponse.clone();
const responseClone = response.clone();
try {
const [oldText, newText] = await Promise.all([
cacheClone.text(),
responseClone.text()
]);
// 1. 尝试比对版本号
const oldVer = getVersionFromHtml(oldText);
const newVer = getVersionFromHtml(newText);
if (oldVer && newVer) {
if (oldVer === newVer) {
// 如果版本号一致,尽管 HTTP 200,我们也认为没更新
// 但为了保险,还是更新一下缓存(虽然内容没变或变了无关紧要的东西),但不发通知
const responseToCache = response.clone();
const cache = await caches.open(CACHE_NAME);
await cache.put(cacheKeyRequest || request, responseToCache);
return response;
}
console.log(`[SW] 版本号变更 ${oldVer} -> ${newVer}`);
} else {
// 2. 无法提取版本号,回退到全文比对
if (oldText === newText) {
return response; // 内容完全一致,不再重复写缓存和发通知
}
}
} catch (e) {
console.error('[SW] 比对出错', e);
}
}
// 服务器返回了新数据 (200),更新缓存
const responseToCache = response.clone();
const cache = await caches.open(CACHE_NAME);
await cache.put(cacheKeyRequest || request, responseToCache);
// 如果是导航请求且之前有缓存(说明是一次更新),清除静态资源缓存并通知页面刷新
if (isNavigation && cachedResponse) {
console.log('[SW] 检测到新版本,清除静态资源缓存并通知刷新');
// 清除已缓存的 JS/CSS/JSON 文件,确保刷新后能获取最新版本
// (防止版本号未同步导致旧缓存持续被 Cache First 策略命中)
try {
const staticCache = await caches.open(CACHE_NAME);
const keys = await staticCache.keys();
await Promise.all(
keys
.filter(req => {
const p = new URL(req.url).pathname.toLowerCase();
return p.endsWith('.js') || p.endsWith('.css') || p.endsWith('.json');
})
.map(req => staticCache.delete(req))
);
console.log('[SW] 静态资源缓存已清除');
} catch (e) {
console.error('[SW] 清除静态资源缓存失败', e);
}
notifyClientsOfUpdate();
}
return response;
} catch (error) {
throw error;
}
};
// 防止并发请求(导航 + 定时检查)在同一时刻重复广播更新通知
let _notifyScheduled = false;
// 通知所有客户端有更新
const notifyClientsOfUpdate = async () => {
if (_notifyScheduled) {
console.log('[SW] 通知已在队列中,跳过重复广播');
return;
}
_notifyScheduled = true;
// 10s 后重置,允许下一次真正的版本变更触发通知
setTimeout(() => { _notifyScheduled = false; }, 10000);
// includeUncontrolled: true 确保即使页面尚未被当前 SW 完全控制(例如首次加载或刷新瞬间)也能收到消息
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' });
console.log(`[SW] 检测到新版本,向 ${clients.length} 个客户端发送通知`);
clients.forEach(client => {
client.postMessage({ type: 'UPDATE_AVAILABLE' });
});
};
const shouldCache = (path) => {
// 排除 index.html 和根路径
if (path === '/' || path.toLowerCase().endsWith('/index.html')) {
return false;
}
const cacheableExtensions = [
// HTML
'.html',
// CSS & JS
'.css', '.js',
// Images
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
// Fonts
'.woff', '.woff2', '.ttf', '.otf', '.eot',
// JSON
'.json',
];
return cacheableExtensions.some(ext => path.toLowerCase().endsWith(ext));
};
const getOfflineHTML = () => {
// 多语言内容
const translations = {
'zh': {
lang: 'zh-CN',
title: '当前已离线',
offline: '离线',
message: '请检查您的网络连接,然后重试。',
retry: '重试'
},
'en': {
lang: 'en',
title: 'Offline',
offline: 'Offline',
message: 'Please check your network connection and try again.',
retry: 'Retry'
},
'ja': {
lang: 'ja',
title: 'オフライン',
offline: 'オフライン',
message: 'ネットワーク接続を確認して、再試行してください。',
retry: '再試行'
},
'es': {
lang: 'es',
title: 'Desconectado',
offline: 'Desconectado',
message: 'Por favor, compruebe su conexión de red y vuelva a intentarlo.',
retry: 'Reintentar'
},
'fr': {
lang: 'fr',
title: 'Hors ligne',
offline: 'Hors ligne',
message: 'Veuillez vérifier votre connexion réseau et réessayer.',
retry: 'Réessayer'
}
};
// 获取浏览器语言
let lang = 'en';
if (typeof navigator !== 'undefined' && navigator.language) {
lang = navigator.language.split('-')[0];
} else if (self && self.navigator && self.navigator.language) {
lang = self.navigator.language.split('-')[0];
}
let t = translations[lang] || translations['en'];
return `
<!DOCTYPE html>
<html lang="${t.lang}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${t.title}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f8f9fa;
color: #3c4858;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
text-align: center;
padding: 20px;
box-sizing: border-box;
}
.container {
max-width: 400px;
}
h1 {
font-size: 24px;
margin-bottom: 10px;
}
p {
font-size: 16px;
margin-bottom: 20px;
}
.retry-btn {
background-color: #506efa;
color: #ffffff;
border: none;
padding: 12px 24px;
border-radius: 5px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease;
-webkit-appearance: none;
}
.retry-btn:hover {
background-color: #3759f9;
}
</style>
</head>
<body>
<div class="container">
<h1>${t.offline}</h1>
<p>${t.message}</p>
<button class="retry-btn" onclick="location.reload()">${t.retry}</button>
</div>
</body>
</html>
`;
};