Merge pull request #51 from Moe-Sakura/dev

Refactor service worker implementation and update caching strategies
This commit is contained in:
Asuna
2025-12-27 03:18:25 +08:00
committed by GitHub
3 changed files with 161 additions and 333 deletions

View File

@@ -1,300 +1,191 @@
// Service Worker - PWA 支持(增强缓存版)
// 尽可能多地缓存文件以提升加载速度
const SW_VERSION = self.__SW_VERSION__ || Date.now().toString(36);
const CACHE_NAME = `searchgal-cache-${SW_VERSION}`;
// Service Worker - PWA 支持
// 版本由构建工具注入,开发时使用时间戳
const SW_VERSION = self.__SW_VERSION__ || Date.now().toString(36)
const CACHE_NAME = `searchgal-${SW_VERSION}`
// 缓存静态资源(尽可能多)
const CACHEABLE_PATTERNS = [
/\/assets\/.*\.(js|css|mjs)(\?.*)?$/i, // Vite 构建的静态资源
/\.(woff2?|ttf|otf|eot)(\?.*)?$/i, // 字体
/\.(png|jpg|jpeg|gif|webp|svg|ico|avif)(\?.*)?$/i, // 图片
/\.(mp3|wav|ogg|m4a|aac|flac)(\?.*)?$/i, // 音频snd-lib 音效)
/\.(mp4|webm|ogv)(\?.*)?$/i, // 视频
/\.(json|xml|txt)(\?.*)?$/i, // 数据文件
/\.(wasm)(\?.*)?$/i, // WebAssembly
/\/manifest\.json$/i, // PWA manifest
/\/favicon/i, // Favicon 相关
/\/apple-touch-icon/i, // iOS 图标
/\/android-chrome/i, // Android 图标
];
// 预缓存的核心资源(安装时缓存)
const PRECACHE_URLS = [
'/',
'/index.html',
'/manifest.json',
];
// 缓存静态资源模式
const CACHEABLE_EXTS = /\.(js|css|mjs|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|webp|svg|ico|avif|mp3|wav|ogg|m4a|aac|flac|wasm)(\?.*)?$/i
const CACHEABLE_PATHS = /\/(manifest\.json|favicon|apple-touch-icon|android-chrome)/i
// 永不缓存
const NO_CACHE_PATTERNS = [
/\/api\//,
/\/sw\.js$/,
/sockjs-node/,
/__vite/,
/hot-update/,
/\.map$/, // Source maps
];
const NO_CACHE = /\/(api|sw\.js|sockjs-node|__vite|hot-update)|\.map$/
// 预缓存资源
const PRECACHE = ['/', '/index.html', '/manifest.json']
// ============================================
// 安装事件 - 预缓存核心资源
// 生命周期事件
// ============================================
self.addEventListener('install', (event) => {
console.log(`[SW] Installing version ${SW_VERSION}`);
// 预缓存失败不应阻止 service worker 安装
const precachePromise = caches.open(CACHE_NAME)
.then((cache) => {
console.log('[SW] Precaching core resources');
return cache.addAll(PRECACHE_URLS);
})
.catch((err) => {
console.warn('[SW] Precache failed:', err);
});
event.waitUntil(
precachePromise.then(() => self.skipWaiting())
);
});
// ============================================
// 激活事件 - 清理旧缓存
// ============================================
self.addEventListener('activate', (event) => {
console.log(`[SW] Activating version ${SW_VERSION}`);
event.waitUntil(
self.addEventListener('install', (e) => {
console.log(`[SW] Installing v${SW_VERSION}`)
e.waitUntil(
caches.open(CACHE_NAME)
.then((c) => c.addAll(PRECACHE).catch(() => {}))
.then(() => self.skipWaiting())
)
})
self.addEventListener('activate', (e) => {
console.log(`[SW] Activating v${SW_VERSION}`)
e.waitUntil(
caches.keys()
.then((names) => Promise.all(
names
.filter((name) => name.startsWith('searchgal-cache-') && name !== CACHE_NAME)
.map((name) => caches.delete(name))
.then((keys) => Promise.all(
keys.filter((k) => k.startsWith('searchgal-') && k !== CACHE_NAME).map((k) => caches.delete(k))
))
.then(() => self.clients.claim())
);
});
)
})
// ============================================
// 消息处理
// ============================================
self.addEventListener('message', (event) => {
const { type } = event.data || {};
self.addEventListener('message', (e) => {
const { type } = e.data || {}
if (type === 'GET_VERSION') {
event.ports[0]?.postMessage({ version: SW_VERSION });
e.ports[0]?.postMessage({ version: SW_VERSION })
} else if (type === 'SKIP_WAITING') {
self.skipWaiting();
self.skipWaiting()
} else if (type === 'CLEAR_CACHE') {
event.waitUntil(
e.waitUntil(
caches.keys()
.then((names) => Promise.all(names.map((name) => caches.delete(name))))
.then(() => event.ports[0]?.postMessage({ success: true }))
);
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
.then(() => e.ports[0]?.postMessage({ success: true }))
)
}
});
})
// ============================================
// 请求拦截
// ============================================
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// 跳过非 GET 请求
if (request.method !== 'GET') return;
// 跳过非 HTTP(S) 请求
if (!url.protocol.startsWith('http')) return;
// 跳过永不缓存的模式
if (NO_CACHE_PATTERNS.some((p) => p.test(url.href))) return;
self.addEventListener('fetch', (e) => {
const { request } = e
const url = new URL(request.url)
// 同源请求
if (url.origin === location.origin) {
// HTML 页面请求:网络优先,离线时显示提示
if (request.destination === 'document' || url.pathname === '/' || url.pathname.endsWith('.html')) {
event.respondWith(handlePageRequest(request));
return;
// 跳过:非 GET、非 HTTP、永不缓存、跨域图片
if (
request.method !== 'GET' ||
!url.protocol.startsWith('http') ||
NO_CACHE.test(url.href) ||
(url.origin !== location.origin && request.destination === 'image')
) return
const isSameOrigin = url.origin === location.origin
const isDocument = request.destination === 'document' || url.pathname === '/' || url.pathname.endsWith('.html')
const isCacheable = CACHEABLE_EXTS.test(url.pathname) || CACHEABLE_PATHS.test(url.pathname)
if (isSameOrigin) {
if (isDocument) {
// HTML网络优先离线返回提示页
e.respondWith(networkFirstWithOffline(request))
} else if (isCacheable) {
// 静态资源:缓存优先
e.respondWith(cacheFirst(request))
} else {
// 其他:网络优先
e.respondWith(networkFirst(request))
}
// 可缓存的静态资源:缓存优先(加速)
if (CACHEABLE_PATTERNS.some((p) => p.test(url.pathname) || p.test(url.href))) {
event.respondWith(cacheFirst(request));
return;
}
// 同源其他资源:网络优先,但也缓存
event.respondWith(networkFirst(request));
return;
} else if (isCacheable) {
// 跨域可缓存资源
e.respondWith(cacheFirstCrossOrigin(request))
}
})
// 跨域资源:仅缓存可缓存的静态资源(如 CDN 资源)
if (CACHEABLE_PATTERNS.some((p) => p.test(url.pathname) || p.test(url.href))) {
event.respondWith(cacheFirstCrossOrigin(request));
return;
}
});
// ============================================
// 缓存策略
// ============================================
/**
* 处理页面请求 - 离线时显示联网提示
*/
async function handlePageRequest(request) {
async function cacheFirst(req) {
const cached = await caches.match(req)
if (cached) return cached
try {
const response = await fetch(request);
return response;
const res = await fetch(req)
if (res.ok) {
const cache = await caches.open(CACHE_NAME)
cache.put(req, res.clone())
}
return res
} catch {
// 离线 - 返回联网提示页面
return createOfflinePage();
return new Response('', { status: 503 })
}
}
/**
* 缓存优先(用于静态资源加速)
*/
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
async function networkFirst(req) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
const res = await fetch(req)
if (res.ok) {
const cache = await caches.open(CACHE_NAME)
cache.put(req, res.clone())
}
return response;
return res
} catch {
return new Response('', { status: 503 });
return (await caches.match(req)) || new Response('', { status: 503 })
}
}
/**
* 网络优先
*/
async function networkFirst(request) {
async function networkFirstWithOffline(req) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
return await fetch(req)
} catch {
const cached = await caches.match(request);
return cached || new Response('', { status: 503 });
return offlinePage()
}
}
/**
* 缓存优先(跨域资源)
*/
async function cacheFirstCrossOrigin(request) {
const cached = await caches.match(request);
if (cached) return cached;
async function cacheFirstCrossOrigin(req) {
const cached = await caches.match(req)
if (cached) return cached
try {
// 跨域请求需要设置 mode
const response = await fetch(request, { mode: 'cors', credentials: 'omit' });
if (response.ok && response.type !== 'opaque') {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
const res = await fetch(req, { mode: 'cors', credentials: 'omit' })
if (res.ok && res.type !== 'opaque') {
const cache = await caches.open(CACHE_NAME)
cache.put(req, res.clone())
}
return response;
return res
} catch {
// 跨域失败时返回空响应
return new Response('', { status: 503 });
return new Response('', { status: 503 })
}
}
/**
* 离线提示页面
*/
function createOfflinePage() {
return new Response(
`<!DOCTYPE html>
// ============================================
// 离线页面
// ============================================
function offlinePage() {
return new Response(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>请连接网络 - SearchGal</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>离线 - SearchGal</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #fff5fa 0%, #ffe4f0 100%);
color: #333;
padding: 1.5rem;
}
.container {
text-align: center;
max-width: 400px;
}
.icon {
font-size: 5rem;
margin-bottom: 1.5rem;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
h1 {
font-size: 1.75rem;
margin-bottom: 0.75rem;
background: linear-gradient(135deg, #ff1493, #d946ef);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
p {
color: #666;
line-height: 1.6;
margin-bottom: 2rem;
}
button {
padding: 1rem 2.5rem;
background: linear-gradient(135deg, #ff1493, #d946ef);
color: white;
border: none;
border-radius: 9999px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(255, 20, 147, 0.3);
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(255, 20, 147, 0.4);
}
button:active {
transform: translateY(0);
}
.hint {
margin-top: 2rem;
font-size: 0.875rem;
color: #999;
}
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(135deg,#fff5fa,#ffe4f0);padding:1.5rem}
.c{text-align:center;max-width:360px}
.i{font-size:4rem;margin-bottom:1rem;animation:f 2s ease-in-out infinite}
@keyframes f{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}
h1{font-size:1.5rem;margin-bottom:.5rem;background:linear-gradient(135deg,#ff1493,#d946ef);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
p{color:#666;line-height:1.5;margin-bottom:1.5rem}
button{padding:.875rem 2rem;background:linear-gradient(135deg,#ff1493,#d946ef);color:#fff;border:none;border-radius:9999px;font-size:.9rem;font-weight:500;cursor:pointer;box-shadow:0 4px 12px rgba(255,20,147,.3);transition:transform .2s,box-shadow .2s}
button:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(255,20,147,.4)}
button:active{transform:translateY(0)}
.h{margin-top:1.5rem;font-size:.8rem;color:#999}
</style>
</head>
<body>
<div class="container">
<div class="icon">🌐</div>
<div class="c">
<div class="i">🌐</div>
<h1>需要网络连接</h1>
<p>SearchGal 是一个在线搜索服务,<br>请连接网络后使用</p>
<p>SearchGal 是在线搜索服务,请连接网络后使用</p>
<button onclick="location.reload()">重新连接</button>
<p class="hint">检查你的网络是否正常连接</p>
<p class="h">检查网络是否正常</p>
</div>
</body>
</html>`,
{
status: 503,
statusText: 'Offline',
headers: { 'Content-Type': 'text/html; charset=utf-8' },
}
);
</html>`, {
status: 503,
headers: { 'Content-Type': 'text/html;charset=utf-8' }
})
}
console.log(`[SW] Service Worker loaded, version: ${SW_VERSION}`);
console.log(`[SW] Loaded v${SW_VERSION}`)

View File

@@ -793,7 +793,7 @@ function resetAdvancedApiSettings(playSound = true) {
/* IDE 风格代码编辑器 */
.code-editor {
font-family: "JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace !important;
font-family: Consolas, "Monaco", "JetBrains Mono", "Fira Code", monospace !important;
font-size: 13px !important;
line-height: 1.5 !important;
min-height: 240px !important;

View File

@@ -55,117 +55,54 @@ createProgressFetch()
app.mount('#app')
// Service Worker 更新检测
// 当前激活的 SW 版本(运行时获取)
let activatedSwVersion: string | null = null
// ============================================
// Service Worker 注册与更新
// ============================================
// 获取 SW 版本
async function getSwVersion(sw: ServiceWorker): Promise<string | null> {
return new Promise((resolve) => {
const messageChannel = new MessageChannel()
messageChannel.port1.onmessage = (event) => {
resolve(event.data?.version || null)
}
sw.postMessage({ type: 'GET_VERSION' }, [messageChannel.port2])
// 超时处理
setTimeout(() => resolve(null), 2000)
})
}
// 检查是否有新版本
async function checkForNewVersion(registration: ServiceWorkerRegistration): Promise<boolean> {
const sw = registration.active
if (!sw) {return false}
const currentVersion = await getSwVersion(sw)
// 首次获取版本时记录
if (!activatedSwVersion && currentVersion) {
activatedSwVersion = currentVersion
console.log(`[SW] Current version: ${activatedSwVersion}`)
return false
}
// 版本不同则有更新
if (currentVersion && activatedSwVersion && currentVersion !== activatedSwVersion) {
console.log(`[SW] New version available: ${currentVersion} (was: ${activatedSwVersion})`)
return true
}
return false
}
// 触发 SW 更新
function triggerSwUpdate(registration: ServiceWorkerRegistration) {
const waitingSw = registration.waiting
if (waitingSw) {
waitingSw.postMessage({ type: 'SKIP_WAITING' })
}
// 刷新页面
window.location.reload()
}
// 自动应用更新(不显示弹窗)
function autoApplyUpdate(registration: ServiceWorkerRegistration) {
console.log('[SW] Auto-applying update...')
triggerSwUpdate(registration)
}
// 注册 Service Worker (PWA 支持)
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js')
const reg = await navigator.serviceWorker.register('/sw.js')
console.log('[SW] Registered')
// 检查更
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 有新版本可用,自动更新
autoApplyUpdate(registration)
}
})
// 新版本检测
reg.addEventListener('updatefound', () => {
const worker = reg.installing
if (!worker) {
return
}
worker.addEventListener('statechange', () => {
// 新 SW 安装完成且有旧 SW 控制页面 = 有更新
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
console.log('[SW] Update available, activating...')
worker.postMessage({ type: 'SKIP_WAITING' })
}
})
})
// 首次获取当前版本
if (registration.active) {
activatedSwVersion = await getSwVersion(registration.active)
console.log(`[SW] Registered, version: ${activatedSwVersion || 'unknown'}`)
}
// 定期检查版本(每 5 分钟)
setInterval(async () => {
try {
await registration.update()
const hasNewVersion = await checkForNewVersion(registration)
if (hasNewVersion) {
autoApplyUpdate(registration)
}
} catch {
// 静默处理检查失败
// 新 SW 激活后刷新页面
let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) {
return
}
}, 5 * 60 * 1000)
refreshing = true
console.log('[SW] New version activated, reloading...')
window.location.reload()
})
// 定期检查更新5 分钟)
setInterval(() => reg.update().catch(() => {}), 5 * 60 * 1000)
// 页面可见时检查更新
document.addEventListener('visibilitychange', async () => {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
try {
await registration.update()
} catch {
// 静默处理
}
reg.update().catch(() => {})
}
})
} catch {
// 静默处理注册失败
// 静默处理
}
})
}
// 导出更新函数供组件使用
;(window as Window & { triggerSwUpdate?: typeof triggerSwUpdate }).triggerSwUpdate = triggerSwUpdate