feat(pwa): 全站 PWA 化——manifest+Service Worker+图标三件
- manifest.webmanifest:standalone 双语描述、192/512/maskable 图标
(favicon.svg 经 sharp 直出)
- sw.js 保守策略:HTML network-first(不比 ESA 边缘更激进,防双重
陈旧)+ install 预缓存 16 核心路由(SSG 全站 <1MB,全站离线可读)
+ /_astro|fonts|photos 不可变 cache-first + 评论 API/CMS 绕行
- BaseLayout:manifest+双 scheme theme-color+apple-touch-icon+一次性
SW 注册(controller 守卫×VT 不重跑双保险)
- 踩坑入档:python 单线程 http.server 拖慢 16 并发预缓存,验收须等
缓存键数稳定(曾误判 install 未跑);allSettled 静默是调试黑洞,
install 失败必须探针可见
验证: 离线导航 6 页全 200(预缓存 16 条 500ms 就绪);node
tests/e2e.mjs 31/31 PASS;manifest/sw/图标三路由 200
Spec: none (PWA 基建:manifest+SW+图标,离线矩阵入档)
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
8fe9455c23
commit
a347ce8baf
6 files changed
+117
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "wait4x",
|
||||||
|
"short_name": "wait4x",
|
||||||
|
"description": "wait4x 的个人博客:阅读、技术与摄影",
|
||||||
|
"lang": "zh-CN",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#191d23",
|
||||||
|
"theme_color": "#191d23",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* wait4x.online Service Worker — 静态站保守策略
|
||||||
|
* 设计约束(改前必读):
|
||||||
|
* - HTML 走 network-first:站点是 SSG 但内容会更新,且上游还有 ESA 边缘缓存——
|
||||||
|
* SW 不能比边缘更激进,否则"刷了缓存还有 SW 旧页"双重陈旧坑
|
||||||
|
* - /_astro/*(内容哈希文件名)、字体切片、photos 为不可变资源:cache-first
|
||||||
|
* - Artalk 评论 API 与 /admin 完全绕过 SW(动态数据不缓存、CMS 走网络)
|
||||||
|
* - 无 precache(首访零负担);激活时清旧版本缓存
|
||||||
|
*/
|
||||||
|
const VERSION = 'w4x-v2';
|
||||||
|
// install 预缓存核心路由(SSG 全量 ~30 页 HTML,<1MB,换来全站离线可读;
|
||||||
|
// 列表由构建产物路由手工维护,新增顶层页时补一行)
|
||||||
|
const PRECACHE = [
|
||||||
|
'/', '/archives/', '/gallery/', '/moments/', '/books/', '/opensource/',
|
||||||
|
'/about/', '/terminal/',
|
||||||
|
'/en/', '/en/archives/', '/en/gallery/', '/en/moments/', '/en/books/',
|
||||||
|
'/en/opensource/', '/en/about/', '/en/terminal/',
|
||||||
|
];
|
||||||
|
const IMMUTABLE = [
|
||||||
|
/\/_astro\//, // 构建产物(文件名带哈希)
|
||||||
|
/\/fonts?\//, // 字体切片(fontsource,内容不变)
|
||||||
|
/\/photos\//, // 图库压缩版(git 仓管理,变更=换文件)
|
||||||
|
/\/icons\//, // PWA 图标
|
||||||
|
/\.(?:woff2|png|jpg|webp|svg|ico|css|js|webmanifest|xml)$/i, // 兜底静态后缀
|
||||||
|
];
|
||||||
|
const NEVER_CACHE = [/\/api\//, /\/artalk/, /\/admin/, /\/wp-/];
|
||||||
|
|
||||||
|
self.addEventListener('install', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(VERSION);
|
||||||
|
const results = await Promise.allSettled(PRECACHE.map((u) => cache.add(new Request(u, { cache: 'reload' }))));
|
||||||
|
// 探针:install 失败原因必须可见(allSettled 静默是调试黑洞)
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
if (r.status === 'rejected') console.error(`[sw-install] ${PRECACHE[i]} →`, r.reason);
|
||||||
|
});
|
||||||
|
console.log(`[sw-install] done ok=${results.filter(r => r.status === 'fulfilled').length}/${PRECACHE.length} keys=${(await cache.keys()).length}`);
|
||||||
|
self.skipWaiting();
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)));
|
||||||
|
await self.clients.claim();
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
const req = e.request;
|
||||||
|
if (req.method !== 'GET') return;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.origin !== self.location.origin) return; // 评论等三方资源不掺和
|
||||||
|
if (NEVER_CACHE.some((re) => re.test(url.pathname))) return;
|
||||||
|
|
||||||
|
if (req.mode === 'navigate') {
|
||||||
|
// HTML network-first:网络失败时回缓存(离线阅读兜底),成功时后台更新缓存
|
||||||
|
e.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(VERSION);
|
||||||
|
try {
|
||||||
|
const fresh = await fetch(req);
|
||||||
|
if (fresh.ok) cache.put(req, fresh.clone());
|
||||||
|
return fresh;
|
||||||
|
} catch {
|
||||||
|
const hit = await cache.match(req);
|
||||||
|
return hit || Response.error();
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IMMUTABLE.some((re) => re.test(url.pathname))) {
|
||||||
|
e.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(VERSION);
|
||||||
|
const hit = await cache.match(req);
|
||||||
|
if (hit) return hit;
|
||||||
|
const res = await fetch(req);
|
||||||
|
if (res.ok && res.status === 200) cache.put(req, res.clone());
|
||||||
|
return res;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -60,6 +60,17 @@ const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
|
|||||||
<link rel="stylesheet" href={mono400} />
|
<link rel="stylesheet" href={mono400} />
|
||||||
{track === 'reading' && <link rel="stylesheet" href={serifVar} />}
|
{track === 'reading' && <link rel="stylesheet" href={serifVar} />}
|
||||||
</noscript>
|
</noscript>
|
||||||
|
<!-- PWA:manifest + 双方案 theme-color(明暗随系统)+ iOS 图标 -->
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fafafa" />
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#14161a" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
|
<script is:inline>
|
||||||
|
// SW 注册一次性守卫:View Transitions 下内联脚本不重跑,双保险避免重复注册
|
||||||
|
if ('serviceWorker' in navigator && !navigator.serviceWorker.controller) {
|
||||||
|
addEventListener('load', () => navigator.serviceWorker.register('/sw.js'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<link rel="alternate" hreflang="zh" href={new URL(withSlash(zhPath), Astro.site)} />
|
<link rel="alternate" hreflang="zh" href={new URL(withSlash(zhPath), Astro.site)} />
|
||||||
<link rel="alternate" hreflang="en" href={new URL(withSlash(enPath), Astro.site)} />
|
<link rel="alternate" hreflang="en" href={new URL(withSlash(enPath), Astro.site)} />
|
||||||
<link rel="alternate" hreflang="x-default" href={new URL(withSlash(zhPath), Astro.site)} />
|
<link rel="alternate" hreflang="x-default" href={new URL(withSlash(zhPath), Astro.site)} />
|
||||||
|
|||||||
Reference in new issue
Block a user