mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 06:31:13 +00:00
refactor: serviceworker
重构sw.js,实现更智能的缓存,根据路由设计缓存
This commit is contained in:
parent
df48c01ce4
commit
3e85d18ab5
@ -1,24 +1,157 @@
|
||||
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/webui/'
|
||||
];
|
||||
/**
|
||||
* NapCat WebUI Service Worker
|
||||
*
|
||||
* 路由缓存策略设计:
|
||||
*
|
||||
* 【永不缓存 - Network Only】
|
||||
* - /api/* WebUI API
|
||||
* - /plugin/:id/api/* 插件 API
|
||||
* - /files/theme.css 动态主题 CSS
|
||||
* - /webui/fonts/CustomFont.woff 用户自定义字体
|
||||
* - WebSocket / SSE 连接
|
||||
*
|
||||
* 【强缓存 - Cache First】
|
||||
* - /webui/assets/* 前端静态资源(带 hash)
|
||||
* - /webui/fonts/* 内置字体(排除 CustomFont)
|
||||
* - q1.qlogo.cn QQ 头像
|
||||
*
|
||||
* 【网络优先 - Network First】
|
||||
* - /webui/* (HTML 导航) SPA 页面
|
||||
* - /plugin/:id/page/* 插件页面
|
||||
* - /plugin/:id/files/* 插件文件系统静态资源
|
||||
*
|
||||
* 【后台更新 - Stale-While-Revalidate】
|
||||
* - /plugin/:id/mem/* 插件内存静态资源
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'napcat-webui-v{{VERSION}}';
|
||||
|
||||
// 缓存配置
|
||||
const CACHE_CONFIG = {
|
||||
// 静态资源缓存最大条目数
|
||||
MAX_STATIC_ENTRIES: 200,
|
||||
// QQ 头像缓存最大条目数
|
||||
MAX_AVATAR_ENTRIES: 100,
|
||||
// 插件资源缓存最大条目数
|
||||
MAX_PLUGIN_ENTRIES: 50,
|
||||
};
|
||||
|
||||
// ============ 路由匹配辅助函数 ============
|
||||
|
||||
/**
|
||||
* 检查是否为永不缓存的请求
|
||||
*/
|
||||
function isNeverCache (url, request) {
|
||||
// WebUI API
|
||||
if (url.pathname.startsWith('/api/')) return true;
|
||||
|
||||
// 插件 API: /plugin/:id/api/*
|
||||
if (/^\/plugin\/[^/]+\/api(\/|$)/.test(url.pathname)) return true;
|
||||
|
||||
// 动态主题 CSS
|
||||
if (url.pathname === '/files/theme.css' || url.pathname.endsWith('/files/theme.css')) return true;
|
||||
|
||||
// 用户自定义字体
|
||||
if (url.pathname.includes('/webui/fonts/CustomFont.woff')) return true;
|
||||
|
||||
// WebSocket 升级请求
|
||||
if (request.headers.get('Upgrade') === 'websocket') return true;
|
||||
|
||||
// SSE 请求
|
||||
if (request.headers.get('Accept') === 'text/event-stream') return true;
|
||||
|
||||
// Socket 相关
|
||||
if (url.pathname.includes('/socket')) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 WebUI 静态资源(强缓存)
|
||||
*/
|
||||
function isWebUIStaticAsset (url) {
|
||||
// /webui/assets/* - 前端构建产物(带 hash)
|
||||
if (url.pathname.startsWith('/webui/assets/')) return true;
|
||||
|
||||
// /webui/fonts/* - 内置字体(排除 CustomFont)
|
||||
if (url.pathname.startsWith('/webui/fonts/') &&
|
||||
!url.pathname.includes('CustomFont.woff')) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为外部头像(强缓存)
|
||||
*/
|
||||
function isQLogoAvatar (url) {
|
||||
return url.hostname === 'q1.qlogo.cn' || url.hostname === 'q2.qlogo.cn';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为插件文件系统静态资源(网络优先)
|
||||
*/
|
||||
function isPluginStaticFiles (url) {
|
||||
// /plugin/:id/files/*
|
||||
return /^\/plugin\/[^/]+\/files(\/|$)/.test(url.pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为插件内存静态资源(Stale-While-Revalidate)
|
||||
*/
|
||||
function isPluginMemoryAsset (url) {
|
||||
// /plugin/:id/mem/*
|
||||
return /^\/plugin\/[^/]+\/mem(\/|$)/.test(url.pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为插件页面(Network First)
|
||||
*/
|
||||
function isPluginPage (url) {
|
||||
// /plugin/:id/page/*
|
||||
return /^\/plugin\/[^/]+\/page(\/|$)/.test(url.pathname);
|
||||
}
|
||||
|
||||
// ============ 缓存管理函数 ============
|
||||
|
||||
/**
|
||||
* 限制缓存条目数量
|
||||
*/
|
||||
async function trimCache (cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
if (keys.length > maxEntries) {
|
||||
// 删除最早的条目
|
||||
const deleteCount = keys.length - maxEntries;
|
||||
for (let i = 0; i < deleteCount; i++) {
|
||||
await cache.delete(keys[i]);
|
||||
}
|
||||
console.log(`[SW] Trimmed ${deleteCount} entries from cache`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型获取缓存限制
|
||||
*/
|
||||
function getCacheLimitForRequest (url) {
|
||||
if (isQLogoAvatar(url)) return CACHE_CONFIG.MAX_AVATAR_ENTRIES;
|
||||
if (isPluginStaticFiles(url) || isPluginMemoryAsset(url)) return CACHE_CONFIG.MAX_PLUGIN_ENTRIES;
|
||||
return CACHE_CONFIG.MAX_STATIC_ENTRIES;
|
||||
}
|
||||
|
||||
// ============ Service Worker 生命周期 ============
|
||||
|
||||
// 安装阶段:预缓存核心文件
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting(); // 强制立即接管
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
// 这里的资源如果加载失败不应该阻断 SW 安装
|
||||
return cache.addAll(ASSETS_TO_CACHE).catch(err => console.warn('Failed to cache core assets', err));
|
||||
})
|
||||
);
|
||||
console.log('[SW] Installing new version:', CACHE_NAME);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// 激活阶段:清理旧缓存
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating new version:', CACHE_NAME);
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
(async () => {
|
||||
// 清理所有旧版本缓存
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName.startsWith('napcat-webui-') && cacheName !== CACHE_NAME) {
|
||||
console.log('[SW] Deleting old cache:', cacheName);
|
||||
@ -26,107 +159,178 @@ self.addEventListener('activate', (event) => {
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
// 立即接管所有客户端
|
||||
await self.clients.claim();
|
||||
})()
|
||||
);
|
||||
self.clients.claim(); // 立即控制所有客户端
|
||||
});
|
||||
|
||||
// 拦截请求
|
||||
// ============ 请求拦截 ============
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
const request = event.request;
|
||||
|
||||
// 1. API 请求:仅网络 (Network Only)
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.includes('/socket')) {
|
||||
// 1. 永不缓存的请求 - Network Only
|
||||
if (isNeverCache(url, request)) {
|
||||
// 不调用 respondWith,让请求直接穿透到网络
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 强缓存策略 (Cache First)
|
||||
// - 外部 QQ 头像 (q1.qlogo.cn)
|
||||
// - 静态资源 (assets, fonts)
|
||||
// - 常见静态文件后缀
|
||||
const isQLogo = url.hostname === 'q1.qlogo.cn';
|
||||
const isCustomFont = url.pathname.includes('CustomFont.woff'); // 用户自定义字体,不强缓存
|
||||
const isThemeCss = url.pathname.includes('files/theme.css'); // 主题 CSS,不强缓存
|
||||
const isStaticAsset = url.pathname.includes('/webui/assets/') ||
|
||||
url.pathname.includes('/webui/fonts/');
|
||||
const isStaticFile = /\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot|ico)$/i.test(url.pathname);
|
||||
|
||||
if (!isCustomFont && !isThemeCss && (isQLogo || isStaticAsset || isStaticFile)) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((response) => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// 跨域请求 (qlogo) 需要 mode: 'no-cors' 才能缓存 opaque response,
|
||||
// 但 fetch(event.request) 默认会继承 request 的 mode。
|
||||
// 如果是 img标签发起的请求,通常 mode 是 no-cors 或 cors。
|
||||
// 对于 opaque response (status 0), cache API 允许缓存。
|
||||
return fetch(event.request).then((response) => {
|
||||
// 对 qlogo 允许 status 0 (opaque)
|
||||
// 对其他资源要求 status 200
|
||||
const isValidResponse = response && (
|
||||
response.status === 200 ||
|
||||
response.type === 'basic' ||
|
||||
(isQLogo && response.type === 'opaque')
|
||||
);
|
||||
|
||||
if (!isValidResponse) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const responseToCache = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
// 2. WebUI 静态资源 - Cache First
|
||||
if (isWebUIStaticAsset(url)) {
|
||||
event.respondWith(cacheFirst(request, url));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. HTML 页面 / 导航请求 -> 网络优先 (Network First)
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const responseToCache = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
// 3. QQ 头像 - Cache First(支持 opaque response)
|
||||
if (isQLogoAvatar(url)) {
|
||||
event.respondWith(cacheFirstWithOpaque(request, url));
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 其他 Same-Origin 请求 -> Stale-While-Revalidate
|
||||
// 优先返回缓存,同时后台更新缓存,保证下次访问是新的
|
||||
// 4. 插件文件系统静态资源 - Network First
|
||||
if (isPluginStaticFiles(url)) {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 插件内存静态资源 - Stale-While-Revalidate
|
||||
if (isPluginMemoryAsset(url)) {
|
||||
event.respondWith(staleWhileRevalidate(request, url));
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. 插件页面 - Network First
|
||||
if (isPluginPage(url)) {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. HTML 导航请求 - Network First
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// 8. 其他同源请求 - Network Only(避免意外缓存)
|
||||
if (url.origin === self.location.origin) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cachedResponse) => {
|
||||
const fetchPromise = fetch(event.request).then((networkResponse) => {
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const responseToCache = networkResponse.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
}
|
||||
return networkResponse;
|
||||
});
|
||||
// 如果有缓存,返回缓存;否则等待网络
|
||||
return cachedResponse || fetchPromise;
|
||||
})
|
||||
);
|
||||
// 不缓存,直接穿透
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认:网络优先
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
// 9. 其他外部请求 - Network Only
|
||||
return;
|
||||
});
|
||||
|
||||
// ============ 缓存策略实现 ============
|
||||
|
||||
/**
|
||||
* Cache First 策略
|
||||
* 优先从缓存返回,缓存未命中则从网络获取并缓存
|
||||
*/
|
||||
async function cacheFirst (request, url) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, networkResponse.clone());
|
||||
// 异步清理缓存
|
||||
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||
}
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.error('[SW] Cache First fetch failed:', error);
|
||||
return new Response('Network error', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache First 策略(支持 opaque response,用于跨域头像)
|
||||
*/
|
||||
async function cacheFirstWithOpaque (request, url) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
// opaque response 的 status 是 0,但仍可缓存
|
||||
const isValidResponse = networkResponse && (
|
||||
networkResponse.status === 200 ||
|
||||
networkResponse.type === 'opaque'
|
||||
);
|
||||
|
||||
if (isValidResponse) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, networkResponse.clone());
|
||||
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||
}
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.error('[SW] Cache First (opaque) fetch failed:', error);
|
||||
return new Response('Network error', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network First 策略
|
||||
* 优先从网络获取,网络失败则返回缓存
|
||||
*/
|
||||
async function networkFirst (request) {
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.log('[SW] Network First: network failed, trying cache');
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
return new Response('Offline', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale-While-Revalidate 策略
|
||||
* 立即返回缓存(如果有),同时后台更新缓存
|
||||
*/
|
||||
async function staleWhileRevalidate (request, url) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const cachedResponse = await cache.match(request);
|
||||
|
||||
// 后台刷新缓存
|
||||
const fetchPromise = fetch(request).then((networkResponse) => {
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
cache.put(request, networkResponse.clone());
|
||||
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
|
||||
}
|
||||
return networkResponse;
|
||||
}).catch((error) => {
|
||||
console.log('[SW] SWR background fetch failed:', error);
|
||||
return null;
|
||||
});
|
||||
|
||||
// 如果有缓存,立即返回缓存
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// 没有缓存,等待网络
|
||||
const networkResponse = await fetchPromise;
|
||||
if (networkResponse) {
|
||||
return networkResponse;
|
||||
}
|
||||
|
||||
return new Response('Network error', { status: 503 });
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user